PostgreSQL n’écrase jamais une ligne en place lors d’un UPDATE. Il écrit une nouvelle version de la ligne (un nouveau tuple) avec un xmin mis à jour (l’identifiant de transaction qui l’a créée), et marque le xmax de l’ancienne version avec l’identifiant de la transaction qui l’a remplacée. Les deux versions coexistent physiquement sur disque jusqu’à ce que vacuum récupère l’ancienne, ce qui permet aux lecteurs et aux écrivains d’avancer simultanément sans se bloquer mutuellement — un lecteur voit un instantané cohérent des lignes visibles au début de sa transaction, indépendamment des écrivains concurrents modifiant les mêmes lignes.
C’est pourquoi les lecteurs PostgreSQL ne bloquent jamais les écrivains et les écrivains ne bloquent jamais les lecteurs sous les règles de visibilité MVCC normales — un contraste net avec les modèles de concurrence à base de verrous. Le coût se paie ailleurs : chaque UPDATE est en réalité un INSERT plus un marquage de mort, chaque DELETE ne fait que marquer un tuple mort sans le supprimer, et la table grossit physiquement à chaque écriture jusqu’à ce que le processus VACUUM d’autovacuum la balaie et récupère l’espace occupé par les tuples non visibles par aucune transaction active.
C’est aussi la racine mécanique du bloat de table évoqué dans l’article EXPLAIN ANALYZE de cette série : une table à fort trafic UPDATE/DELETE avec un vacuum à la traîne (parce que désactivé, mal configuré, ou bloqué par une transaction longue retenant l’horizon de vacuum) accumule des tuples morts que les Seq Scans, et même les scans d’index, doivent contourner, gonflant à la fois la taille de la table et le coût des requêtes avec des lignes qu’aucune transaction vivante ne peut même voir.
Une transaction longue est dangereuse précisément à cause de ce mécanisme : tant qu’elle reste ouverte, PostgreSQL ne peut pas nettoyer une version de tuple que l’instantané de cette transaction pourrait encore avoir besoin de voir, même si cette transaction ne touche jamais la table en question. Une simple connexion inactive-en-transaction laissée ouverte pendant des heures sur une base active peut bloquer silencieusement vacuum sur toute l’instance, d’où l’intérêt de `idle_in_transaction_session_timeout` et de surveiller `pg_stat_activity` pour repérer les vieilles transactions.
2. Les niveaux d’isolation SQL
Le standard SQL définit quatre niveaux d’isolation selon les anomalies qu’ils évitent : Read Uncommitted (autorise les lectures sales — voir les changements non validés d’une autre transaction), Read Committed (évite les lectures sales mais autorise les lectures non répétables — la même requête exécutée deux fois dans une transaction peut retourner des résultats différents si une autre transaction valide entre-temps), Repeatable Read (évite les lectures non répétables mais autorise selon le standard les lectures fantômes — de nouvelles lignes correspondant à un WHERE apparaissant à la relecture), et Serializable (évite tout cela, se comportant comme si les transactions s’exécutaient une par une).
PostgreSQL n’implémente pas du tout Read Uncommitted — il le remplace silencieusement par Read Committed, car le MVCC rend les véritables lectures sales architecturalement impossibles sans casser délibérément l’isolation par instantané. Read Committed est le niveau par défaut de PostgreSQL : chaque instruction individuelle d’une transaction reçoit son propre instantané frais, si bien que deux SELECT dans la même transaction peuvent voir des états validés différents si une transaction concurrente valide entre les deux.
Le Repeatable Read de PostgreSQL est en réalité plus fort que ce qu’exige le standard SQL — il utilise un instantané unique pour toute la transaction (pris à la première requête, pas au BEGIN), ce qui empêche par ailleurs les lectures fantômes, chose que le standard n’impose pas à ce niveau. Ce qu’il n’empêche pas, c’est le write skew : deux transactions lisant chacune un instantané cohérent, prenant chacune une décision basée sur ce qu’elles ont lu, et validant toutes deux avec succès alors même que la combinaison de leurs écritures viole un invariant qu’aucune vue individuelle ne pouvait déceler.
Le Serializable de PostgreSQL est implémenté via la Serializable Snapshot Isolation (SSI), qui détecte au moment du commit si l’entrelacement de transactions Serializable concurrentes n’aurait pas pu produire un ordre sériel valide, et fait échouer l’une d’elles avec une erreur de serialization failure plutôt que d’autoriser silencieusement le write skew. Le coût est que votre application doit être prête à rejouer les transactions échouant avec le SQLSTATE 40001, et le taux de faux positifs de SSI sous forte contention peut être non négligeable — un vrai compromis entre garanties de correction et complexité de gestion des relances, pas une amélioration gratuite.
3. Verrouillage pessimiste avec SELECT FOR UPDATE
SELECT ... FOR UPDATE acquiert un verrou exclusif au niveau ligne sur chaque ligne retournée par la requête, maintenu jusqu’au commit ou rollback de la transaction. Toute autre transaction tentant de verrouiller (via FOR UPDATE/FOR NO KEY UPDATE) ou de mettre à jour/supprimer la même ligne se bloque jusqu’à la fin de la première — c’est le schéma standard pour les séquences lire-modifier-écrire où vous devez garantir qu’aucune autre transaction ne modifie la ligne entre votre lecture et votre écriture, comme décrémenter un stock ou faire avancer une machine à états de workflow.
FOR UPDATE est le verrou de ligne le plus fort ; PostgreSQL propose aussi FOR NO KEY UPDATE (bloque les autres updates/deletes mais pas les détenteurs de FOR KEY SHARE, utile quand vous modifiez des colonnes non-clés et voulez autoriser les vérifications de clé étrangère concurrentes), FOR SHARE (plusieurs lecteurs peuvent le détenir simultanément, mais il bloque les écrivains — utile pour garantir qu’une ligne ne sera ni supprimée ni modifiée sur ses colonnes clés tant que vous en dépendez), et FOR KEY SHARE (le plus faible, surtout utilisé en interne pour les vérifications de clé étrangère). Choisir le mode de verrou le plus faible qui satisfait réellement votre invariant réduit significativement la contention sous charge.
SKIP LOCKED transforme SELECT FOR UPDATE en brique de base pour des files de travail : au lieu de bloquer sur des lignes déjà verrouillées par un autre worker, la requête les ignore silencieusement et ne retourne que les lignes correspondantes non verrouillées. C’est le schéma standard pour implémenter une file de tâches directement dans PostgreSQL sans système de file externe — plusieurs workers peuvent exécuter la même requête simultanément et chacun s’appropriera un ensemble disjoint de lignes sans contention de verrous ni double traitement d’une même tâche.
NOWAIT est l’instinct opposé : au lieu d’ignorer les lignes verrouillées, il lève immédiatement une erreur si une ligne cible est déjà verrouillée, ce qui est utile quand attendre indéfiniment est pire qu’échouer rapidement — par exemple une action d’interface devant dire à l’utilisateur 'quelqu’un d’autre modifie ceci' plutôt que de rester en suspens jusqu’à ce que l’autre session termine.
4. Détection et prévention des deadlocks
Un deadlock survient quand la transaction A détient un verrou que la transaction B veut, et que B détient un verrou que A veut — aucune ne peut avancer. PostgreSQL ne laisse pas cette situation s’éterniser : un détecteur de deadlock en arrière-plan s’exécute périodiquement (régi par `deadlock_timeout`, 1 seconde par défaut) et, en détectant un cycle dans le graphe d’attente, annule l’une des transactions (généralement celle la moins coûteuse à annuler) avec une erreur deadlock_detected, libérant l’autre pour continuer.
La règle de prévention la plus efficace est un ordre de verrouillage cohérent : si chaque transaction devant verrouiller à la fois la ligne A et la ligne B les verrouille toujours dans le même ordre (disons, toujours par clé primaire croissante), un cycle ne peut jamais se former, car la deuxième transaction arrivée se contentera d’attendre la première au lieu que chacune détienne ce dont l’autre a besoin. C’est une discipline à faire respecter dans le code applicatif ou une couche d’accès aux données partagée — la base ne peut pas deviner votre ordre de verrouillage voulu à partir du seul SQL.
Les transactions longues avec plusieurs acquisitions de verrous séquentielles sont la source habituelle de deadlocks dans le code applicatif réel, en particulier les ORM qui émettent paresseusement des UPDATE dans un ordre déterminé par le parcours du graphe d’objets plutôt que par un ordre de clé délibéré. Garder les transactions courtes, acquérir tous les verrous nécessaires le plus tôt possible (plutôt qu’incrémentalement selon les branches de la logique transactionnelle), et éviter les délais dépendant d’une interaction utilisateur à l’intérieur d’une transaction ouverte réduisent tous la fenêtre où un deadlock peut se former.
Les deadlocks sont un phénomène normal et attendu dans tout système suffisamment concurrent, pas le signe d’un schéma fondamentalement cassé — la bonne réponse est que l’application capture l’erreur deadlock_detected (SQLSTATE 40P01) et rejoue la transaction depuis le début, exactement comme pour un échec de sérialisation d’une transaction Serializable. Traitez les deux comme des conditions à rejouer dans votre couche d’accès aux données plutôt que comme des erreurs exceptionnelles devant faire échouer la requête.
5. Verrous au niveau table et interaction avec le DDL
Les verrous de ligne (issus de SELECT FOR UPDATE, UPDATE, DELETE) ne sont qu’une couche du système de verrouillage de PostgreSQL ; les verrous au niveau table acquis implicitement par presque toute instruction comptent tout autant, en particulier pour le DDL. Un simple SELECT prend ACCESS SHARE ; INSERT/UPDATE/DELETE prennent ROW EXCLUSIVE ; mais ALTER TABLE, DROP TABLE, TRUNCATE, et la plupart des CREATE INDEX (sans CONCURRENTLY) prennent ACCESS EXCLUSIVE, le verrou de table le plus fort, qui entre en conflit avec tous les autres modes de verrou, y compris un simple SELECT.
C’est le mécanisme derrière l’incident classique 'la migration a fait tomber la production' : un ALTER TABLE ADD COLUMN avec un DEFAULT non constant (avant l’optimisation de PostgreSQL 11), ou tout ALTER TABLE devant réécrire la table, se met en file derrière les verrous ACCESS SHARE détenus par les SELECT en cours, puis bloque lui-même tout SELECT suivant tentant d’acquérir son propre ACCESS SHARE (car un verrou plus fort est en file d’attente devant lui, dans l’ordre FIFO de la file de verrous) — une seule instruction de migration peut ainsi bloquer tout le trafic de lecture sur cette table pendant toute sa durée, même si elle n’avait 'besoin' que d’un verrou exclusif bref.
CREATE INDEX CONCURRENTLY existe précisément pour éviter cela : il construit l’index en plusieurs passes sans détenir ACCESS EXCLUSIVE pendant toute l’opération, au prix d’une durée environ double et de l’impossibilité de s’exécuter dans un bloc de transaction. De même, ALTER TABLE ... ADD COLUMN avec une valeur par défaut constante, ou DROP COLUMN (qui ne fait que marquer la colonne supprimée dans le catalogue sans réécrire la table), sont des opérations rapides ne touchant que les métadonnées, tandis qu’ALTER COLUMN TYPE force, pour la plupart des changements de type, une réécriture complète de la table et le verrou exclusif prolongé correspondant.
La règle opérationnelle : pour tout DDL sur une table en trafic de production réel, sachez à l’avance s’il prend ACCESS EXCLUSIVE et pour combien de temps, privilégiez les variantes CONCURRENTLY / valeur par défaut constante / métadonnées seules quand elles existent, fixez un `lock_timeout` court avant d’exécuter un DDL risqué pour qu’il échoue rapidement plutôt que de se mettre en file (puis de bloquer) le trafic réel, et exécutez-le de toute façon en fenêtre de faible trafic.
6. Règle anti-deadlock et synthèse
Par défaut, utilisez Read Committed pour l’écrasante majorité du code applicatif — c’est le niveau par défaut de PostgreSQL pour de bonnes raisons, il a la plus faible contention et le moindre coût de relance, et suffit dès que la vue cohérente propre à chaque instruction suffit (ce qui est le cas de la plupart des opérations CRUD). Recourez au verrouillage explicite de lignes (SELECT FOR UPDATE) quand une séquence lire-modifier-écrire précise, à l’intérieur de Read Committed, a besoin d’une garantie plus forte que les instantanés par instruction, et recourez à SKIP LOCKED spécifiquement pour construire des schémas de consommation en file.
Recourez à Repeatable Read quand une transaction a besoin que plusieurs requêtes voient un instantané cohérent unique de la base (typique de la logique de reporting/réconciliation qui ne doit pas voir un mélange d’état pré- et post-modification), et recourez à Serializable seulement en cas de risque réel de write skew que vos invariants ne tolèrent pas, en étant prêt à implémenter une logique de relance de transaction — ce n’est pas une amélioration sans risque par défaut, cela change le contrat de gestion d’erreurs de votre application.
Sur le verrouillage, les règles pratiques qui préviennent la plupart des incidents en production sont : garder les transactions courtes, toujours acquérir plusieurs verrous dans un ordre cohérent, ne jamais laisser une transaction inactive en plein vol en attente d’I/O externe ou de saisie utilisateur, traiter deadlock_detected et serialization_failure comme des conditions à rejouer plutôt que comme des bugs, et pour le DDL sur des tables vivantes, toujours connaître le niveau de verrou que vous allez prendre et privilégier la variante concurrente/métadonnées seules. Combiné à la discipline de vacuum de la section 1, c’est le cœur opérationnel pour faire tourner PostgreSQL correctement sous charge concurrente réelle — et cela prépare directement les stratégies d’indexation et de partitionnement traitées ensuite dans cette série, qui interagissent toutes deux exactement avec ces mécaniques de verrouillage et de MVCC.
isolation_levels.sqlsql
-- Read Committed (défaut) : chaque instruction reçoit un instantané fraisBEGIN;SELECT balance FROM accounts WHERE id = 1; -- voit l'état validé au moment de CETTE instruction-- ... une autre transaction valide une mise à jour ici ...SELECT balance FROM accounts WHERE id = 1; -- peut voir la nouvelle valeurCOMMIT;-- Serializable : isolation par instantané complète avec détection du write skewBEGIN ISOLATION LEVEL SERIALIZABLE;SELECT sum(balance) FROM accounts WHERE owner_id = 42;-- la logique applicative décide d'insérer une nouvelle ligne selon la sommeINSERT INTO accounts (owner_id, balance) VALUES (42, 100);COMMIT;-- peut lever : ERROR: could not serialize access due to read/write dependencies-- SQLSTATE 40001 -- l'application doit rejouer toute la transaction
job_queue.sqlsql
-- Schéma classique de file de tâches : plusieurs workers, pas de double traitementBEGIN;SELECT id, payloadFROM jobsWHERE status = 'pending'ORDER BY created_atLIMIT 1FOR UPDATE SKIP LOCKED;UPDATE jobs SET status = 'processing', worker_id = 7 WHERE id = $1;COMMIT;-- Échouer rapidement plutôt qu'attendre, quand le blocage est inacceptableSELECT * FROM inventory WHERE sku = 'ABC123' FOR UPDATE NOWAIT;-- ERROR: could not obtain lock on row in relation "inventory"
lock_monitoring.sqlsql
-- Inspecter les chaînes de blocage actuelles (qui attend qui, en ce moment)SELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query, blocking.pid AS blocking_pid, blocking.query AS blocking_queryFROM pg_locks blJOIN pg_stat_activity blocked ON blocked.pid = bl.pidJOIN pg_locks kl ON kl.locktype = bl.locktype AND kl.database IS NOT DISTINCT FROM bl.database AND kl.relation IS NOT DISTINCT FROM bl.relation AND kl.pid != bl.pid AND kl.grantedJOIN pg_stat_activity blocking ON blocking.pid = kl.pidWHERE NOT bl.granted;