Une transaction, c’est l’outil qu’on apprend en premier et qu’on maîtrise en dernier.
Ce qui se joue vraiment reste souvent invisible jusqu’au jour où la production ralentit sans raison apparente :
- les verrous qu’on tient,
- la mémoire qu’on empêche de nettoyer,
- la connexion qu’on immobilise.
Cet article n’est pas une référence exhaustive sur l’ACID mais un retour d’expérience sur trois questions concrètes :
- quand une transaction est-elle nécessaire,
- quand ne l’est-elle pas,
- quel est le piège qui revient sur presque tous les projets.
On prendra PostgreSQL comme exemple, mais l’essentiel vaut pour n’importe quelle base transactionnelle.
🧩 Une transaction, c’est quoi au juste ?
Une transaction regroupe plusieurs opérations en une seule unité indivisible :
- soit tout réussit et devient visible d’un bloc au
COMMIT, - soit rien ne s’applique après un
ROLLBACK(ou une erreur).
C’est la propriété d’atomicité, le « A » d’ACID.
En SQL, une transaction s’ouvre avec l’instruction BEGIN puis se ferme soit via COMMIT, soit via ROLLBACK.
L’exemple canonique, le virement bancaire :
débiter un compte et créditer l’autre doivent se produire ensemble, sinon l’argent se volatilise ou se duplique.
-- On démarre la transaction
BEGIN;
-- On applique les modifications
UPDATE comptes SET solde = solde - 100 WHERE id = 'A';
UPDATE comptes SET solde = solde + 100 WHERE id = 'B';
-- ✅ On valide les modifications car tout s'est bien passé
COMMIT;
A l’inverse:
-- On démarre la transaction
BEGIN;
-- On applique les modifications
UPDATE comptes SET solde = solde - 100 WHERE id = 'A';
-- 💥 Erreur de typage
UPDATE comptes SET solde = solde + 'Hello' WHERE id = 'B';
-- ❌ On abandonne les modifications car une erreur est survenue
ROLLBACK;
Sous le capot, PostgreSQL s’appuie sur le MVCC (Multi-Version Concurrency Control) :
chaque transaction travaille sur un instantané cohérent de la base.
Les lecteurs ne bloquent jamais les écrivains, et inversement, seuls deux écrivains qui touchent la même ligne se disputent.
C’est cette mécanique qui, mal comprise, se retourne contre nous avec les transactions longues (on y revient).
Le détail qu'on oublie
Sous PostgreSQL, chaque instruction est déjà une transaction.
Un UPDATE seul, sans BEGIN, s'exécute dans une transaction implicite qui commit toute seule (autocommit).
Ouvrir explicitement une transaction n'a donc de sens que pour regrouper plusieurs instructions.
🔤 ACID : et les trois autres lettres ?
On retient surtout le « A ». Mais une transaction porte trois autres garanties, et chacune se traduit par une décision très concrète une fois en production.
| Lettre | Propriété | Ce qu’elle garantit | Où elle se joue au quotidien |
|---|---|---|---|
| A | Atomicité | Tout ou rien | COMMIT / ROLLBACK |
| C | Cohérence | La base passe d’un état valide à un autre | Contraintes, clés étrangères, triggers |
| I | Isolation | Les transactions concurrentes ne se marchent pas dessus | Niveau d’isolation, MVCC |
| D | Durabilité | Ce qui est validé survit à un crash | WAL, réplication |
C comme Cohérence
Une transaction fait passer la base d’un état valide à un autre. Jamais elle ne la laisse entre les deux.
En pratique, c’est PostgreSQL qui refuse de valider quoi que ce soit qui violerait une contrainte : clé primaire dupliquée, clé étrangère orpheline, CHECK non respecté, NOT NULL oublié. La moindre violation fait échouer l’instruction, et l’atomicité se charge d’annuler le reste.
Le vrai levier est là : tout invariant qu’on sait exprimer en contrainte de base est un invariant qu’on n’a plus à défendre à la main. Un montant qui ne peut pas être négatif, c’est un CHECK (montant >= 0), pas trois vérifications éparpillées dans le code applicatif. Ce que la base ne connaît pas, en revanche, les règles qu’on garde côté application, reste à notre charge, et c’est souvent pour ça qu’on ouvre une transaction : tenir un invariant que le schéma seul ne sait pas exprimer.
I comme Isolation
Deux transactions qui tournent en même temps doivent donner le même résultat que si elles s’étaient exécutées l’une après l’autre.
C’est exactement ce que le MVCC orchestre : chaque transaction travaille sur son instantané et ne voit jamais les écritures non validées des autres. PostgreSQL propose plusieurs niveaux, du plus souple au plus strict :
| Niveau | Comportement | À retenir |
|---|---|---|
READ COMMITTED (défaut) |
Un nouvel instantané à chaque instruction | Deux SELECT d’une même transaction peuvent voir des données différentes |
REPEATABLE READ |
Instantané figé dès la première requête | Peut échouer avec could not serialize access |
SERIALIZABLE |
Comme si les transactions s’enchaînaient en série | Le plus sûr, mais impose une logique de retry |
Deux conséquences directes pour la gestion des transactions.
D’abord, aux niveaux stricts, PostgreSQL peut avorter une transaction avec l’erreur 40001 (could not serialize access) : ce n’est pas un bug, c’est le contrat, et l’application doit la rejouer.
Ensuite, plus une transaction dure, plus son instantané s’éloigne de la réalité et plus le risque de conflit grimpe, une raison de plus de les garder courtes (on y revient).
D comme Durabilité
Quand le
COMMITrend la main, l’écriture est gravée. Même si le serveur perd le courant la seconde d’après.
PostgreSQL y parvient grâce au WAL (Write-Ahead Log) : avant de valider un COMMIT, il écrit d’abord l’opération dans un journal séquentiel forcé sur disque. En cas de crash, la base rejoue ce journal au redémarrage et récupère toutes les transactions validées.
Ce même WAL est le fil qui relie ce chapitre au reste de l’article : c’est lui qu’on envoie aux réplicas pour étendre la durabilité au-delà d’une seule machine, et c’est précisément ce flux qu’exploitent les transactions en lecture seule, plus bas.
Le curseur durabilité / latence
Le paramètre synchronous_commit arbitre ce compromis. À on (défaut), le COMMIT attend la confirmation que le WAL est bien sur disque. À off, il rend la main plus vite, au prix d'une fenêtre de quelques millisecondes où un crash pourrait perdre les toutes dernières transactions : acceptable pour des données peu critiques, à proscrire pour un virement.
🎯 Quand a-t-on vraiment besoin d’une transaction ?
La règle tient en une phrase : dès qu’un invariant métier s’étend sur plusieurs écritures, il faut une transaction, sinon on peut s’en passer.
| Situation | Transaction ? | Pourquoi |
|---|---|---|
Un seul INSERT / UPDATE / DELETE |
❌ Inutile | L’instruction est déjà atomique |
Une simple lecture (SELECT) |
❌ Inutile* | Rien à valider ni annuler |
| Virement, décrément de stock + création de commande | ✅ Indispensable | Plusieurs lignes/tables doivent basculer ensemble |
| Insertion d’une commande et de ses lignes | ✅ Indispensable | Une commande sans lignes est un état incohérent |
| Écritures indépendantes sans lien métier | ❌ Superflu | Les grouper ne fait qu’allonger la transaction |
Une transaction en lecture seule explicite a quand même son utilité dès qu'on veut un instantané cohérent sur plusieurs requêtes, ou router la lecture vers un réplica.
Le réflexe inverse est tout aussi coûteux : entourer par précaution tout et n’importe quoi d’un BEGIN … COMMIT.
Grouper deux écritures sans lien logique, c’est tenir des verrous plus longtemps que nécessaire et allonger la durée de la transaction sans rien y gagner. La bonne granularité, c’est l’invariant métier, ni plus, ni moins.
👉 Ne demandez pas « puis-je mettre une transaction ici ? » mais « ces écritures doivent-elles réussir ou échouer ensemble ? ».
👉 Si la réponse est non, pas de transaction.
⏳ Le vrai piège : la transaction longue
Voici l’erreur que presque tout le monde a commise une fois, moi y compris.
On veut débiter une commande et encaisser un paiement via un prestataire externe.
En apparence, c’est un cas d’école pour une transaction :
BEGIN;
SELECT * FROM commandes WHERE id = $1 FOR UPDATE; -- 🔒 verrou pris sur la ligne
-- ⬇️ code applicatif : await paiement.charger(...) -- 2 à 5 secondes de réseau
UPDATE commandes SET statut = 'payee' WHERE id = $1;
COMMIT; -- 🔒 verrou enfin relâché
Ça « marche » en développement. En production, sous charge, ça met la base à genoux. Le SELECT … FOR UPDATE a posé un verrou sur la ligne, et ce verrou est tenu pendant tout l’appel réseau. Chaque autre transaction qui veut cette commande attend 2 à 5 secondes. Les connexions s’accumulent, le pool se vide, et l’incident se propage bien au-delà de la table concernée.
Les dégâts ne s’arrêtent pas aux verrous. Une transaction qui reste ouverte longtemps a trois effets pervers, propres à MVCC :
- elle bloque le
VACUUMet pas seulement sur sa table,
Tant qu’une vieille transaction est vivante, PostgreSQL ne peut pas recycler les dead tuples qu’elle pourrait encore voir, sur toute la base. Résultat : les tables et index gonflent (bloat), tiennent moins en cache, et les scans ralentissent.
- elle immobilise une connexion,
Une connexion
idle in transactionest le problème opérationnel MVCC numéro un : elle ne fait rien, mais elle empêche le ménage et occupe une place dans le pool.
- elle tient ses verrous jusqu’au bout.
Un pic de latence chez le prestataire externe se propage en contention généralisée chez vous.
La correction est presque toujours la même : sortir toute I/O externe de la transaction.
On appelle le prestataire d’abord, puis on ouvre une transaction courte, purement base de données :
-- 1) l'application appelle paiement.charger(...) HORS transaction, récupère payment_id
-- 2) transaction courte, aucune I/O réseau à l'intérieur
BEGIN;
UPDATE commandes
SET statut = 'payee', payment_id = $1
WHERE id = $2 AND statut = 'en_attente' -- conditionnel : idempotent + détecte les courses
RETURNING *;
COMMIT;
La clause AND statut = 'en_attente' rend l’UPDATE idempotent et détecte les courses concurrentes sans avoir à poser de verrou explicite. Si zéro ligne est renvoyée, c’est qu’une autre exécution est déjà passée et on le gère côté application.
Deux garde-fous à activer en production
Même avec de la discipline, un bug finit par ouvrir une transaction et l'oublier. PostgreSQL sait couper court.
-- tue les sessions restées « idle in transaction » (30 s à 5 min selon la charge)
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
-- borne toute requête qui s'emballe
SET statement_timeout = '5s'; -- par session, ou SET LOCAL en transaction
SELECT pg_reload_conf();
Pour repérer les coupables déjà en place :
SELECT pid, state, now() - xact_start AS age, wait_event, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start; -- les plus vieilles transactions en tête
La règle d’or : une transaction se compte en millisecondes.
✅ Aucun appel réseau, aucune attente utilisateur, aucunsleepentre leBEGINet leCOMMIT.
🎯 Le travail lent se fait avant ou après, jamais pendant.
📦 Tout ce qui précède existe en Go et s’exécute : le dépôt compagnon de cet article. Chaque thèse y a un test qui la prouve, pas un test qui la répète — le chemin fautif ci-dessus y est chronométré face à sa correction, à travail égal et résultat en base identique.
📖 La lecture seule brille avec la réplication
On l’a dit : pour une simple lecture, pas besoin d’entourer quoi que ce soit.
Mais déclarer explicitement une transaction en lecture seule apporte deux choses réelles.
BEGIN TRANSACTION READ ONLY;
SELECT ... ; -- plusieurs requêtes sur un instantané cohérent
SELECT ... ;
COMMIT;
1. Une intention claire, et un filet de sécurité. PostgreSQL refusera toute écriture dans cette transaction (ERROR: cannot execute UPDATE in a read-only transaction). C’est une garantie utile pour du reporting ou des exports : impossible de modifier la base par accident.
2. Le vrai bénéfice : router la lecture vers un réplica. C’est là que ça devient intéressant quand la réplication est en place. Le primaire encaisse les écritures ; un ou plusieurs hot standbys reçoivent le flux WAL et servent les lectures. Une transaction marquée READ ONLY peut partir sur un réplica sans jamais toucher le primaire.
READ ONLY permet à la couche de routage (proxy ou pool applicatif) de l'envoyer sans crainte vers un réplica, ce qui déleste le primaire.Concrètement, tout le trafic de lecture (reporting, pages de consultation, exports, tableaux de bord) part sur les réplicas, et le primaire se concentre sur les écritures. Sur une application où les lectures dominent, ce qui est le cas le plus fréquent, le primaire respire et la capacité de lecture grimpe pour presque rien une fois la réplication en place.
La contrepartie : le retard de réplication
Un réplica applique le WAL avec un léger décalage, un indicateur à surveiller et contrôler.
Une lecture qui en sort peut donc rater une écriture tout juste validée sur le primaire, le classique « je viens de sauvegarder mais je ne vois pas mon changement ». Pour les écrans read-your-writes (juste après une écriture de l'utilisateur), routez la lecture vers le primaire et réservez les réplicas au trafic qui tolère quelques millisecondes de retard.
Deux réglages utiles autour de tout ça :
1. default_transaction_read_only = on sur la connexion vers les réplicas : toute transaction y est en lecture seule par défaut.
C’est un réglage de connexion, pas de transaction : il couvre donc aussi l’autocommit, et pas seulement ce qu’on a pensé à entourer d’un BEGIN. Sur un standby, c’est de toute façon une ceinture par-dessus des bretelles, car un serveur en recovery (replica de lecture) refuse les écritures même si la session remet le réglage à off.
Aller plus loin — l'autocommit, et le cas du standby
Sous PostgreSQL il n'y a pas de « hors transaction ». En autocommit, chaque instruction s'exécute déjà dans une transaction implicite (le détail vu plus haut). default_transaction_read_only est un GUC, Grand Unified Configuration, le nom que PostgreSQL donne à tous ses paramètres de configuration (postgresql.conf, ALTER SYSTEM, ou simplement SET le temps d'une session). Il fixe la valeur par défaut de transaction_read_only pour toute transaction ouverte sur la session, implicite ou explicite — d'où le terme « réglage de connexion » et sa couverture de l'autocommit.
Le mécanisme est réactif, pas déclaratif : rien à annoncer à l'ouverture d'une transaction. Le caractère lecture seule n'est évalué qu'au moment où une commande écrivante s'exécute, avec le message ERROR: cannot execute INSERT in a read-only transaction. Une transaction en lecture seule interdit INSERT, UPDATE, DELETE, MERGE, COPY FROM vers une table non temporaire, tout CREATE/ALTER/DROP, ainsi que SELECT ... FOR UPDATE.
Pour écrire malgré le défaut, pas de déclaration d'intention à passer : on lève simplement le drapeau, en premier dans le bloc.
SET transaction_read_only = off; -- au niveau session
-- ou, ciblé sur une transaction explicite :
BEGIN;
SET TRANSACTION READ WRITE;
INSERT INTO ...;
COMMIT;
Ce « doit être la première instruction » est un vrai garde-fou côté serveur, avec trois messages distincts selon le moment où on l'enfreint :
| Situation | Erreur | SQLSTATE |
|---|---|---|
| Après la première requête du bloc | transaction read-write mode must be set before any query |
25001 |
| Dans une sous-transaction read-only | cannot set transaction read-write mode inside a read-only transaction |
25001 |
| Sur un serveur en recovery | cannot set transaction read-write mode during recovery |
0A000 |
Une précision avant de tester, sinon le tableau paraît faux : ces trois erreurs ne se déclenchent que si la transaction est déjà en lecture seule. Sur une transaction ordinaire, SET TRANSACTION READ WRITE après une requête renvoie SET sans broncher — il ne change rien, il n'y a donc rien à refuser.
La dernière ligne est la clé de la « ceinture par-dessus les bretelles ». Le même bloc, joué des deux côtés :
BEGIN TRANSACTION READ ONLY;
SET TRANSACTION READ WRITE;
-- primaire : SET ✅ le drapeau se lève, aucune requête n'a encore été jouée
-- standby : ERROR 0A000 ❌ cannot set transaction read-write mode during recovery
Sur un primaire, default_transaction_read_only = on n'est donc qu'une convention : l'application peut la renverser d'elle-même, à condition de le faire avant sa première requête. Sur un standby, ce même SET échoue avant même d'arriver à la commande écrivante, car le refus vient d'un autre étage — le fait que le serveur est en recovery, pas du GUC lui-même.
2. max_standby_streaming_delay (30 s par défaut) et hot_standby_feedback (off par défaut) pour les longues requêtes analytiques sur un réplica. C’est là qu’est le vrai risque d’annulation : le rejeu du WAL entre en conflit avec l’instantané de la requête, et le standby finit par la tuer. Le premier fixe le délai de grâce avant l’annulation, le second demande au primaire de ne pas recycler les lignes dont le standby a encore besoin.
Aller plus loin — pourquoi 30 secondes ne veut pas dire 30 secondes
max_standby_streaming_delay n'est pas un timeout par requête. C'est le retard maximum toléré dans l'application du WAL, mesuré depuis l'instant où le WAL a été reçu. Le délai de grâce accordé à une requête n'est jamais supérieur au paramètre, et peut être bien moindre si le standby a déjà pris du retard — par exemple parce qu'une requête précédente l'a fait patienter. Sous charge, le budget peut être épuisé dès l'arrivée de la requête : annulation immédiate, alors que la même requête serait passée sans problème une heure plus tôt.
Quand l'annulation tombe, elle prend cette forme :
ERROR: canceling statement due to conflict with recovery
DETAIL: User query might have needed to see row versions that must be removed.
Le SQLSTATE de cette erreur est 40001 — le même que l'échec de sérialisation. Un client qui retente automatiquement sur 40001 relance donc la requête, qui se fait tuer pareil tant que le conflit dure.
C'est la nuance qui manque à la règle posée plus haut, « 40001 n'est pas un bug, c'est le contrat, rejouez ». Elle vaut sur le primaire : le conflit vient d'une transaction concurrente, elle finit par se terminer, et le rejeu passe. Sur un standby, le même code dit l'inverse : rejouer tout de suite ne sert à rien tant que le rejeu du WAL n'a pas avancé, et une politique de retry aveugle transforme une annulation en boucle. Même SQLSTATE, deux causes opposées — le retry doit savoir sur quel serveur il tourne (pg_is_in_recovery()), et sur un réplica temporiser au lieu de relancer aussitôt.
Il existe cinq causes de conflit avec le rejeu, et hot_standby_feedback n'en couvre qu'une seule :
| Cause | DETAIL renvoyé |
hot_standby_feedback aide ? |
|---|---|---|
| Nettoyage VACUUM d’une ligne encore visible par l’instantané | User query might have needed to see row versions that must be removed. | ✅ |
Verrou ACCESS EXCLUSIVE rejoué (DDL, TRUNCATE, LOCK) |
User was holding a relation lock for too long. | ❌ |
| Buffer épinglé trop longtemps | User was holding shared buffer pin for too long. | ❌ |
| Tablespace supprimé sur le primaire | User was or might have been using tablespace that must be dropped. | ❌ |
| Deadlock entre le rejeu et la transaction | User transaction caused buffer deadlock with recovery. | ❌ |
Un DROP DATABASE rejoué ne tue pas la requête mais toute la session, en FATAL. Ces compteurs sont visibles dans pg_stat_database_conflicts, côté standby.
hot_standby_feedback déplace le coût du conflit, il ne le supprime pas : le standby remonte le xmin de sa plus vieille transaction au primaire, qui renonce alors à nettoyer les lignes encore nécessaires. Une requête analytique de 40 minutes sur le réplica bloque donc le VACUUM du primaire pendant 40 minutes, exactement comme si elle y tournait — c'est le piège de la transaction longue, déplacé d'un serveur à l'autre.
L'arbitrage dépend du rôle du réplica :
| Rôle du réplica | hot_standby_feedback |
max_standby_streaming_delay |
Garde-fou indispensable |
|---|---|---|---|
| Analytique / reporting | on |
généreux, voire -1 (attente illimitée) |
statement_timeout sur les requêtes |
| Failover / HA | off |
bas (le défaut, ou moins) | rien : la priorité est de rester à jour |
Enfin, quand la connexion de réplication tombe puis se rétablit, le feedback s'est interrompu entre-temps : le standby rattrape alors un tas de WAL d'un coup et peut annuler en rafale. Sur des standbys qui se reconnectent souvent, augmenter aussi max_standby_archive_delay absorbe ce trou.
📝 En résumé
| Réflexe | La bonne pratique |
|---|---|
| Portée | Une transaction = un invariant métier sur plusieurs écritures. Pas plus, pas moins. |
| Instruction unique | Déjà atomique : inutile de l’entourer d’un BEGIN … COMMIT. |
| Durée | Millisecondes. Aucune I/O réseau, aucune attente utilisateur pendant la transaction. |
| Appel externe | Avant ou après, jamais pendant. UPDATE conditionnel pour l’idempotence. |
| Garde-fous | idle_in_transaction_session_timeout + statement_timeout en production. |
| Lecture | READ ONLY explicite pour router vers un réplica et décharger le primaire. |
| Réplication | Attention au retard : read-your-writes sur le primaire, le reste sur les réplicas. |
| Réplica analytique | hot_standby_feedback + statement_timeout : la transaction longue se déplace d’un serveur à l’autre, elle ne disparaît pas. |
✅ La longue transaction ne se voit pas en développement. Elle se paie en production, sous charge, sur une table qui n’a parfois rien à voir. Gardez-les courtes, gardez l’I/O dehors, et servez la lecture depuis les réplicas.
🧑💻 Retrouver le code associé à cet article avec les thèses ci-dessus en Go, chacune avec le test qui la prouve.
Crédits & Ressources
- PostgreSQL — Transactions — les bases du modèle transactionnel
- PostgreSQL — Transaction Isolation — niveaux d’isolation et MVCC
- PostgreSQL — Explicit Locking — comprendre les verrous et
FOR UPDATE - PostgreSQL — Hot Standby — lectures sur réplica, conflits de rejeu et
max_standby_streaming_delay - PostgreSQL — Handling Query Conflicts — les cinq types de conflit et les paramètres de délai
- PostgreSQL —
pg_stat_database_conflicts— compteurs de conflits par cause, côté standby - Gérer ses migrations de schéma PostgreSQL — l’autre facette du travail avec Postgres en production
- Gérer des jobs asynchrones avec PostgreSQL —
SKIP LOCKEDet transactions courtes en pratique

