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.

Une transaction est atomique : tout est appliqué au COMMIT, ou rien après un ROLLBACK BEGIN − 100 € compte A + 100 € compte B unité indivisible COMMIT les deux écritures sont visibles ROLLBACK / erreur aucune écriture n'est appliquée
Tout ou rien : l'état intermédiaire (A débité mais B pas encore crédité) n'est jamais visible par les autres.
-- 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 COMMIT rend 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.

Une transaction qui tient un verrou pendant un appel réseau bloque toutes les autres temps → T1 (payante) BEGIN 🔒 verrou tenu — appel HTTP paiement (2–5 s) FOR UPDATE COMMIT T2 ⏳ en attente du verrou… T3 ⏳ en attente… Effets en cascade pendant ces quelques secondes : • connexions immobilisées → pool épuisé • VACUUM bloqué → bloat des tables et index • état « idle in transaction » qui traîne
Le verrou de T1 n'est libéré qu'au COMMIT. Tant que l'appel HTTP tourne, T2 et T3 patientent — et le mal se propage au pool de connexions.

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 VACUUM et 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 transaction est 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, aucun sleep entre le BEGIN et le COMMIT.
🎯 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.

Les transactions en écriture vont au primaire, les transactions en lecture seule aux réplicas Application routage r/w Primaire écritures + lectures read-write Réplica 1 hot standby — read only Réplica 2 hot standby — read only read-only flux WAL
Marquer une transaction 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