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 :
À 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é.
