🐘 Cas 1 verrous & deadlocks
Objectif : comprendre les verrous PostgreSQL, diagnostiquer une tempête de verrous et éviter les deadlocks qui peuvent figer tout un cluster.
Typologie des verrous & niveaux d’isolation
Types de verrous (ligne, index, relation), niveaux d’isolation, paramètres influents. Comprendre ce que PostgreSQL verrouille vraiment.
Scénarios classiques de deadlocks
Transactions qui s’attendent mutuellement, contraintes FK, triggers et ordres d’accès incohérents.
Diagnostic temps réel
pg_locks, pg_stat_activity, wait_event, logs. Comment identifier rapidement la session responsable.
Design SQL & bonnes pratiques
Limiter la durée des transactions, choisir les bons index, éviter les locks globaux inutiles.
Runbook incident « tempête de verrous »
Checklist minute par minute quand la prod est bloquée par des verrous.
Scripts & vues d’audit locking
Pack de requêtes SQL pour capturer l’état des verrous, des sessions et des requêtes lentes.
Ce que PostgreSQL verrouille
| Type | Exemples |
|---|---|
| Ligne (row lock) | UPDATE/DELETE, SELECT ... FOR UPDATE/SHARE |
| Relation (table/index) | DDL (ALTER TABLE, REINDEX), VACUUM FULL, TRUNCATE, certains INDEX CONCURRENTLY mal gérés |
| Métadonnées | Schémas, catalogues système (CREATE/DROP DATABASE, EXTENSION, etc.). |
Niveaux d’isolation
- READ COMMITTED (par défaut) : verrouillage ligne à la demande, cohérent pour OLTP.
- REPEATABLE READ : snapshot stable, plus de risque de conflits d’écriture.
- SERIALIZABLE : détection de conflits par abort, peut générer beaucoup de rollbacks.
-- Isolation par défaut
SHOW default_transaction_isolation;
-- Forcer un niveau pour une transaction critique
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Paramètres qui jouent sur le locking
max_locks_per_transaction: nombre de verrous distincts par transaction.lock_timeout: délai max avant d’abandonner une attente de verrou.deadlock_timeout: délai avant que le détecteur de deadlock ne s’active.log_lock_waits: loguer les attentes supérieures àdeadlock_timeout.
-- Exemple de politique plus « safe » (session ou global)
SET lock_timeout = '3s';
SET deadlock_timeout = '500ms';
SET log_lock_waits = on;
Idées reçues
- PostgreSQL ne fait pas de « lock escalation » brutal façon SQL Server ; mais un DDL ou un VACUUM FULL sur une grosse table peut agir comme un verrou global.
- 90 % des problèmes viennent de transactions trop longues ou de mise à jour en masse sans découpage.
Pattern 1 – Mise à jour croisée
-- TX1
BEGIN;
UPDATE compte SET solde = solde - 100 WHERE id = 1;
-- attend...
-- TX2
BEGIN;
UPDATE compte SET solde = solde - 50 WHERE id = 2;
UPDATE compte SET solde = solde + 50 WHERE id = 1; -- bloque sur TX1
-- TX1 (suite)
UPDATE compte SET solde = solde + 100 WHERE id = 2; -- bloque sur TX2
Chaque transaction attend un verrou détenu par l’autre → cycle de dépendances → deadlock detected.
Pattern 2 – FK sans index
- Table enfant avec contrainte FK mais sans index sur la colonne référencée.
- DELETE/UPDATE sur la table parent → scan complet de la table enfant avec verrous en cascade.
- Combinaison avec d’autres transactions → deadlocks difficiles à expliquer.
Pattern 3 – Triggers & ordres d’accès
- Deux modules métier accèdent aux mêmes tables mais pas dans le même ordre (A→B→C vs B→A→C).
- Un trigger fait des UPDATE/INSERT sur d’autres tables dans un ordre différent.
- Avec beaucoup de concurrence, les cycles de verrous se multiplient.
Prévention
- Imposer un ordre d’accès strict aux tables pour toute l’application.
- Indexer systématiquement les colonnes de FK.
- Segmenter les gros traitements en lots (batchs de quelques milliers de lignes).
- Limiter la durée des transactions : pas de dialogues métier interactifs à l’intérieur du
BEGIN.
Paramétrage des logs
ALTER SYSTEM SET deadlock_timeout = '500ms';
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
On obtient ainsi des entrées de log exploitables par pgbadger / observabilité pour rejouer le scénario.
Vue « qui bloque qui ? »
SELECT
now(),
bl.pid AS blocked_pid,
a_bl.usename AS blocked_user,
a_bl.query AS blocked_query,
lk.pid AS blocking_pid,
a_lk.usename AS blocking_user,
a_lk.query AS blocking_query
FROM pg_locks bl
JOIN pg_stat_activity a_bl ON a_bl.pid = bl.pid
JOIN pg_locks lk
ON bl.locktype = lk.locktype
AND bl.database IS NOT DISTINCT FROM lk.database
AND bl.relation IS NOT DISTINCT FROM lk.relation
AND bl.page IS NOT DISTINCT FROM lk.page
AND bl.tuple IS NOT DISTINCT FROM lk.tuple
AND bl.transactionid IS NOT DISTINCT FROM lk.transactionid
AND bl.classid IS NOT DISTINCT FROM lk.classid
AND bl.objid IS NOT DISTINCT FROM lk.objid
AND bl.objsubid IS NOT DISTINCT FROM lk.objsubid
AND bl.pid <> lk.pid
JOIN pg_stat_activity a_lk ON a_lk.pid = lk.pid
WHERE NOT bl.granted;
Sessions en attente de verrou
SELECT pid, usename, state, wait_event_type, wait_event,
now() - xact_start AS xact_age,
query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY xact_start;
Checklist d’analyse rapide
- Combien de sessions bloquées ? Sur quelles tables / index ?
- Le blocage vient-il d’un DDL, d’un VACUUM FULL, d’un batch UPDATE/DELETE ?
- La transaction bloquante est-elle ancienne, idle in transaction, ou très active ?
Décision : tuer ou patienter ?
- Transaction très ancienne, souvent idle, non critique → candidate pour
pg_terminate_backend(). - Transaction courte mais critique (paiement, commande) → on stoppe plutôt le batch qui la bloque.
-- Exemple : tuer les sessions qui tiennent un verrou sur une table donnée
SELECT pg_terminate_backend(pid)
FROM pg_locks
JOIN pg_stat_activity USING (pid)
WHERE relation = 'orders'::regclass
AND mode LIKE 'Exclusive%'
AND pid <> pg_backend_pid();
Toutes ces requêtes sont à adapter aux conventions de nommage (schémas, tables sensibles), d’où l’intérêt d’un runbook documenté.
Choix des types de données
| Besoin | Recommandation PG | Pourquoi ? |
|---|---|---|
| ID unique | BIGINT (Identity) ou UUID (v4/v7) | UUID évite les collisions en distribué, mais fragmente plus les index que BIGINT. |
| Texte | TEXT ou VARCHAR | Pas de différence de perf. Éviter CHAR(n) (padding inutile). |
| Date/Heure | TIMESTAMPTZ | Gère les fuseaux horaires automatiquement. Évite les bugs de conversion. |
| JSON | JSONB | Binaire, indexable, plus rapide à lire que JSON texte brut. |
Normalisation vs JSONB
PostgreSQL excelle en hybride. Utilisez JSONB pour les attributs flexibles ou rares, mais gardez les colonnes fréquemment recherchées ou jointes en relationnel strict.
-- Bon design hybride
CREATE TABLE events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
event_type TEXT NOT NULL, -- Indexé et typé
payload JSONB -- Données variables
);
Indexation avancée
- Index partiels : Indexer seulement ce qui est requêté.
WHERE status = 'active'réduit la taille de l'index de 90%. - Covering Index (INCLUDE) : Ajouter des colonnes dans les feuilles de l'index pour permettre un Index Only Scan (zéro lecture de table).
- GIN : Indispensable pour la recherche Full-Text ou le requêtage dans JSONB.
Exemples concrets
-- Index partiel : ultra léger
CREATE INDEX idx_users_active ON users(email)
WHERE deleted_at IS NULL;
-- Covering index : évite le "Heap Fetch"
CREATE INDEX idx_orders_summary ON orders(customer_id)
INCLUDE (total_amount, created_at);
-- SELECT total FROM orders WHERE customer_id=X
-- devient instantané.
pg_stat_user_indexes).Sargable vs Non-Sargable
Pour utiliser un index, ne touchez pas à la colonne dans la clause WHERE.
| À éviter (Scan complet) | Bonne pratique (Index Seek) |
|---|---|
WHERE EXTRACT(YEAR FROM date) = 2024 | WHERE date >= '2024-01-01' AND date < '2025-01-01' |
WHERE nom LIKE '%toto%' | WHERE to_tsvector(nom) @@ to_tsquery('toto') (Full text) |
WHERE lower(email) = 'x@y.com' | Créer un index sur l'expression :CREATE INDEX ON users(lower(email)) |
Pagination & OFFSET
OFFSET 1000000 LIMIT 10 est lent car PG doit lire et jeter 1M de lignes. Préférez la Keyset Pagination (pagination par curseur).
-- Keyset Pagination (Ultra rapide)
WHERE (created_at, id) < ('2024-01-01 12:00', 505)
ORDER BY created_at DESC, id DESC LIMIT 10;Hygiène des transactions
- Courtes : Pas d'appel API externe ou d'attente utilisateur dans un
BEGIN. - Ordonnées : Toujours verrouiller les tables (ou lignes) dans le même ordre pour éviter les deadlocks.
- Timeouts : Définir
lock_timeoutpour ne pas figer une session indéfiniment.
Patterns Anti-Lock
- Queue : Utiliser
FOR UPDATE SKIP LOCKEDpour traiter une file d'attente sans bloquer les autres workers. - Migrations : Toujours utiliser
ADD COLUMN ... DEFAULT(rapide depuis PG 11+) ouCREATE INDEX CONCURRENTLY.
-- Worker pattern
UPDATE jobs SET status = 'processing'
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY priority DESC
FOR UPDATE SKIP LOCKED
LIMIT 1
) RETURNING *;
Traitement de masse
L'ennemi de la perf, c'est le "Round Trip" réseau et le coût de commit transactionnel par ligne.
Méthodes recommandées
- COPY : Pour charger des CSV/Fichiers. C'est 10x à 100x plus rapide que des INSERTs.
- Multi-row INSERT :
INSERT INTO t VALUES (...), (...), (...). Idéal par paquets de 1000 à 5000. - Unlogged Tables : Pour les données temporaires de chargement (pas de WAL = vitesse maximale, mais perte sur crash).
À éviter
- Boucle d'INSERT ligne à ligne dans le code applicatif (ORM mal configuré).
- DELETE massif (provoque du bloat). Préférez
TRUNCATEou le partitionnement (Drop partition).
-- Upsert de masse efficace
INSERT INTO metrics (time, value)
VALUES ('2024-01-01', 10), ('2024-01-01', 20)
ON CONFLICT (time) DO UPDATE
SET value = excluded.value;
Max connections reached, CPU DB faible (attente) ou Load Average élevé.1. Confirmer le blocage
Vérifier si des sessions s'empilent en attente de verrou.
SELECT count(*), wait_event_type, wait_event FROM pg_stat_activity WHERE state = 'active' GROUP BY 2, 3 ORDER BY 1 DESC;
Si Lock ou LWLock domine, c'est confirmé.
2. Identifier le coupable (Root Blocker)
Trouver la transaction qui bloque toutes les autres.
SELECT pg_blocking_pids(pid) as blockers, * FROM pg_stat_activity WHERE wait_event_type = 'Lock' LIMIT 5;
Le PID qui apparaît dans blockers mais qui n'est pas bloqué lui-même est la cible.
Niveau 1 : La manière douce (Cancel)
Envoie un SIGINT. La requête s'arrête, mais la connexion reste ouverte. À tenter en premier.
SELECT pg_cancel_backend(PID_DU_COUPABLE);
Niveau 2 : La manière forte (Terminate)
Envoie un SIGTERM. Coupe la connexion TCP. Radical mais libère tout immédiatement.
SELECT pg_terminate_backend(PID_DU_COUPABLE);
Niveau 3 : Le "Mass Murder" (Urgence absolue)
Si le serveur est inaccessible ou que les verrous s'accumulent trop vite. Tuer toutes les connexions sur une base (sauf la vôtre).
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'ma_base_prod'
AND pid <> pg_backend_pid()
AND state in ('idle in transaction', 'active');Cas A : "Idle in Transaction"
Symptôme : Une session a ouvert un BEGIN, a fait des updates, mais n'a jamais fait COMMIT ou ROLLBACK.
Cause : Bug applicatif, script dev planté, debuggage manuel.
Action : pg_terminate_backend immédiat.
Cas B : Migration DDL Bloquée
Symptôme : Un ALTER TABLE attend un verrou exclusif (AccessExclusiveLock), et tout le trafic de lecture s'empile derrière lui.
Action : Annuler le DDL. Relancer plus tard avec un lock_timeout strict.
Cas C : Manque d'Index FK
Symptôme : Suppression dans une table parente verrouille toute la table enfant.
Action : Identifier la FK, créer l'index en CONCURRENTLY.
Paramètres de sécurité (Safety Nets)
Configurez ces timeouts pour que PostgreSQL se protège lui-même à l'avenir.
| Paramètre | Valeur recommandée (OLTP) | Effet |
|---|---|---|
lock_timeout | 5s à 10s | Si une transaction attend un verrou plus longtemps que ça, elle s'annule toute seule. Évite l'effet boule de neige. |
idle_in_transaction_session_timeout | 60s à 5min | Tue automatiquement les sessions "zombies" qui oublient de commit. Indispensable. |
statement_timeout | 30s à 60s | Tue les requêtes trop longues (au global). |
Vue d’audit « pg_lock_audit »
CREATE OR REPLACE VIEW pg_lock_audit AS
SELECT
now() AS snapshot_ts,
a.datname,
a.usename,
a.application_name,
a.client_addr,
a.pid,
a.state,
a.wait_event_type,
a.wait_event,
now() - a.xact_start AS xact_age,
l.mode,
l.granted,
l.locktype,
l.relation::regclass AS relation,
a.query
FROM pg_stat_activity a
LEFT JOIN pg_locks l ON l.pid = a.pid
WHERE a.pid <> pg_backend_pid();
Vue générique à déployer sur tous les environnements : elle capture l’état des verrous, des sessions et de la requête en cours au moment du snapshot.
Extraction « incident » depuis psql
-- Snapshot complet dans un fichier (psql)
\o lock_snapshot_$(date +%Y%m%d-%H%M%S).log
SELECT * FROM pg_lock_audit ORDER BY snapshot_ts, pid;
\o
- À lancer dès que l’incident est détecté pour figer l’état des verrous.
- Le fichier généré est attaché au ticket d’incident / post-mortem.
- On peut planifier un cron qui exécute ce snapshot quand un seuil d’alertes de locking est dépassé (via l’outil de supervision).
Top requêtes en attente (pg_stat_statements)
SELECT queryid,
left(query,120) AS sample,
calls,
total_time,
mean_time,
rows,
shared_blks_hit + shared_blks_read AS shared_blocks
FROM pg_stat_statements
ORDER BY total_time DESC
LIMIT 20;
- Permet d’identifier les requêtes structurellement lourdes qui sont ensuite souvent impliquées dans les blocages.
- À combiner avec les vues de locking pour déterminer si un problème est purement « perf » ou réellement lié aux verrous.
- Pense à faire un export des résultats avant tout reset de
pg_stat_statements.
