Pourquoi un virement bancaire a besoin de plus que deux UPDATE
Déplacez 50 € d’un compte à un autre et la version naïve, ce sont deux instructions : soustraire du compte A, ajouter au compte B. Exécutez-les séparément et, si le processus plante ou si la connexion tombe entre les deux, l’argent a disparu de A et n’arrive jamais sur B. La base de données a fait exactement ce qu’on lui a demandé, deux fois. L’entreprise a quand même perdu 50 €.
Une transaction enveloppe les deux instructions pour que la base de données les traite comme une seule unité : soit les deux se produisent, soit aucune. Cette garantie, plus trois autres, c’est ce qu’on appelle ACID. C’est pour cela que vous pouvez confier à une base de données de l’argent ou des niveaux de stock, là où « à moitié fait » est pire que « pas fait ».
Ce que garantissent les quatre propriétés ACID
- Atomicité. Toutes les instructions d’une transaction valident ensemble, ou aucune ne valide. Si le processus plante après le débit mais avant le crédit, la base de données annule aussi le débit. Il n’y a aucun état partiel à nettoyer à la main.
- Cohérence (Consistency). Une transaction ne peut faire passer la base de données que d’un état valide à un autre. Si une contrainte
CHECKinterdit un solde négatif, aucune transaction ne peut valider une ligne qui la viole, quoi que le code de l’application ait tenté. - Isolation. Les transactions qui tournent en même temps ne voient pas le travail à moitié fait des autres. Le niveau de rigueur appliqué est un curseur, pas une règle fixe : ce curseur, c’est le niveau d’isolation, traité plus bas.
- Durabilité. Une fois que la base de données annonce qu’une transaction a été validée, cette donnée survit à un crash une seconde plus tard. C’est à cela que sert le write-ahead log : la modification est sur disque (ou répliquée) avant que le commit ne retourne.
L’atomicité et la durabilité sont le travail de la base de données, et les moteurs les plus utilisés les gèrent correctement. L’isolation, elle, dépend de vous. Une isolation plus stricte coûte de la concurrence, et ce coût se voit dans les graphes de latence en production.
Comment exécuter une transaction avec BEGIN, COMMIT et ROLLBACK
Dans PostgreSQL (et la plupart des bases SQL, à de petites différences de syntaxe près), une transaction est l’ensemble des instructions entre BEGIN et COMMIT :
| |
Si quelque chose se passe mal avant le COMMIT — une violation de contrainte, ou une vérification métier dans le code de l’application — ROLLBACK annule tout ce qui s’est passé depuis BEGIN :
| |
Après le rollback, le solde du compte 1 est exactement celui d’avant l’exécution de BEGIN. Pour une transaction longue où vous ne voulez annuler qu’une partie du travail, un savepoint vous donne un rollback partiel :
| |
En dehors d’un BEGIN explicite, la plupart des drivers exécutent chaque instruction dans sa propre transaction auto-validée. Chaque instruction est atomique en elle-même, mais rien ne lie deux instructions entre elles. Sautez le BEGIN dans le virement bancaire et vous retrouvez les 50 € perdus.
Ce que préviennent les niveaux d’isolation des transactions
Deux transactions concurrentes peuvent interférer entre elles de trois façons précises, et les niveaux d’isolation se définissent selon celles qu’ils autorisent :
| Anomalie | Ce qui se passe |
|---|---|
| Dirty read | La transaction A lit une ligne que B a écrite mais pas encore validée. Si B fait un rollback, A a lu une donnée qui n’a jamais existé. |
| Non-repeatable read | A lit la même ligne deux fois dans une transaction et obtient des valeurs différentes, parce que B a validé un changement entre les deux. |
| Phantom read | A relance la même requête WHERE deux fois et obtient un jeu de lignes différent, parce que B a inséré ou supprimé une ligne correspondante entre les deux. |
Le standard SQL définit quatre niveaux autour de ces trois anomalies :
| Niveau d’isolation | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| Read Uncommitted | Possible | Possible | Possible |
| Read Committed | Empêché | Possible | Possible |
| Repeatable Read | Empêché | Empêché | Possible (autorisé par le standard) |
| Serializable | Empêché | Empêché | Empêché |
Vous voudrez rarement utiliser READ UNCOMMITTED. PostgreSQL ne l’implémente même pas et exécute Read Committed à la place sans le dire ; MySQL le respecte, lui, et vous montrera des dirty reads. En pratique, les deux niveaux qui comptent sont Read Committed et Repeatable Read, et les deux grandes bases open source n’ont pas le même réglage par défaut :
- PostgreSQL utilise Read Committed par défaut. Chaque instruction à l’intérieur d’une transaction voit un snapshot pris au début de cette instruction, donc deux
SELECTdans la même transaction peuvent renvoyer des données différentes si une autre transaction a validé entre les deux. - MySQL (InnoDB) utilise Repeatable Read par défaut. Le snapshot est pris une seule fois, à la première lecture de la transaction, et chaque
SELECTultérieur dans cette transaction voit ce même snapshot, même si une autre transaction a validé une mise à jour depuis.
Dans PostgreSQL, fixez le niveau juste après BEGIN, avant qu’aucune requête ne s’exécute :
| |
Dans MySQL, la forme SESSION change le niveau pour chaque transaction suivante sur cette connexion :
| |
Comment Repeatable Read bloque un phantom read dans PostgreSQL
Ouvrez deux sessions psql sur la même table et lancez ceci. Le voir tourner une fois marque plus durablement que mémoriser le tableau.
Session A :
| |
Session B, pendant que la transaction de A est encore ouverte :
| |
Session A, même transaction ouverte, en relançant la requête identique :
| |
La ligne que B a insérée et validée n’apparaît pas. Le Repeatable Read de PostgreSQL prend son snapshot à la première requête de la transaction de A et le garde jusqu’au COMMIT, donc les nouvelles lignes d’autres transactions n’apparaissent jamais. C’est plus strict que ce que demande le standard : le tableau ci-dessus autorise des phantoms à ce niveau, mais l’implémentation de PostgreSQL n’en produit pas. Lancez la même séquence avec A en Read Committed et le second SELECT renvoie la nouvelle ligne, parce que Read Committed prend un snapshot frais à chaque instruction.
Serializable va plus loin. Il détecte quand deux transactions concurrentes ont produit un résultat qui n’aurait pu se produire dans aucun ordre séquentiel, et en annule une avec une erreur de sérialisation (PostgreSQL renvoie le SQLSTATE 40001) plutôt que de laisser l’anomalie se valider. C’est le niveau qui ferme la classique race condition où deux transactions lisent chacune une valeur, décident en fonction d’elle, et écrivent toutes les deux. Le code qui utilise SERIALIZABLE doit être prêt à réessayer :
| |
Ce que coûtent les niveaux d’isolation plus stricts
Le prix d’une isolation plus stricte, c’est la concurrence :
- Les écritures restent verrouillées jusqu’au commit, à tous les niveaux. Un
UPDATEprend un verrou de ligne au moment où il s’exécute et le garde jusqu’àCOMMITouROLLBACK, quel que soit le niveau d’isolation. Ce queRepeatable ReadetSerializableajoutent, c’est de garder le même snapshot (ou, pourSerializable, des verrous de prédicat sur les lignes que vos requêtes ont examinées) pendant toute la transaction. Faites un appel API externe lent entreBEGINetCOMMITet chaque ligne que vous avez écrite reste verrouillée, et chaque ligne que vous avez lue sousSerializablereste suivie, pendant tout le temps que dure cet appel. - Les relances ne sont pas optionnelles sous Serializable. Il rejettera des transactions qui semblent valides sous charge concurrente, par conception. Si votre application ne capture pas les erreurs de sérialisation et ne relance pas, ces transactions échouent de façon intermittente, sous charge, ce qui est le pire moment pour s’en rendre compte.
Repeatable Readpeut lever la même erreur40001sur une simple mise à jour concurrente, donc le code de relance ne sert pas qu’àSerializable— toute transaction au-dessus de Read Committed en a besoin. - Les transactions longues bloquent les changements de schéma et font gonfler le stockage. Le MVCC de PostgreSQL garde les anciennes versions des lignes aussi longtemps qu’une transaction ouverte pourrait encore en avoir besoin. Une transaction laissée ouverte pendant des heures (un bug courant : en ouvrir une dans une requête web et ne jamais faire ni commit ni rollback) empêche
VACUUMde récupérer cet espace, et unALTER TABLEsur une table qu’elle a touchée attend qu’elle se termine. - Une écriture n’a pas toujours besoin d’une transaction explicite. Une seule instruction
UPDATEest déjà atomique. En envelopper une dansBEGIN/COMMITajoute un aller-retour sans aucune garantie supplémentaire. Utilisez une transaction explicite quand la correction dépend de la réussite ou de l’échec conjoint de deux instructions ou plus : le virement bancaire, une inscription qui écrit à la fois une ligne dansuserset dansaccounts, une commande qui décrémente le stock et insère l’enregistrement de la commande.
Les ORM (Entity Framework, SQLAlchemy, ActiveRecord) ouvrent généralement une transaction par unité de travail et la valident pour vous. N’allez pas contre ça. Le bug à surveiller, c’est l’inverse : du code qui appelle .save() ou .commit() séparément pour deux écritures liées, en jetant l’atomicité que l’ORM vous aurait donnée.
Quel niveau d’isolation utiliser
| Si votre charge de travail est… | Utilisez |
|---|---|
| Du CRUD classique de web app, lectures et écritures par requête | Read Committed. C’est le défaut de PostgreSQL pour une raison : contention minimale, et les dirty reads sont déjà exclus. |
| Des rapports ou jobs batch qui ont besoin que chaque requête de la transaction soit cohérente avec les autres | Repeatable Read. Chaque requête lit le même snapshot, donc les totaux et les lignes de détail ne peuvent pas diverger en plein rapport. |
| Des virements financiers, des comptages de stock, tout cas où une course entre deux transactions est un bug de correction, pas une gêne de performance | Serializable, avec une logique de relance sur 40001. Gardez ces transactions courtes et le coût de concurrence reste faible. |
| Une seule instruction sans écriture liée | Pas besoin de transaction explicite. L’instruction est déjà atomique. |
Partez de Read Committed et ne montez que pour les transactions précises où vous savez nommer la course que vous fermez. C’est la même discipline que l’ajout d’un index seulement après avoir trouvé la requête lente. Si vous ne pouvez pas écrire cette course en une phrase, vous n’avez pas encore besoin d’un niveau plus strict.