Image de couverture : Transactions atomiques en PHP/MySQL : blindez votre portefeuille numérique contre les mises à jour perdues
Bases de données

Transactions atomiques en PHP/MySQL : blindez votre portefeuille numérique contre les mises à jour perdues

5 août 2026
5 min de lecture
3 vues
Sébastien Muler

Un bug silencieux qui coûte de l'argent réel

Imaginez que votre système de wallet traite deux crédits simultanés et qu'à la fin, l'un d'eux disparaît sans laisser de trace. Pas d'exception, pas d'erreur dans les logs, juste un solde qui ne colle plus. Ce scénario n'est pas théorique : c'est le quotidien de nombreux systèmes de paiement maison qui souffrent du problème dit de "lost update" — la mise à jour perdue. Cet article, inspiré de l'expérience de l'équipe PayWithToken publiée sur dev.to, explore les disciplines concrètes pour rendre vos mouvements d'argent corrects sous charge concurrente.

Le piège classique : lire, calculer, réécrire

Voici le code que l'on écrit presque naturellement pour créditer un solde :

// ❌ Ne faites jamais ça
$row = $db->query("SELECT balance FROM users WHERE id = $id")->fetch();
$new = $row['balance'] + $amount;
$db->exec("UPDATE users SET balance = $new WHERE id = $id");

Ce pattern fonctionne parfaitement... en dehors de toute concurrence. Dès que deux requêtes arrivent simultanément, la séquence devient :

  1. Requête A lit balance = 1000
  2. Requête B lit balance = 1000 (A n'a pas encore écrit)
  3. A calcule 1500, écrit 1500
  4. B calcule 1500, écrit 1500

Deux crédits de 500 ont été appliqués, mais le solde n'a augmenté que de 500. La moitié de l'argent vient de s'évaporer. C'est le lost update : une race condition classique que les tests unitaires ne détectent jamais, car ils s'exécutent en séquence.

Les cinq disciplines pour une atomicité réelle

1. Déléguer le calcul à la base de données

La première correction est aussi la plus simple : ne jamais rapatrier le solde en PHP pour le modifier. Laisser MySQL faire l'arithmétique de manière atomique :

// ✅ Mise à jour atomique
$db->exec("UPDATE users SET balance = balance + $amount WHERE id = $id");

Une seule instruction SQL, pas de fenêtre entre la lecture et l'écriture. MySQL sérialise les écritures sur la même ligne, le problème de concurrence disparaît pour ce cas simple.

2. Envelopper les opérations multi-lignes dans une transaction

Dès qu'un mouvement implique plusieurs tables — débit d'un compte, crédit d'un autre, écriture dans un journal de transactions — il faut impérativement une transaction :

$db->beginTransaction();
try {
    $db->exec("UPDATE accounts SET balance = balance - :amount WHERE id = :from");
    $db->exec("UPDATE accounts SET balance = balance + :amount WHERE id = :to");
    $db->exec("INSERT INTO ledger (from_id, to_id, amount) VALUES (:from, :to, :amount)");
    $db->commit();
} catch (Throwable $e) {
    $db->rollBack();
    throw $e;
}

Sans transaction, un crash entre le débit et le crédit crée un trou dans les fonds. La propriété ACID de MySQL (et de MariaDB) garantit que les trois opérations réussissent ensemble ou échouent ensemble.

3. Verrouillage pessimiste avec SELECT … FOR UPDATE

Quand la logique métier oblige à lire le solde en PHP avant d'écrire (vérification d'un seuil, calcul de frais variables), il faut poser un verrou exclusif dès la lecture :

$db->beginTransaction();
$row = $db->query("SELECT balance FROM users WHERE id = $id FOR UPDATE")->fetch();

if ($row['balance'] < $amount) {
    $db->rollBack();
    throw new InsufficientFundsException();
}

$db->exec("UPDATE users SET balance = balance - :amount WHERE id = :id");
$db->commit();

FOR UPDATE force les autres transactions à attendre avant de lire ou modifier la même ligne. La fenêtre de race condition est fermée. C'est le verrou pessimiste : coûteux en contention, mais indispensable quand la décision dépend de la valeur lue.

4. Idempotence et protection contre les rejeux

Les webhooks bancaires peuvent livrer le même événement plusieurs fois. Un virement dupliqué est aussi destructeur qu'un virement perdu. La solution : stocker un identifiant unique par opération et rejeter les doublons :

$db->beginTransaction();
$exists = $db->query(
    "SELECT 1 FROM transactions WHERE external_id = :eid"
)->fetch();

if ($exists) {
    $db->rollBack();
    return; // déjà traité, on ignore en silence
}

$db->exec("INSERT INTO transactions (external_id, amount, ...) VALUES (...)");
$db->exec("UPDATE users SET balance = balance + :amount WHERE id = :id");
$db->commit();

Cette contrainte d'idempotence, combinée à un index unique sur external_id, transforme votre endpoint de webhook en consommateur fiable, même face à des livraisons multiples.

5. Le grand livre (ledger) comme source de vérité

Plutôt que de faire confiance à un champ balance mutable, les systèmes fintech robustes stockent chaque mouvement dans une table de grand livre append-only et reconstituent le solde par agrégation :

CREATE TABLE ledger (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    account_id INT NOT NULL,
    amount DECIMAL(15,2) NOT NULL, -- positif = crédit, négatif = débit
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    external_id VARCHAR(255) UNIQUE
);

-- Solde courant
SELECT SUM(amount) AS balance FROM ledger WHERE account_id = ?;

Le balance dans la table users devient un cache dénormalisé, mis à jour de façon atomique avec chaque ligne de ledger. En cas de doute, on peut toujours recalculer depuis le ledger et détecter toute incohérence.

Conclusion : la concurrence ne pardonne pas l'approximation

Le code naïf de wallet fonctionne en développement, passe les tests, se déploie en production — et commence à perdre de l'argent au premier pic de charge. Les cinq disciplines présentées ici (calcul côté base, transactions ACID, FOR UPDATE, idempotence, grand livre) ne sont pas du sur-engineering : elles sont le minimum pour tout système qui manipule de l'argent réel.

En PHP/Symfony, ces patterns s'implémentent très naturellement avec Doctrine DBAL ou l'ORM, les événements de domaine et les message handlers asynchrones via Messenger. L'atomicité n'est pas un détail d'implémentation — c'est une exigence métier.

Partager cet article