Samen Steeve
MES SERVICES.
Retour au blog
LogicielSQLFintech
15 mai 20265 min de lecture

Anatomie d'une double facturation : concurrence et transactions SQL

En production, un bug logique classique peut sommeiller des mois avant de se manifester brutalement sous l'effet de la charge. C'est le cas du problème de double débit concurrent. Deux requêtes de paiement identiques arrivant à la même milliseconde peuvent contourner vos validations de solde applicatives et vider le compte d'un utilisateur. En voici l'analyse technique complète.

Le scénario du désastre (Race Condition)

Imaginez un utilisateur possédant un solde de 10 000 FCFA. Il essaie d'acheter un service à 8 000 FCFA. S'il clique deux fois très rapidement sur le bouton “Payer”, deux serveurs web (ou deux workers de process) traitent ces requêtes en parallèle :

Requête A (Worker 1)
Requête B (Worker 2)
1. Lit le solde en BDD : 10 000 FCFA
1. Lit le solde en BDD : 10 000 FCFA
2. Vérifie : 10 000 >= 8 000 ? (Oui)
2. Vérifie : 10 000 >= 8 000 ? (Oui)
3. Débite le compte : Solde = 2 000 FCFA
3. Débite le compte : Solde = 2 000 FCFA
4. Commit de la transaction
4. Commit de la transaction

À la fin de l'opération, le solde final de l'utilisateur est de 2 000 FCFA au lieu d'avoir bloqué la deuxième transaction pour solde insuffisant (ou d'avoir un solde négatif de -6 000 FCFA). L'entreprise a perdu de l'argent et la comptabilité est faussée.

La mauvaise solution : l'optimisme applicatif

Tenter de résoudre cela en vérifiant le statut de la transaction en mémoire (ex: via des variables de session ou un cache Redis non verrouillé) est insuffisant. De même, les transactions de base de données classiques avec le niveau d'isolation par défaut (READ COMMITTED dans PostgreSQL) n'empêchent pas cette race condition, car les deux requêtes lisent l'état validé de la base de données avant que l'autre n'ait sauvegardé son écriture.

La solution robuste : le verrouillage pessimiste (Pessimistic Locking)

Pour sécuriser cette transaction financière, nous devons forcer la base de données à sérialiser l'accès à la ligne du portefeuille de l'utilisateur. C'est le rôle de l'instruction SELECT ... FOR UPDATE.

Voici l'implémentation propre en Laravel et PHP 8+ encapsulée dans une transaction :

use Illuminate\Support\Facades\DB;
use App\Exceptions\InsufficientBalanceException;

DB::transaction(function () use ($userId, $amount) {
    // 1. Récupérer le portefeuille de l'utilisateur en verrouillant la ligne SQL
    // Cette requête bloque tout autre SELECT ... FOR UPDATE sur ce compte spécifique
    $wallet = DB::table('wallets')
        ->where('user_id', $userId)
        ->lockForUpdate()
        ->first();

    // 2. Vérification stricte du solde à l'abri des écritures concurrentes
    if ($wallet->balance < $amount) {
        throw new InsufficientBalanceException("Solde insuffisant.");
    }

    // 3. Débiter le compte
    DB::table('wallets')
        ->where('user_id', $userId)
        ->decrement('balance', $amount);

    // 4. Enregistrer la transaction pour audit
    DB::table('ledger_entries')->insert([
        'user_id' => $userId,
        'amount' => -$amount,
        'type' => 'debit',
        'created_at' => now(),
    ]);
});

Que se passe-t-il sous le capot ?

Lorsque le premier worker exécute lockForUpdate() (qui compile en SELECT * FROM wallets WHERE user_id = ? FOR UPDATE), PostgreSQL pose un verrou exclusif sur cette ligne spécifique.

Si la requête B arrive une milliseconde plus tard et tente d'exécuter le même lockForUpdate(), la base de données met la requête B en attente (state: lock wait). Dès que le worker 1 valide sa transaction (commit) ou échoue (rollback), le verrou est libéré. La requête B lit alors le nouveau solde mis à jour (2 000 FCFA), détecte que 2 000 < 8 000, et échoue proprement en levant une exception.

Règles d'or pour la production

  • Définir un timeout : Ne laissez pas une requête attendre indéfiniment un verrou. Configurez un timeout SQL court (ex: SET lock_timeout = '3s').
  • Toujours indexer la clause WHERE : Si votre requête de verrouillage n'utilise pas un index unique, la base de données risque de verrouiller toute la table (Table Lock) au lieu d'une seule ligne (Row Lock), paralysant l'ensemble de l'application.
  • Contraintes de validation en BDD : Ajoutez une contrainte SQL CHECK (balance >= 0) au niveau de la table. Si le code applicatif faillit, la base de données rejettera l'écriture pour protéger son intégrité.

Un projet technique critique ?

Vous cherchez un ingénieur expérimenté pour concevoir votre architecture, auditer votre code ou intégrer de l'IA ?

Démarrer un projet