← Tous les cours / Transactions SQL & Isolation Cours
00 — ACID

ACID — les 4 garanties d'une transaction

Atomicité, Cohérence, Isolation, Durabilité — quatre promesses que la base de données fait quand tu écris BEGIN.

Une transaction, c'est un groupe d'opérations SQL traité comme une seule unité indivisible. Soit tout réussit, soit rien ne change. La base de données fait quatre promesses pour garantir ça — résumées par l'acronyme ACID.

BEGIN
Op 1
Op 2
COMMIT / ROLLBACK

Atomicité

Tout ou rien
Si une opération échoue au milieu de la transaction, toutes les opérations précédentes sont annulées. La base revient à son état initial.

Imagine un virement bancaire : débiter le compte A et créditer le compte B. Si le crédit échoue après le débit, l'atomicité garantit que le débit est annulé automatiquement.

-- Virement atomique
BEGIN;
  UPDATE comptes SET solde = solde - 100 WHERE id = 1;
  UPDATE comptes SET solde = solde + 100 WHERE id = 2;
COMMIT; -- ou ROLLBACK si erreur

Cohérence

Les contraintes sont toujours respectées
Avant et après la transaction, la base de données est dans un état valide : clés étrangères intactes, contraintes CHECK respectées, soldes non négatifs…

La cohérence est garantie par la combinaison de l'atomicité ET des contraintes déclarées dans le schéma. Si une opération violerait une contrainte, la transaction est annulée.

Isolation

Les transactions ne se voient pas
Deux transactions s'exécutant en parallèle ne doivent pas se perturber mutuellement — comme si chacune s'exécutait seule. Dans quelle mesure elles se voient est contrôlé par le niveau d'isolation.

C'est la propriété la plus complexe à mettre en œuvre. On verra dans les sections suivantes que les bases de données offrent plusieurs degrés d'isolation, chacun avec ses compromis.

Durabilité

COMMIT = engagement permanent
Une fois que la base confirme le COMMIT, les données sont sur disque — même si le serveur crashe une milliseconde après. C'est le rôle du WAL (Write-Ahead Log).

Le WAL est un journal séquentiel : toute modification est d'abord écrite dans ce journal, puis confirmée au disque via fsync(), avant que le COMMIT soit acquitté. En cas de crash, le journal permet de rejouer les transactions commitées.

💡 ACID ≠ performance nulle : les bases modernes implémentent ACID avec des mécanismes très efficaces (MVCC, WAL, group commit). Le coût réel dépend du niveau d'isolation choisi.

🧪 Simulateur — Atomicité

Compte A
1 000 €
Compte B
500 €
01 — ANOMALIES

Anomalies de concurrence

Dirty read, non-repeatable read, phantom read, write skew — ce qui peut mal tourner quand deux transactions s'exécutent en même temps.

Quand plusieurs transactions s'exécutent en même temps, des interactions inattendues peuvent survenir. Ces anomalies de concurrence sont ce que les niveaux d'isolation cherchent à prévenir.

Dirty Read

Lire des données non encore commitées
Transaction A lit les modifications de Transaction B avant que B ait fait son COMMIT. Si B fait ROLLBACK, A a travaillé avec des données qui n'ont jamais existé officiellement.
Transaction A
BEGIN;
-- Lit solde = 1100
-- (donnée non commitée !)
SELECT solde FROM comptes
  WHERE id = 1;
-- Prend une décision
-- basée sur 1100...
Transaction B
BEGIN;
UPDATE comptes
  SET solde = 1100
  WHERE id = 1;
-- pas encore commitée


ROLLBACK; -- annulée !

Non-Repeatable Read

La même ligne renvoie deux valeurs différentes
Transaction A lit une ligne. Transaction B modifie cette ligne et commite. Transaction A relit la même ligne et obtient une valeur différente.

A a pourtant lu la même requête dans la même transaction. Avec un Non-Repeatable Read, le résultat change entre deux lectures.

Phantom Read

Un ensemble de lignes change entre deux lectures
Transaction A exécute SELECT ... WHERE montant > 100 et obtient 5 lignes. Transaction B insère une nouvelle ligne avec montant = 150 et commite. Transaction A réexécute la même requête et obtient 6 lignes.

C'est comme le Non-Repeatable Read, mais pour un ensemble de lignes plutôt qu'une seule. Les "fantômes" sont ces nouvelles lignes qui apparaissent (ou disparaissent) entre deux lectures.

Lost Update

Deux transactions écrasent mutuellement leurs modifications
A et B lisent la même valeur (ex: stock = 10). A décrémente et écrit 9. B décrémente et écrit 9 aussi — en écrasant le travail de A. Le stock devrait être à 8.
-- T1 lit stock=10, décrémente
UPDATE produits SET stock = 10 - 1 WHERE id = 5;

-- T2 a aussi lu stock=10, décrémente
UPDATE produits SET stock = 10 - 1 WHERE id = 5;
-- Résultat : stock=9 au lieu de 8 → mise à jour perdue

Write Skew

Deux transactions lisent, décident, et écrivent en se contredisant
Exemple classique : deux médecins de garde. La règle exige qu'au moins un médecin soit en service. Chacun lit "deux médecins actifs", conclut "je peux me désinscrire", et se désinscrit. Résultat : zéro médecin.

Ce que rend le Write Skew insidieux : chaque transaction individuelle respecte les contraintes telles qu'elle les voit. C'est leur combinaison qui les viole. Seul SERIALIZABLE le prévient.

⚠️ Write Skew ≠ Lost Update : dans le Lost Update, les deux transactions écrivent sur la même ligne. Dans le Write Skew, elles écrivent sur des lignes différentes mais leurs décisions étaient basées sur les mêmes données lues.
Anomalie Ce qui se passe Niveau minimum pour l'éviter
Dirty Read Lit les données non-commitées d'une autre TX READ COMMITTED
Non-Repeatable Read Même ligne = valeurs différentes dans la TX REPEATABLE READ
Phantom Read Ensemble de lignes change entre deux lectures SERIALIZABLE
Lost Update Écriture écrase celle d'une autre TX REPEATABLE READ (selon impl.)
Write Skew Décisions mutuellement contradictoires SERIALIZABLE
02 — NIVEAUX D'ISOLATION

Les 4 niveaux d'isolation

READ UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE — chaque niveau ajoute une protection, retire une anomalie.

La norme SQL:1992 définit quatre niveaux d'isolation. Chaque niveau ajoute une protection contre une anomalie, au prix d'un coût de synchronisation un peu plus élevé. Tu choisis le compromis selon le besoin de ton application.

-- Définir le niveau avant ou juste après BEGIN
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
-- ... tes opérations ...
COMMIT;

READ UNCOMMITTED

Niveau le plus permissif
Une transaction peut lire les modifications non-commitées des autres. Les Dirty Reads, Non-Repeatable Reads et Phantom Reads sont tous possibles.

Pratiquement jamais utilisé en production. PostgreSQL l'accepte syntaxiquement mais le traite comme READ COMMITTED (un Dirty Read n'a aucun sens avec MVCC).

READ COMMITTED

Défaut PostgreSQL
Chaque lecture voit uniquement les données commitées au moment de cette lecture. Pas de Dirty Reads. Mais deux lectures dans la même transaction peuvent renvoyer des résultats différents.

C'est le niveau par défaut de PostgreSQL. Pour la majorité des applications web (lecture-modification-écriture sans dépendances complexes), il est suffisant.

REPEATABLE READ

Snapshot au début de la transaction
Toutes les lectures dans la transaction voient un snapshot figé au moment du premier SELECT. Pas de Dirty Read, pas de Non-Repeatable Read. En PostgreSQL, les Phantom Reads sont aussi éliminés (grâce à MVCC).

Défaut MySQL InnoDB. PostgreSQL l'implémente via MVCC — chaque transaction travaille avec un snapshot cohérent du début à la fin.

SERIALIZABLE

Isolation totale — comme si les transactions étaient séquentielles
Zéro anomalie. Le moteur garantit que l'exécution concurrente produit un résultat équivalent à une exécution en série. PostgreSQL utilise SSI (Serializable Snapshot Isolation) — détection des cycles de dépendances, pas de locks.
💡 SSI vs 2PL : les anciennes implémentations de SERIALIZABLE utilisaient le Two-Phase Locking (beaucoup de verrous, faible concurrence). PostgreSQL et MySQL 8+ utilisent SSI — les transactions s'exécutent librement, et le moteur détecte les conflits potentiels a posteriori pour annuler si nécessaire.
Niveau Dirty Read Non-Repeatable Phantom Write Skew
READ UNCOMMITTED possible possible possible possible
READ COMMITTED protégé possible possible possible
REPEATABLE READ protégé protégé partiel* possible
SERIALIZABLE protégé protégé protégé protégé

* PostgreSQL REPEATABLE READ élimine aussi les Phantom Reads via MVCC.

🧪 Simulateur — Dirty Read

Transaction 1 (lecteur)
Transaction 2 (écrivain)
03 — MVCC POSTGRESQL

MVCC — PostgreSQL et les snapshots

Au lieu de locker, PostgreSQL crée des versions — chaque transaction vit dans son propre snapshot du passé.

PostgreSQL n'utilise pas de locks de lecture pour implémenter l'isolation. À la place, il crée des versions multiples de chaque ligne — c'est le MVCC (Multi-Version Concurrency Control).

MVCC — chaque UPDATE crée une nouvelle version
Quand tu fais UPDATE sur une ligne, PostgreSQL ne modifie pas la ligne en place. Il crée une nouvelle version de la ligne et marque l'ancienne comme obsolète. Les transactions en cours voient l'ancienne version ; les nouvelles transactions voient la nouvelle.

Résultat : les lecteurs ne bloquent jamais les écrivains, et les écrivains ne bloquent jamais les lecteurs.

xmin et xmax

Chaque ligne en PostgreSQL porte deux colonnes cachées :

Colonne Signification
xmin ID de la transaction qui a créé cette version
xmax ID de la transaction qui a supprimé/remplacé cette version (0 si encore active)
-- Voir les versions cachées
SELECT xmin, xmax, id, solde FROM comptes WHERE id = 1;

-- Résultat possible :
--  xmin | xmax | id | solde
--  1043 |    0 |  1 |  900

Le snapshot de transaction

Chaque transaction travaille avec un snapshot figé
Au démarrage de la transaction (ou du premier SELECT selon le niveau d'isolation), PostgreSQL prend un "snapshot" : la liste de toutes les transactions en cours. La transaction ne verra jamais les modifications de ces transactions, même si elles commitent ensuite.
TX 100
commence
snapshot
tx en cours: {101}
TX 101 commite
(non visible pour 100)
TX 100 commite

VACUUM — nettoyage des versions mortes

Les anciennes versions s'accumulent
Après un UPDATE, l'ancienne version de la ligne reste sur disque jusqu'à ce que VACUUM la nettoie. VACUUM identifie les versions dont le xmax est commitée et qu'aucune transaction active ne peut plus voir, puis libère l'espace.
-- VACUUM manuel (normalement automatique)
VACUUM comptes;

-- Voir les tables avec le plus de tuples morts
SELECT relname, n_dead_tup, n_live_tup
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
💡 autovacuum : PostgreSQL lance autovacuum automatiquement quand le ratio de lignes mortes dépasse un seuil. Sur les tables très actives (beaucoup d'UPDATE), surveiller n_dead_tup est important pour éviter le table bloat (tables qui gonflent sur disque).

READ COMMITTED vs REPEATABLE READ en MVCC

Niveau Snapshot pris quand ? Conséquence
READ COMMITTED À chaque instruction SQL Chaque SELECT voit les données les plus récentes commitées
REPEATABLE READ Au premier SELECT de la transaction Toute la transaction voit le même snapshot figé
⚠️ Sérialization failure : en SERIALIZABLE, PostgreSQL peut détecter un cycle de dépendances et lever une erreur ERROR: could not serialize access due to read/write dependencies. L'application doit catcher cette erreur et rejouer la transaction.
04 — LOCKS INNODB

InnoDB — row locks, gap locks, next-key locks

MySQL prévient les phantoms autrement que PostgreSQL — en lockant l'espace entre les valeurs d'index.

MySQL InnoDB utilise une approche différente de PostgreSQL. En plus du MVCC (qui existe aussi dans InnoDB), il emploie des locks au niveau des lignes et des index pour prévenir les anomalies de concurrence.

REPEATABLE READ par défaut, avec gap locks
Le niveau par défaut d'InnoDB est REPEATABLE READ. Contrairement à PostgreSQL, InnoDB utilise des gap locks pour prévenir les Phantom Reads dans ce niveau — pas besoin de passer à SERIALIZABLE.

Row Lock

Verrou sur une ligne précise
Quand tu modifies une ligne, InnoDB pose un row lock sur cette ligne. Les autres transactions qui tentent de modifier la même ligne sont bloquées jusqu'au COMMIT.
-- Row lock implicite lors d'un UPDATE
UPDATE commandes SET statut = 'expédiée' WHERE id = 42;
-- La ligne 42 est lockée jusqu'au COMMIT

Gap Lock

Verrou sur l'espace entre deux valeurs
InnoDB peut locker l'espace entre deux valeurs d'index, empêchant toute insertion dans cet intervalle. C'est ce qui prévient les Phantom Reads.

Exemple : si tu lis SELECT ... WHERE montant BETWEEN 100 AND 200, InnoDB locke le gap entre 100 et 200. Aucune autre transaction ne peut insérer une ligne avec montant = 150 pendant que ta transaction est active.

Next-Key Lock

Row lock + gap lock combinés
Le mécanisme de lock par défaut d'InnoDB pour les range queries. Il combine un row lock sur la ligne existante et un gap lock sur l'intervalle qui la précède.
-- Cette requête pose des next-key locks
SELECT * FROM commandes
  WHERE montant > 100
  FOR UPDATE;
-- Locke les lignes ET les gaps dans l'index montant
💡 Gap locks et index : les gap locks ne fonctionnent qu'avec des index. Sur une colonne sans index, InnoDB peut locker toute la table. Assure-toi d'avoir des index sur les colonnes utilisées dans les clauses WHERE de tes transactions.
Type de lock Ce qui est locké Prévient
Row lock Une ligne précise Modifications concurrentes de la même ligne
Gap lock Espace entre deux valeurs d'index Insertions fantômes dans un range
Next-key lock Ligne + gap précédent Non-Repeatable Reads + Phantom Reads
⚠️ Gap locks peuvent provoquer des deadlocks : deux transactions qui insèrent dans le même gap peuvent se bloquer mutuellement. Utilise SHOW ENGINE INNODB STATUS pour diagnostiquer les deadlocks InnoDB.
05 — SELECT FOR UPDATE

SELECT FOR UPDATE — le verrou explicite

Quand tu as besoin de lire ET écrire sans conflit, FOR UPDATE acquiert le lock dès la lecture — pas au COMMIT.

SELECT ... FOR UPDATE est le verrou explicite de SQL. Il dit à la base de données : "Je vais lire ces lignes ET les modifier — acquiers le lock dès maintenant, pas au moment du COMMIT."

Le problème sans FOR UPDATE
Sans lock explicite, deux transactions peuvent lire la même ligne en parallèle, décider toutes les deux de la modifier, et la dernière à écrire écrase la première — c'est le Lost Update.
Sans FOR UPDATE — Lost Update
-- T1 : lit stock=10
SELECT stock FROM produits
  WHERE id = 5;

-- T2 aussi lit stock=10
-- T2 écrit stock=9

-- T1 écrit stock=9
-- (devrait être 8 !)
Avec FOR UPDATE
-- T1 : lit ET locke
SELECT stock FROM produits
  WHERE id = 5
  FOR UPDATE;

-- T2 bloquée ici
-- T1 écrit stock=9

-- T1 COMMIT
-- T2 lit stock=9, écrit 8 ✓

FOR UPDATE vs FOR SHARE

Commande Type de lock Bloque qui ? Usage
FOR UPDATE Exclusif Autres FOR UPDATE + FOR SHARE + écritures Lecture avant modification
FOR SHARE Partagé Autres FOR UPDATE + écritures (pas les autres FOR SHARE) Lecture avec garantie de non-modification
-- Lock exclusif : personne d'autre ne peut lire/écrire ces lignes
SELECT * FROM commandes WHERE statut = 'en_attente' FOR UPDATE;

-- Lock partagé : d'autres peuvent lire, mais pas modifier
SELECT * FROM produits WHERE id = 5 FOR SHARE;
-- MySQL : équivalent LOCK IN SHARE MODE

SKIP LOCKED et NOWAIT

Par défaut, FOR UPDATE attend que les lignes lockées soient libérées. Deux options permettent de changer ce comportement :

-- NOWAIT : échoue immédiatement si locked
SELECT * FROM jobs WHERE statut = 'pending'
  FOR UPDATE NOWAIT;
-- ERROR: could not obtain lock on row in relation "jobs"

-- SKIP LOCKED : ignore les lignes lockées (idéal pour les queues)
SELECT * FROM jobs WHERE statut = 'pending'
  LIMIT 1
  FOR UPDATE SKIP LOCKED;
-- Retourne la première ligne NON lockée — parfait pour workers parallèles
💡 Queue de jobs avec SKIP LOCKED : c'est la façon idiomatique d'implémenter une queue de jobs en SQL. Chaque worker prend la prochaine ligne disponible, l'autres workers voient une ligne différente grâce à SKIP LOCKED. Simple, sans dépendance externe.
FOR UPDATE et MVCC
En PostgreSQL, FOR UPDATE en READ COMMITTED lit toujours la version la plus récente de la ligne (pas le snapshot), puis pose le lock. Si la ligne a été modifiée entre le snapshot et le lock, PostgreSQL relit la ligne fraîche.
06 — DEADLOCKS

Deadlocks — détecter et prévenir les cycles

Transaction A attend B, B attend A — un cycle que la base brise en sacrifiant une transaction.

Un deadlock survient quand deux transactions (ou plus) s'attendent mutuellement, chacune tenant un verrou que l'autre veut. Le cycle est indéfini — aucune ne peut progresser sans l'aide du moteur.

Le cycle classique
T1 locke la ligne A, puis veut la ligne B.
T2 locke la ligne B, puis veut la ligne A.
→ Ni T1 ni T2 ne peut continuer. La base détecte le cycle et annule la transaction "victime".
T1 : lock A
→ veut B
T2 : lock B
→ veut A
DEADLOCK

Détection et résolution

Les bases de données modernes détectent automatiquement les deadlocks :

Moteur Mécanisme Victime choisie
PostgreSQL Détection de cycle dans le graphe d'attente Transaction "moins coûteuse à rollbacker"
MySQL InnoDB Détection de cycle + timeout (innodb_lock_wait_timeout) Transaction avec le moins de modifications
-- PostgreSQL : l'erreur reçue par la victime
-- ERROR: deadlock detected
-- DETAIL: Process 12345 waits for ShareLock on transaction 789;
-- blocked by process 67890.
-- Hint: See server log for query details.

-- MySQL : voir les deadlocks récents
SHOW ENGINE INNODB STATUS;

Prévention

Règle 1 : toujours locker dans le même ordre
Si toutes les transactions acquièrent les verrous dans le même ordre (ex: toujours A avant B), un cycle devient impossible. Simple et efficace.
Règle 2 : des transactions courtes
Plus une transaction dure longtemps, plus elle occupe des verrous et multiplie les risques de conflits. Réduis le travail entre BEGIN et COMMIT au strict nécessaire.
Règle 3 : des index appropriés
Sans index, un UPDATE peut scanner et locker toute la table au lieu d'une seule ligne. Des index bien placés réduisent drastiquement la surface de lock.
💡 Retenter la transaction : quand la base détecte un deadlock et rollback une transaction, l'application doit attraper l'erreur et retenter. C'est une erreur transitoire, pas un bug de logique. Implémente un retry avec backoff exponentiel.

🧪 Simulateur — Deadlock

Observe comment deux transactions se bloquent mutuellement, et comment le moteur résout le cycle.

07 — OPTIMISTIC LOCKING

Optimistic locking — écrire sans verrouiller

Pas de lock — juste une colonne version. Au COMMIT, si quelqu'un a modifié entre-temps, la mise à jour échoue proprement.

Le locking pessimiste (FOR UPDATE) part du principe que les conflits sont probables — il locke préventivement. Le locking optimiste fait le pari inverse : les conflits sont rares, donc on ne locke pas. On vérifie au moment d'écrire que rien n'a changé.

Principe : une colonne version
Chaque ligne porte une colonne version (entier incrémental ou timestamp). Quand tu modifies une ligne, tu inclus la version que tu as lue dans ta condition WHERE. Si quelqu'un d'autre a modifié la ligne entre-temps, sa version est différente — ton UPDATE ne touche aucune ligne, et tu détectes le conflit.
-- Lecture (pas de lock)
SELECT id, solde, version FROM comptes WHERE id = 1;
-- Retourne : id=1, solde=1000, version=7

-- Modification avec vérification de version
UPDATE comptes
  SET solde = 900, version = 8
  WHERE id = 1 AND version = 7;

-- Si 0 ligne affectée → conflit → rejouer la transaction
Lire ligne
version=7
Calculer
nouvelle valeur
UPDATE WHERE
version=7
0 lignes ?
RETRY

Optimiste vs Pessimiste — quand choisir ?

Critère Optimiste Pessimiste (FOR UPDATE)
Taux de conflits Faible — peu de modifications concurrentes Élevé — beaucoup de concurrence sur les mêmes lignes
Durée de la transaction Longue possible (pas de lock tenu) Courte recommandée (lock tenu pendant toute la TX)
Coût en cas de conflit Élevé (recalcul complet + retry) Faible (attente puis exécution)
Deadlocks Impossibles Possibles
Exemple d'usage Wiki, formulaires de saisie Réservations, stocks en temps réel

Implémentation en Doctrine ORM

Doctrine supporte nativement les deux modes via le LockMode :

// Pessimiste : FOR UPDATE
$em->find(Compte::class, $id, LockMode::PESSIMISTIC_WRITE);

// Optimiste : vérification de version
try {
    $em->lock($compte, LockMode::OPTIMISTIC, $versionLue);
    $em->flush();
} catch (OptimisticLockException $e) {
    // Conflit — rejouer la transaction
}
💡 Column version avec Doctrine : ajoute #[ORM\Version] sur une propriété int ou DateTimeImmutable de ton entité. Doctrine gère l'incrément et la vérification automatiquement.
⚠️ Optimiste dans les API REST : l'ETag HTTP est une forme d'optimistic locking. Le client renvoie l'ETag qu'il a reçu dans If-Match. Le serveur vérifie que la ressource n'a pas changé avant d'appliquer la modification. Même principe, même bénéfice — zéro lock de base de données.
08 — SAVEPOINTS

Savepoints — le point de sauvegarde partiel

Annuler une partie d'une transaction sans perdre tout le travail — SAVEPOINT, ROLLBACK TO, RELEASE.

Un savepoint est un point de restauration partielle à l'intérieur d'une transaction. Il permet d'annuler une partie du travail sans tout rollbacker — comme un "Ctrl+Z" sélectif.

SAVEPOINT — marquer un point de retour
SAVEPOINT nom crée un marqueur dans la transaction courante. Tu peux ensuite revenir à ce marqueur avec ROLLBACK TO SAVEPOINT nom sans annuler tout ce qui précède.

Les trois commandes

BEGIN;

INSERT INTO logs (msg) VALUES ('étape 1'); -- OK

SAVEPOINT avant_etape2;

INSERT INTO commandes (id) VALUES (999); -- Peut échouer

-- Si l'INSERT a échoué, on revient au savepoint
ROLLBACK TO SAVEPOINT avant_etape2;
-- L'INSERT de logs est toujours là, l'INSERT commandes est annulé

RELEASE SAVEPOINT avant_etape2; -- Libère le savepoint (optionnel)

COMMIT; -- Commite ce qui reste (logs)
BEGIN
Op 1 ✓
SAVEPOINT
Op 2 ✗
→ ROLLBACK TO
Op 1 intact
COMMIT

Cas d'usage — boucle avec gestion d'erreurs partielles

Les savepoints sont particulièrement utiles pour importer un lot d'enregistrements où certains peuvent échouer individuellement, sans sacrifier les succès.

BEGIN;

DO $$
DECLARE
    rec RECORD;
    i INT := 0;
BEGIN
    FOR rec IN SELECT * FROM import_staging LOOP
        i := i + 1;
        SAVEPOINT sp;
        BEGIN
            INSERT INTO commandes VALUES (rec.id, rec.montant);
        EXCEPTION WHEN OTHERS THEN
            ROLLBACK TO SAVEPOINT sp; -- annule juste cet INSERT
            INSERT INTO erreurs_import (ligne, msg)
              VALUES (i, SQLERRM);
        END;
        RELEASE SAVEPOINT sp;
    END LOOP;
END;
$$;

COMMIT; -- Commite les succès, les erreurs sont loggées

Savepoints et Doctrine

Doctrine utilise les savepoints automatiquement
Quand tu appelles $em->beginTransaction() à l'intérieur d'une autre transaction (transactions imbriquées), Doctrine crée automatiquement un savepoint en interne — car SQL ne supporte pas les BEGIN imbriqués.
$conn->beginTransaction(); // BEGIN réel

$conn->beginTransaction(); // SAVEPOINT doctrine_savepoint_1
try {
    // opérations partielles
    $conn->commit(); // RELEASE SAVEPOINT doctrine_savepoint_1
} catch (Exception $e) {
    $conn->rollBack(); // ROLLBACK TO SAVEPOINT doctrine_savepoint_1
}

$conn->commit(); // COMMIT réel
💡 RELEASE SAVEPOINT : libère les ressources allouées au savepoint. Non obligatoire (un COMMIT final libère tout), mais bonne pratique dans les boucles pour éviter l'accumulation de savepoints en mémoire.
09 — SYNTHÈSE

Synthèse — choisir le bon niveau d'isolation

Quel niveau pour quelle application ? Optimistic ou pessimistic ? Le guide de décision.

Maintenant que tu connais tous les outils — niveaux d'isolation, MVCC, locks, optimistic locking — il s'agit de choisir le bon pour chaque situation. Voici le guide de décision.

Quel niveau d'isolation ?

Situation Niveau recommandé Pourquoi
Lectures seules, analytics, reporting READ COMMITTED Performances maximales, Dirty Reads pas possibles avec snapshots
Transactions classiques CRUD READ COMMITTED (PostgreSQL défaut) Suffisant pour la majorité des applications web
Calculs financiers, comptabilité REPEATABLE READ Snapshot cohérent sur toute la transaction
Invariants complexes, write skew possible SERIALIZABLE Garantie totale, SSI en PostgreSQL sans locks inutiles

Optimistic ou Pessimistic ?

Choix Quand l'utiliser Exemple concret
Pessimistic (FOR UPDATE) Conflits fréquents, stocks limités, réservations Dernier siège d'avion, stock produit ≤ 5
Optimistic (version column) Conflits rares, formulaires longs, APIs REST Édition de profil utilisateur, wiki
SKIP LOCKED Workers parallèles sur une queue de jobs Traitement d'emails, jobs async sans Redis

Commandes SQL de référence

-- Définir le niveau d'isolation
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;

-- Ou au niveau session (PostgreSQL)
SET default_transaction_isolation = 'repeatable read';

-- Lock explicite
SELECT * FROM t WHERE id = 1 FOR UPDATE;
SELECT * FROM t WHERE id = 1 FOR UPDATE NOWAIT;
SELECT * FROM t LIMIT 1 FOR UPDATE SKIP LOCKED;

-- Savepoints
SAVEPOINT mon_save;
ROLLBACK TO SAVEPOINT mon_save;
RELEASE SAVEPOINT mon_save;

-- Diagnostique PostgreSQL
SELECT * FROM pg_locks;
SELECT * FROM pg_stat_activity;

-- Diagnostique InnoDB
SHOW ENGINE INNODB STATUS;

Checklist de bonnes pratiques

Le principe le plus important
La plupart des problèmes de transactions viennent d'une incompréhension du niveau d'isolation en vigueur. Connais le défaut de ton moteur (READ COMMITTED pour PostgreSQL, REPEATABLE READ pour MySQL), teste avec de la concurrence réelle, et monte le niveau uniquement quand tu as un problème prouvé à résoudre.