Un virement débite ton compte, puis le serveur plante avant de créditer le destinataire. L’argent a disparu ? Pas si tu utilises les transactions SQL. Dans ce guide, tu comprends pourquoi elles existent, comment utiliser COMMIT et ROLLBACK en SQL comme en PHP, et comment éviter les pièges de la concurrence : lectures incohérentes, stocks négatifs et interblocages.
Une application ne fait presque jamais une seule modification à la fois. Passer une commande, c’est créer la commande, ajouter ses lignes, diminuer le stock et enregistrer le paiement. Si l’une de ces étapes échoue au milieu, ta base se retrouve dans un état incohérent : une commande sans lignes, un stock diminué pour une commande qui n’existe pas…

Les transactions SQL résolvent ce problème : elles regroupent plusieurs requêtes en un bloc indivisible. Soit tout réussit, soit rien n’est enregistré. C’est l’une des notions les plus importantes des bases de données, et pourtant beaucoup de développeurs s’en passent… jusqu’au jour où un incident les rattrape.

- Ce qu’est une transaction et les quatre propriétés ACID.
- Utiliser
START TRANSACTION,COMMIT,ROLLBACKetSAVEPOINT. - Gérer les transactions SQL en PHP avec PDO, avec une gestion d’erreurs fiable.
- Comprendre les niveaux d’isolation et les anomalies qu’ils évitent.
- Verrouiller des lignes avec
SELECT … FOR UPDATEpour éviter les stocks négatifs. - Réagir aux interblocages (deadlocks).
Pourquoi les transactions SQL sont indispensables
Prenons le grand classique : un virement de 100 € du compte d’Alice vers celui de Bruno. Il faut deux requêtes :
UPDATE comptes SET solde = solde - 100 WHERE id = 1; -- débite Alice
UPDATE comptes SET solde = solde + 100 WHERE id = 2; -- crédite BrunoSQLQue se passe-t-il si la connexion est coupée, si le serveur redémarre ou si la seconde requête échoue (contrainte non respectée, compte inexistant) entre les deux ? Alice a perdu 100 €, Bruno n’a rien reçu. Les 100 € se sont évaporés.
Avec une transaction, ce scénario devient impossible :
START TRANSACTION;
UPDATE comptes SET solde = solde - 100 WHERE id = 1;
UPDATE comptes SET solde = solde + 100 WHERE id = 2;
COMMIT;SQLSi quoi que ce soit échoue avant le COMMIT, aucune des deux modifications n’est enregistrée.
Les propriétés ACID
Les transactions SQL garantissent quatre propriétés, résumées par l’acronyme ACID :
| Propriété | Signification | Dans l’exemple du virement |
|---|---|---|
| Atomicité | Tout ou rien : la transaction est indivisible | Les deux comptes sont modifiés, ou aucun |
| Cohérence | La base passe d’un état valide à un autre état valide | La somme des soldes reste identique, les contraintes sont respectées |
| Isolation | Les transactions simultanées ne se perturbent pas | Personne ne voit Alice débitée et Bruno pas encore crédité |
| Durabilité | Une fois validée, la modification survit à une panne | Après le COMMIT, le virement est enregistré, même si le serveur redémarre |
Dans MySQL et MariaDB, seules les tables InnoDB gèrent les transactions. Le moteur MyISAM, utilisé par de très anciennes applications, les ignore silencieusement : ROLLBACK ne fait rien. InnoDB est le moteur par défaut depuis MySQL 5.5. Vérifie avec SHOW TABLE STATUS WHERE Name = 'comptes'; (colonne Engine) et convertis si besoin : ALTER TABLE comptes ENGINE = InnoDB;.
Le mode autocommit
Par défaut, MySQL fonctionne en mode autocommit : chaque requête est une transaction à elle seule, validée immédiatement. C’est pourquoi, sans START TRANSACTION, tes UPDATE sont enregistrés aussitôt et ne peuvent pas être annulés.
SELECT @@autocommit; -- 1 = activé (comportement par défaut)SQLSTART TRANSACTION (ou son synonyme BEGIN) suspend l’autocommit jusqu’au prochain COMMIT ou ROLLBACK.
COMMIT et ROLLBACK en pratique
Crée cette table pour suivre les exemples :
CREATE TABLE comptes (
id INT PRIMARY KEY,
titulaire VARCHAR(50) NOT NULL,
solde DECIMAL(10,2) NOT NULL,
CONSTRAINT solde_positif CHECK (solde >= 0)
) ENGINE = InnoDB;
INSERT INTO comptes VALUES (1, 'Alice', 500.00), (2, 'Bruno', 200.00);SQLValider avec COMMIT
START TRANSACTION;
UPDATE comptes SET solde = solde - 100 WHERE id = 1;
UPDATE comptes SET solde = solde + 100 WHERE id = 2;
COMMIT;
SELECT * FROM comptes;SQL| id | titulaire | solde |
|---|---|---|
| 1 | Alice | 400.00 |
| 2 | Bruno | 300.00 |
Annuler avec ROLLBACK
START TRANSACTION;
UPDATE comptes SET solde = 0 WHERE id = 1; -- oups, une erreur de manipulation
SELECT * FROM comptes WHERE id = 1; -- dans la transaction : solde = 0
ROLLBACK;
SELECT * FROM comptes WHERE id = 1; -- après ROLLBACK : solde = 400.00SQLPendant la transaction, ta session voit ses propres modifications. Les autres connexions, elles, voient toujours l’ancienne valeur (c’est l’isolation). Après ROLLBACK, tout revient comme avant.
Avant de lancer un UPDATE ou un DELETE à la main sur une base de production, ouvre une transaction, exécute ta requête, vérifie le résultat avec un SELECT, puis seulement COMMIT. Une clause WHERE oubliée se rattrape alors avec un simple ROLLBACK.
Les SAVEPOINT : annuler une partie seulement
Un point de sauvegarde permet d’annuler une partie de la transaction sans tout abandonner :
START TRANSACTION;
INSERT INTO commandes (id, client_id, total) VALUES (42, 1, 150.00);
SAVEPOINT avant_bonus;
UPDATE clients SET points_fidelite = points_fidelite + 15 WHERE id = 1;
-- Le programme de fidélité pose problème : on annule seulement cette partie
ROLLBACK TO SAVEPOINT avant_bonus;
COMMIT; -- la commande est enregistrée, sans les pointsSQLLes validations implicites : attention aux requêtes DDL
Certaines instructions provoquent un COMMIT automatique de la transaction en cours, même sans que tu le demandes. C’est le cas des instructions qui modifient la structure de la base (DDL) :
START TRANSACTION;
UPDATE comptes SET solde = 0 WHERE id = 2;
CREATE TABLE journal (id INT); -- ⚠️ COMMIT implicite ici !
ROLLBACK; -- trop tard : la mise à zéro est déjà validéeSQLCREATE, ALTER, DROP, TRUNCATE, RENAME et LOCK TABLES déclenchent tous un commit implicite dans MySQL. Ne les mélange jamais à des modifications de données dans une transaction.
Les transactions SQL en PHP avec PDO
Dans une application, tu ne tapes pas START TRANSACTION à la main : tu utilises les méthodes de PDO. Voici le modèle à retenir, avec une gestion d’erreurs complète :
<?php
declare(strict_types=1);
$pdo = new PDO('mysql:host=localhost;dbname=banque;charset=utf8mb4', 'app', 'secret', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // indispensable : les erreurs lèvent des exceptions
]);
function virement(PDO $pdo, int $de, int $vers, float $montant): void
{
if ($montant <= 0) {
throw new InvalidArgumentException('Le montant doit être positif.');
}
$pdo->beginTransaction();
try {
$debit = $pdo->prepare('UPDATE comptes SET solde = solde - ? WHERE id = ? AND solde >= ?');
$debit->execute([$montant, $de, $montant]);
if ($debit->rowCount() !== 1) {
throw new RuntimeException('Solde insuffisant ou compte débiteur introuvable.');
}
$credit = $pdo->prepare('UPDATE comptes SET solde = solde + ? WHERE id = ?');
$credit->execute([$montant, $vers]);
if ($credit->rowCount() !== 1) {
throw new RuntimeException('Compte créditeur introuvable.');
}
$journal = $pdo->prepare('INSERT INTO mouvements (compte_de, compte_vers, montant) VALUES (?, ?, ?)');
$journal->execute([$de, $vers, $montant]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e; // on laisse l'appelant décider (message à l'utilisateur, journalisation)
}
}PHPLes points clés de ce modèle :
| Élément | Pourquoi |
|---|---|
PDO::ERRMODE_EXCEPTION | Sans lui, une requête qui échoue renvoie false en silence et la transaction serait validée malgré l’erreur |
try / catch (Throwable) | On annule pour toute erreur, y compris les erreurs PHP |
inTransaction() | Évite une exception si la transaction a déjà été terminée |
rowCount() | Vérifie qu’une ligne a réellement été modifiée (un UPDATE sur un id inexistant ne lève pas d’erreur) |
AND solde >= ? | Le contrôle du solde est fait dans la requête, de façon atomique |
throw $e | L’erreur n’est pas masquée : elle remonte pour être gérée et journalisée |
Laravel propose DB::transaction(function () { ... }), qui fait beginTransaction, commit et rollBack pour toi (et peut même réessayer en cas d’interblocage). Doctrine (Symfony) offre $entityManager->wrapInTransaction(...). Le principe reste exactement le même.
Les niveaux d’isolation
Quand plusieurs utilisateurs travaillent en même temps, leurs transactions s’exécutent en parallèle. Le niveau d’isolation détermine ce qu’une transaction peut voir des modifications des autres. Plus l’isolation est forte, plus les données sont cohérentes, mais plus les transactions risquent de s’attendre mutuellement.
D’abord, les trois anomalies que l’isolation cherche à éviter :
| Anomalie | Ce qui se passe |
|---|---|
| Lecture sale (dirty read) | Tu lis une donnée modifiée par une autre transaction pas encore validée (et qui sera peut-être annulée) |
| Lecture non répétable | Tu lis deux fois la même ligne dans ta transaction et obtiens deux valeurs différentes |
| Lecture fantôme (phantom read) | Tu relances la même requête et de nouvelles lignes apparaissent |
Et les quatre niveaux d’isolation définis par la norme SQL :
| Niveau | Lecture sale | Lecture non répétable | Lecture fantôme |
|---|---|---|---|
READ UNCOMMITTED | Possible | Possible | Possible |
READ COMMITTED | Évitée | Possible | Possible |
REPEATABLE READ (défaut InnoDB) | Évitée | Évitée | Évitée dans la plupart des cas (¹) |
SERIALIZABLE | Évitée | Évitée | Évitée |
(¹) InnoDB, grâce à ses lectures cohérentes (snapshots) et à ses verrous sur les intervalles d’index, évite la plupart des lectures fantômes dès REPEATABLE READ, ce qui va au-delà de la norme.
-- Changer le niveau pour la prochaine transaction de cette session
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Voir le niveau actuel
SELECT @@transaction_isolation;SQLREPEATABLE READ convient à la grande majorité des applications. Ne change de niveau que pour un besoin précis et compris. PostgreSQL et SQL Server, eux, utilisent READ COMMITTED par défaut : un détail à connaître si tu changes de base de données.
Verrouiller des lignes avec SELECT … FOR UPDATE
L’isolation ne suffit pas toujours. Voici un bug de concurrence très courant : deux clients achètent le dernier article en stock au même moment.
-- Transaction A et transaction B, exactement en même temps
SELECT stock FROM produits WHERE id = 7; -- les deux lisent : stock = 1
UPDATE produits SET stock = 0 WHERE id = 7; -- les deux pensent pouvoir acheter
-- Résultat : deux commandes pour un seul articleSQLChaque transaction a lu une valeur cohérente… mais déjà dépassée au moment de l’écriture. La solution : verrouiller la ligne au moment de la lecture avec FOR UPDATE. La seconde transaction attend que la première se termine avant de pouvoir la lire.
START TRANSACTION;
SELECT stock FROM produits WHERE id = 7 FOR UPDATE; -- verrouille la ligne
-- Si stock > 0 : on peut vendre en toute sécurité
UPDATE produits SET stock = stock - 1 WHERE id = 7;
INSERT INTO lignes_commande (commande_id, produit_id, quantite) VALUES (42, 7, 1);
COMMIT; -- libère le verrouSQLEn PHP :
<?php
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT stock FROM produits WHERE id = ? FOR UPDATE');
$stmt->execute([$produitId]);
$stock = (int) $stmt->fetchColumn();
if ($stock < $quantite) {
throw new RuntimeException('Stock insuffisant.');
}
$pdo->prepare('UPDATE produits SET stock = stock - ? WHERE id = ?')
->execute([$quantite, $produitId]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}PHPPour un cas simple comme celui-ci, une seule requête conditionnelle suffit : UPDATE produits SET stock = stock - 1 WHERE id = 7 AND stock >= 1, puis on vérifie que rowCount() vaut 1. L’opération est atomique, sans SELECT préalable. FOR UPDATE devient indispensable quand la décision dépend de plusieurs lectures.
Les interblocages (deadlocks)
Un interblocage se produit quand deux transactions s’attendent mutuellement : A a verrouillé la ligne 1 et attend la ligne 2, pendant que B a verrouillé la ligne 2 et attend la ligne 1. Aucune ne peut avancer. InnoDB détecte la situation et annule l’une des deux avec l’erreur :
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transactionTexteCe n’est pas un bug de MySQL : c’est un événement normal dans une application très sollicitée, à gérer dans ton code. La transaction annulée peut simplement être relancée :
<?php
function avecReessai(PDO $pdo, callable $operation, int $tentatives = 3): mixed
{
for ($i = 1; ; $i++) {
$pdo->beginTransaction();
try {
$resultat = $operation($pdo);
$pdo->commit();
return $resultat;
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$interblocage = ($e->errorInfo[1] ?? null) === 1213;
if (!$interblocage || $i >= $tentatives) {
throw $e;
}
usleep(random_int(50_000, 200_000)); // petite pause aléatoire avant de réessayer
}
}
}PHP- Accède toujours aux tables et aux lignes dans le même ordre (par exemple, par identifiant croissant).
- Garde des transactions courtes.
- Indexe les colonnes utilisées dans les
WHEREdesUPDATE: sans index, InnoDB verrouille beaucoup plus de lignes que nécessaire.
Les bonnes pratiques des transactions SQL
- Courtes : ouvre la transaction le plus tard possible et termine-la le plus tôt possible. Les verrous sont maintenus jusqu’au
COMMIT. - Sans attente extérieure : n’appelle jamais une API, n’envoie pas d’e-mail et n’attends pas une action de l’utilisateur au milieu d’une transaction. Fais-le après le
COMMIT. - Toujours terminées : chaque
beginTransaction()doit aboutir à uncommit()ou unrollBack(), quoi qu’il arrive (d’où letry/catch). - Pas de DDL dans une transaction de données (commit implicite).
- Les contrôles dans la base : contraintes
CHECK,FOREIGN KEY,UNIQUE. Elles font échouer la transaction au bon moment, même si ton code oublie une vérification.

FAQ
Les transactions SQL ralentissent-elles l’application ?
Très peu, et souvent elles l’accélèrent : regrouper 1 000 INSERT dans une seule transaction est beaucoup plus rapide que 1 000 validations séparées, car le disque n’est synchronisé qu’une fois au COMMIT. Ce qui ralentit, ce sont les transactions longues, qui gardent des verrous.
Que se passe-t-il si la connexion est coupée avant le COMMIT ?
La transaction est automatiquement annulée par le serveur : c’est l’atomicité. Aucune modification partielle n’est enregistrée.
Peut-on imbriquer des transactions ?
Pas dans MySQL : un nouveau START TRANSACTION valide implicitement la transaction en cours. Pour un comportement similaire, utilise les SAVEPOINT. Certains frameworks simulent l’imbrication en créant des points de sauvegarde automatiquement.
Faut-il une transaction pour une seule requête ?
Non : en mode autocommit, chaque requête est déjà atomique. Les transactions deviennent nécessaires dès que plusieurs requêtes doivent réussir ou échouer ensemble.
Conclusion
Les transactions SQL sont la garantie que ta base de données reste cohérente quoi qu’il arrive : panne, erreur, accès simultanés. Retiens le trio START TRANSACTION, COMMIT, ROLLBACK, le modèle PDO avec try/catch et inTransaction(), le verrouillage avec FOR UPDATE pour les stocks et les soldes, et la relance automatique en cas d’interblocage.
Une base de données fiable passe aussi par des requêtes bien écrites : notre guide GROUP BY et HAVING expliqués avec des exemples t’aide à maîtriser l’analyse de tes données. Et pour sécuriser l’ensemble de ton application, jette un œil aux failles de l’OWASP Top 10. La documentation MySQL sur les transactions InnoDB détaille le fonctionnement interne.

