Deux achats en même temps, un seul solde : l'achat atomique
Temps 2 · séance 2 · ~40 min
- Expliquer, chronologie à l'appui, comment deux achats lancés au même instant dépensent deux fois le même solde.
- Provoquer une panne au milieu d'un achat et relever le stock, le solde et la table des achats.
- Écrire un achat qui se fait en entier ou pas du tout, avec
DB::transaction(). - Relire le solde et le stock sous un verrou, avec
lockForUpdate(), avant de les contrôler. - Prouver la correction par les tests de la faille et par la panne rejouée.
1. Starbucks, 2015
En mai 2015, Egor Homakov, le développeur du chapitre 2.03, publie une faille du site de Starbucks, qui permettait de transférer le solde d'une carte cadeau vers une autre. Avec deux navigateurs, il envoie la confirmation du même transfert au même instant, et au sixième essai les mêmes 5 $ arrivent deux fois : ses trois cartes achetées 15 $ en valent 20. Il dépense ce solde en magasin pour prouver la faille, puis prévient Starbucks. L'entreprise corrige la faille une dizaine de jours après qu'il a trouvé le bon contact, d'après lui, et parle d'activité frauduleuse.
Dans le Tamagotchi, le magasin a la même forme. Il lit un solde, le contrôle, puis le débite.
2. Deux achats, un seul solde
Voici la méthode qui enregistre un achat, dans app/Http/Controllers/PurchaseController.php, telle que vous l'avez laissée à la fin du chapitre 2.08 :
public function store(Request $request, Item $item): RedirectResponse
{
$user = $request->user();
$request->validate([
'creature_id' => ['required', 'integer', Rule::exists('creatures', 'id')->where('user_id', $user->id)],
'quantity' => ['required', 'integer', 'min:1', 'max:10'],
]);
$creature = Creature::findOrFail($request->integer('creature_id'));
$quantity = $request->integer('quantity');
$total = $item->price_cents * $quantity;
if ($item->stock < $quantity) {
return back()->withErrors(['quantity' => 'Il n\'en reste pas assez en stock.']);
}
if ($user->balance_cents < $total) {
return back()->withErrors(['quantity' => 'Votre solde est insuffisant.']);
}
$item->update(['stock' => $item->stock - $quantity]);
$user->balance_cents -= $total;
$user->save();
Purchase::create([
'user_id' => $user->id,
'item_id' => $item->id,
'creature_id' => $creature->id,
'quantity' => $quantity,
'total_cents' => $total,
]);
$gauge = $item->effect;
$creature->update([
$gauge => min(100, $creature->getAttribute($gauge) + $item->effect_value * $quantity),
]);
Inertia::flash('toast', ['type' => 'success', 'message' => "Achat effectué pour {$creature->name}."]);
return to_route('shop.index');
}
Le prix vient de la base, la quantité est bornée, le stock et le solde sont contrôlés. Pour un achat seul, tout est juste. Le défaut apparaît quand deux requêtes arrivent ensemble.
$item et $user sont lus au début de la requête : l'article par la liaison de modèle, le joueur par le middleware auth. Les deux contrôles comparent ces valeurs lues. Les écritures viennent après, et save() envoie à la base le solde calculé en mémoire. Un serveur en ligne traite plusieurs requêtes en même temps, chacune dans son propre processus PHP. Rien n'empêche donc une seconde requête de lire le solde entre le contrôle et l'écriture de la première.
Mallory a 5,00 €. La balle rebondissante coûte 3,20 €. Elle ouvre le magasin dans deux onglets et clique sur « Acheter » dans les deux, au même instant :
Les deux requêtes ont lu 5,00 €, et les deux contrôles ont dit oui. Chacune écrit ensuite le solde qu'elle a calculé : 5,00 − 3,20 = 1,80 €. save() envoie la valeur 180, en centimes, et non « 320 de moins ». La seconde écriture recouvre donc la première avec la même valeur. La base garde un solde de 1,80 €, un stock de 29 et deux achats. Mallory a reçu deux balles et en a payé une. Le stock dit 29 alors que deux balles sont parties. Le magasin livre donc un objet jamais payé, et son stock ne correspond plus à ce qu'il a vendu. Cette situation, où le résultat dépend de l'ordre dans lequel deux requêtes avancent, s'appelle une situation de compétition, race condition en anglais.
Le développeur ne l'a pas vue, parce qu'il teste un clic à la fois. Ses tests aussi envoient une requête, attendent la réponse, puis envoient la suivante. La faille n'existe que dans l'intervalle de quelques millisecondes entre la lecture et l'écriture. Il suffit pourtant de recommencer : Homakov a réussi au sixième essai.
3. Un achat coupé en deux
La même méthode a un second défaut, plus simple à voir. Elle fait quatre écritures séparées : le stock, le solde, l'achat, puis la jauge de la créature. Si le serveur s'arrête entre deux d'entre elles, les premières restent dans la base et les suivantes n'ont jamais lieu. Une panne peut venir d'une base saturée, d'un délai dépassé, d'un disque plein ou d'un bug dans le code qui suit.
Une vraie panne ne se commande pas. Vous allez donc en fabriquer une, juste avant que l'achat soit écrit. Dans PurchaseController, ajoutez une ligne entre $user->save(); et Purchase::create([ :
$user->balance_cents -= $total;
$user->save();
throw new \RuntimeException('Panne au milieu de l\'achat.');
Purchase::create([
throw lève une exception, une erreur qui arrête la méthode à cet endroit. Rien de ce qui suit ne tourne.
Mallory ouvre le magasin, avec 5,00 € dans l'en-tête et « 30 en stock » sur la balle rebondissante. Elle choisit Encre et la quantité 1, puis clique sur « Acheter ». L'onglet Réseau montre la requête et sa réponse :
POST /magasin/4/acheter
{"creature_id":"3","quantity":"1"}
Réponse : 500
Le code 500 signale une erreur du serveur. Avec APP_DEBUG=true dans votre .env, Inertia affiche la page d'erreur de Laravel dans une fenêtre, par-dessus le magasin :

L'en-tête affiche encore 5,00 €, car la page n'a pas été rechargée. Mallory ferme la fenêtre et recharge le magasin :

Le solde est passé à 1,80 €, le stock à 29. La table des achats se lit avec Tinker. Les montants sont en centimes depuis le chapitre 2.07 :
php artisan tinker --execute="echo 'solde ', App\Models\User::find(2)->balance_cents, ' · stock ', App\Models\Item::find(4)->stock, ' · achats ', App\Models\Purchase::count();"
solde 180 · stock 29 · achats 0
Mallory a payé 3,20 €, une balle a quitté le stock, et aucun achat n'existe. L'humeur d'Encre n'a pas bougé. Pour le jeu, cet argent a disparu. Dans une vraie boutique, ce serait un client débité sans commande, et un service client qui ne trouve aucune trace de l'achat.
4. À vous : la panne sur votre copie (5 min)
- Lancez
php artisan migrate:fresh --seed, puis relevez l'état de départ avec la commande Tinker de la section 3 :solde 500 · stock 30 · achats 0. - Ajoutez la ligne
throw new \RuntimeException(…)entre$user->save();etPurchase::create([. - Connectez-vous comme Mallory, ouvrez « 04 Magasin » et l'onglet Réseau, puis achetez une balle rebondissante pour Encre.
- Relevez le statut de la requête
acheter, puis rechargez la page et lisez le solde et le stock. - Relancez la commande Tinker.
Retirez ensuite la ligne de la panne et relancez php artisan migrate:fresh --seed. Elle reviendra à la section 6, pour prouver la correction.
5. La correction : tout ou rien, chacun son tour
Tout ou rien
Un virement bancaire retire l'argent d'un compte et l'ajoute sur un autre. La banque ne laisse jamais la première opération sans la seconde : les deux ont lieu, ou aucune. Une base de données sait faire la même chose avec un groupe d'écritures. Ce groupe s'appelle une transaction : la base garde les écritures en suspens, puis les valide toutes ensemble à la fin, ou les annule toutes. Un achat dont les écritures sont toutes gardées ou toutes annulées est dit atomique, d'un mot grec qui signifie « qu'on ne peut pas couper ».
Laravel ouvre une transaction avec DB::transaction(), qui reçoit une fonction. Si la fonction se termine normalement, les écritures sont validées. Si une exception sort de la fonction, Laravel annule toutes les écritures faites dedans, puis relance l'exception. Tinker le montre sur le magasin, qui compte sept articles :
php artisan tinker --execute='try { DB::transaction(function () { App\Models\Item::find(4)->delete(); echo App\Models\Item::count(), PHP_EOL; throw new RuntimeException; }); } catch (RuntimeException $e) { echo get_class($e), PHP_EOL; } echo App\Models\Item::count(), PHP_EOL;'
6
RuntimeException
7
Dans la transaction, l'article est supprimé, et la base n'en compte plus que six. L'exception sort de la fonction, DB::transaction() annule, et la balle rebondissante est de retour. Les guillemets simples autour du code évitent que PowerShell ou le terminal remplacent $e.
La transaction règle la panne. Sur MySQL ou MariaDB, elle ne règle pas les deux onglets : chaque requête ouvre sa transaction, les deux lisent 5,00 € avant que l'une d'elles écrive, et les deux vont jusqu'au bout. Il faut un second outil.
Chacun son tour
Contre les deux onglets, la seconde requête doit attendre que la première ait fini avant même de lire le solde. Une cabine d'essayage fermée à clé fonctionne ainsi : la personne suivante attend devant la porte, et entre quand la première est sortie. En base de données, cette attente s'obtient avec un verrou posé sur les lignes lues.
Laravel pose ce verrou avec lockForUpdate(), placé dans la requête qui lit :
$user = User::lockForUpdate()->findOrFail($request->user()->id);
Sur MySQL ou MariaDB, la ligne du joueur est relue dans la base et verrouillée jusqu'à la fin de la transaction. Une autre requête qui demande la même ligne avec lockForUpdate() attend que la transaction soit validée ou annulée. Elle lit alors la valeur déjà écrite. La documentation de Laravel recommande de placer ces verrous dans une transaction, qui les libère à sa fin, même en cas d'erreur.
Note
Votre base de développement est SQLite. La requête réellement envoyée par lockForUpdate() s'y lit avec DB::listen() :
php artisan tinker --execute='DB::listen(fn ($q) => print($q->sql . PHP_EOL)); App\Models\Item::lockForUpdate()->find(4);'
select * from "items" where "items"."id" = ? limit 1
Aucun verrou dans la requête : SQLite n'a pas de verrou de ligne, et Laravel n'écrit rien de plus. SQLite se protège autrement. Une seule connexion écrit à la fois, sur toute la base. Laravel ouvre les transactions SQLite en mode DEFERRED, la clé transaction_mode de config/database.php : deux transactions peuvent lire ensemble. Mais quand l'une a écrit, l'autre, qui a lu avant, ne peut plus écrire. Son écriture échoue avec l'erreur « database is locked », sa transaction est annulée, et l'onglet reçoit une page d'erreur 500 au lieu du message « Votre solde est insuffisant. ». Le solde n'est pas dépensé deux fois, mais la seconde requête échoue au lieu d'attendre.
Le même code, écrit pour MySQL, la base que l'encart du chapitre 1.22 évoque chez OVH, donne une autre requête :
php artisan tinker --execute="echo DB::connection('mysql')->table('items')->where('id', 4)->lockForUpdate()->toSql();"
select * from `items` where `id` = ? for update
for update verrouille les lignes lues, et c'est sur MySQL ou MariaDB que la seconde requête attend, relit, puis reçoit le bon message. Écrivez le verrou quand même avec SQLite : votre code reste juste le jour où la base change.
Contrôler après avoir verrouillé
Le verrou ne sert à rien si le contrôle porte sur l'ancienne lecture. $item et $request->user() ont été lus avant la transaction, sans verrou. L'article et le joueur doivent donc être relus dans la transaction, avec lockForUpdate(), et le stock et le solde contrôlés sur ces valeurs relues.
La créature aussi. Sa jauge se calcule à partir de sa valeur actuelle : l'humeur d'Encre plus 25. Si Mallory achète en même temps une balle et un os à mâcher pour Encre, la seconde requête attend le verrou du joueur. Avec une créature lue avant la transaction, elle calcule pourtant la nouvelle humeur depuis l'ancienne valeur, et efface l'effet du premier achat. Relue sous verrou, la créature porte déjà l'humeur écrite par la première requête.
Dans la transaction, return back()->withErrors(…) ne fait que sortir de la fonction. Le contrôleur continuerait jusqu'au message « Achat effectué ». Le refus doit donc lever une exception. ValidationException::withMessages() en fabrique une qui porte le message du champ. La transaction est annulée, et Laravel renvoie au formulaire avec le message, comme pour une validation ratée.
À vous : la correction (15 min)
Ce que vous devez obtenir :
- Les écritures du stock, du solde, de l'achat et de la jauge sont dans un seul
DB::transaction(). - Dans la transaction, l'article, le joueur et la créature sont relus avec
lockForUpdate(). - Le total, le contrôle du stock, le contrôle du solde et la nouvelle jauge utilisent ces valeurs relues, et un refus lève une
ValidationExceptionavec les mêmes messages qu'avant. - La validation de la requête reste avant la transaction.
Deux précisions sur la fonction passée à DB::transaction(). Une fonction anonyme écrite avec function () { … } ne voit pas les variables de la méthode : use ($request, $item, $creature, $quantity) les lui passe. Les classes DB, User et ValidationException demandent leur use en tête du fichier.
Où chercher : la documentation de Laravel, page « Database: Getting Started », section « Database Transactions », et page « Database: Query Builder », section « Pessimistic Locking ». Son exemple de transfert entre deux comptes a la même forme que l'achat. Ouvrez la correction après dix minutes de recherche.
Le refus passe toujours par le même message. Mallory, avec 5,00 €, demande deux balles à 3,20 € :

Le bouton grisé ne protège de rien
Le bouton « Acheter » du magasin porte :disabled="processing || creatures.length === 0". Il est grisé pendant l'envoi, comme le bouton « Créer le projet » du chapitre 1.12, ou quand le joueur n'a aucune créature. Un double clic n'envoie donc qu'une requête. C'est du confort, comme le lien masqué du chapitre 2.02. Deux onglets ont chacun leur bouton, et un script n'a pas de bouton du tout. La protection est dans le contrôleur : la transaction, le verrou et le contrôle fait sous le verrou.
6. La preuve
En arrivant à ce chapitre, les tests de la faille ne sont plus tous rouges. Les deux contrôles, sur le stock et sur le solde, passent depuis les chapitres 2.07 et 2.08. Le troisième test installe une panne au moment où Laravel écrit l'achat, comme votre ligne throw, puis vérifie que rien n'a changé :
php artisan test --filter=Flaw09
FAIL Tests\Feature\Flaws\Flaw09AtomicPurchaseTest
⨯ a purchase that breaks halfway changes nothing 3.23s
✓ a player cannot spend more than their balance 0.08s
✓ a player cannot buy more than the stock 0.05s
────────────────────────────────…
FAILED Tests\Feature\Flaws\Flaw09AtomicPurchaseTest > a purchase that breaks halfway changes nothing
Failed asserting that 500 is identical to 1000.
at tests\Feature\Flaws\Flaw09AtomicPurchaseTest.php:35
…
➜ 35▕ expect($this->player->fresh()->balance_cents)->toBe(1000);
…
Tests: 1 failed, 2 passed (10 assertions)
Le joueur du test avait 1000 centimes. Après la panne, il lui en reste 500 : deux articles à 250 ont été payés sans être achetés. Après la correction :
PASS Tests\Feature\Flaws\Flaw09AtomicPurchaseTest
✓ a purchase that breaks halfway changes nothing 2.37s
✓ a player cannot spend more than their balance 0.04s
✓ a player cannot buy more than the stock 0.04s
Tests: 3 passed (13 assertions)
Lancez ensuite toute la suite avec php artisan test. Le tableau de bord passe de 2 tests rouges à 1, le dernier étant celui du chapitre 2.10, et les tests du jeu restent verts :
Tests: 1 failed, 88 passed (368 assertions)
Rejouez enfin la panne. Remettez la ligne throw dans la fonction de la transaction, entre $user->save(); et Purchase::create([, puis achetez une balle rebondissante comme Mallory. La page d'erreur s'affiche comme avant. Rechargez le magasin : le solde est resté à 5,00 € et le stock à 30.

solde 500 · stock 30 · achats 0
Retirez alors la ligne de la panne. Tant qu'elle est là, aucun achat n'aboutit, et le test d'achat de tests/Feature/Game devient rouge.
7. La règle
Recopiez cette phrase dans votre carnet des failles, avec l'endroit de votre portfolio où elle s'applique :
Un achat se fait dans une transaction : on relit le solde et le stock sous verrou, on les contrôle, puis on écrit tout, ou rien.
Votre portfolio n'a ni solde ni stock. Cherchez quand même une action qui écrit dans deux tables à la suite, par exemple un projet et ses étiquettes, et notez ce qu'il resterait en base si le serveur s'arrêtait entre les deux.
Le chapitre 2.10 s'attaque au dernier test rouge : un code cadeau essayé mille fois.
À retenir
- Deux requêtes qui lisent le même solde avant que l'une d'elles l'écrive passent toutes deux le contrôle et dépensent deux fois le même argent.
DB::transaction()valide toutes les écritures de sa fonction à la fin, ou les annule toutes si une exception en sort.- Sur MySQL ou MariaDB,
lockForUpdate()dans une transaction fait attendre toute autre requête qui verrouille la même ligne, alors que sur SQLite la seconde transaction échoue au lieu d'attendre. - Le contrôle du solde et du stock se fait sur les valeurs relues sous le verrou, jamais sur celles lues avant la transaction.
- Un bouton grisé pendant l'envoi évite un double clic, mais il n'empêche ni un second onglet ni un script d'envoyer la même requête.
Pour aller plus loin
- « Hacking Starbucks for unlimited coffee », Egor Homakov, blog de Sakurity, 21 mai 2015 : https://sakurity.com/blog/2015/05/21/starbucks.html
- « Race Condition Exploit in Starbucks Gift Cards », Bruce Schneier, Schneier on Security, 26 mai 2015 : https://www.schneier.com/blog/archives/2015/05/race_condition_.html
- « Database: Getting Started », section « Database Transactions », documentation de Laravel 13.x : https://laravel.com/docs/13.x/database#database-transactions
- « Database: Query Builder », section « Pessimistic Locking », documentation de Laravel 13.x : https://laravel.com/docs/13.x/queries#pessimistic-locking