Aller au contenu

Deux achats en même temps, un seul solde : l'achat atomique

Temps 2 · séance 2 · ~40 min

Les slides du chapitre (PDF)

  • 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 :

Deux colonnes, l'onglet 1 et l'onglet 2 de Mallory, et la base au milieu, le temps vers le bas. Les deux onglets lisent le solde de 5,00 euros, les deux contrôles acceptent 3,20 euros, puis l'onglet 1 écrit le stock 29 et le solde 1,80 euro et son achat, et l'onglet 2 écrit les mêmes valeurs et un second achat. Résultat : deux balles livrées, une seule payée

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 :

La page d'erreur de Laravel ouverte par-dessus le magasin : RuntimeException, Panne au milieu de l'achat., le code 500 et la requête POST tamagotchi.test/magasin/4/acheter, puis l'extrait de PurchaseController avec la ligne 42 du throw surlignée en rouge, sous les lignes qui mettent à jour le stock et le solde

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 magasin rechargé par Mallory : l'en-tête affiche SOLDE 1,80 € et l'annonce de la balle rebondissante dit 29 en stock

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)

  1. 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.
  2. Ajoutez la ligne throw new \RuntimeException(…) entre $user->save(); et Purchase::create([.
  3. Connectez-vous comme Mallory, ouvrez « 04 Magasin » et l'onglet Réseau, puis achetez une balle rebondissante pour Encre.
  4. Relevez le statut de la requête acheter, puis rechargez la page et lisez le solde et le stock.
  5. 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.

Deux lignes. Sans transaction : stock 30 vers 29, solde 5,00 vers 1,80, panne à l'achat, et la base garde stock 29, solde 1,80 euro et 0 achat. Avec DB::transaction() : les mêmes trois étapes dans un cadre pointillé, la même panne, une flèche tout est annulé, et la base garde stock 30, solde 5,00 euros et 0 achat

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.

Sur MySQL ou MariaDB, les mêmes deux onglets, avec le verrou. L'onglet 1 verrouille puis lit le solde de 5,00 euros, accepte, écrit le stock 29 et le solde 1,80 euro, écrit l'achat et la jauge d'Encre, puis valide la transaction, ce qui libère les verrous. L'onglet 2 veut verrouiller la même ligne et attend son tour, puis verrouille, lit 1,80 euro et refuse. Résultat : une balle, payée une fois, et le message Votre solde est insuffisant.

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 ValidationException avec 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.

app/Http/Controllers/PurchaseController.php :

<?php

namespace App\Http\Controllers;

use App\Models\Creature;
use App\Models\Item;
use App\Models\Purchase;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;

class PurchaseController extends Controller
{
    public function store(Request $request, Item $item): RedirectResponse
    {
        $request->validate([
            'creature_id' => ['required', 'integer', Rule::exists('creatures', 'id')->where('user_id', $request->user()->id)],
            'quantity' => ['required', 'integer', 'min:1', 'max:10'],
        ]);

        $creature = Creature::findOrFail($request->integer('creature_id'));
        $quantity = $request->integer('quantity');

        DB::transaction(function () use ($request, $item, $creature, $quantity) {
            // Read the item, the player and the creature again, and keep them locked until the end.
            $item = Item::lockForUpdate()->findOrFail($item->id);
            $user = User::lockForUpdate()->findOrFail($request->user()->id);
            $creature = Creature::lockForUpdate()->findOrFail($creature->id);

            $total = $item->price_cents * $quantity;

            if ($item->stock < $quantity) {
                throw ValidationException::withMessages(['quantity' => 'Il n\'en reste pas assez en stock.']);
            }

            if ($user->balance_cents < $total) {
                throw ValidationException::withMessages(['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 refus passe toujours par le même message. Mallory, avec 5,00 €, demande deux balles à 3,20 € :

L'annonce de la balle rebondissante après l'envoi : quantité 2, et sous les champs le message Votre solde est insuffisant., le stock toujours à 30

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.

Le magasin rechargé par Mallory après la panne, une fois la faille corrigée : l'en-tête affiche SOLDE 5,00 € et la balle rebondissante 30 en stock

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