Dès qu’une application tourne en plusieurs exemplaires, la même question revient :

comment garantir qu’une seule instance fait une chose à la fois ?

Le réflexe est d’aller chercher SELECT ... FOR UPDATE, parce que c’est l’outil de verrouillage qu’on connaît. Et il rend d’excellents services tant que ce qu’on protège est bien une ligne.

Le problème arrive quand nous ne sommes pas intéressé par le verrouillage de ligne :

  • un export vers S3,
  • un recalcul nocturne,
  • une reprise de fichier

PostgreSQL a une réponse à ça, et elle est étonnamment peu utilisée : les advisory locks.

Cet article n’est pas une référence exhaustive sur le gestionnaire de verrous de PostgreSQL, mais un tour d’horizon pratique.

Comment ces verrous fonctionnent, les cas où ils sont le bon outil, et les pièges auxquels on peut être confronté.

🎯 Verrouiller une ligne, ou verrouiller une intention

SELECT ... FOR UPDATE a une propriété structurelle qu’on oublie facilement : il ne verrouille que des lignes qui existent déjà.
C’est logique : un verrou de ligne se pose sur une ligne.

Mais ça bloque des situations très courantes comme ceux énoncés ci-dessous.

Premier cas : la ligne n’existe pas encore.
Deux requêtes concurrentes veulent créer le même compte client, chacune fait son SELECT de vérification, ne trouve rien, et insère.

-- ❌ Deux sessions peuvent passer ici en même temps
SELECT id FROM accounts WHERE client_id = 42 FOR UPDATE;
-- 0 ligne → aucun verrou posé, rien à verrouiller
INSERT INTO accounts (client_id, ...) VALUES (42, ...);

Le FOR UPDATE sur zéro ligne ne verrouille rien du tout.
Quand la règle d’unicité s’exprime par un index unique, la contrainte règle le problème. Quand elle ne s’exprime pas, il n’y a rien à quoi s’accrocher et c’est là le problème.
Exemple : « un seul abonnement actif par client, sauf pour les comptes de démonstration »

Deuxième cas : ce qu’on protège n’est pas dans la base.
Générer un rapport mensuel, pousser un fichier sur un bucket, appeler une API de facturation.
La ressource critique est ailleurs ; la base ne sert que de point de rendez-vous.

Troisième cas : la table est le mauvais grain.
Vouloir sérialiser « le job de réconciliation » ne correspond à aucune ligne en particulier car ça correspond à un nom.

C’est exactement le vide que comblent les advisory locks : ils verrouillent une clé arbitraire, pas un objet de la base. Vous choisissez un entier, PostgreSQL vous garantit qu’une seule session le détient à la fois. Ce que cet entier signifie ne regarde que vous.

Pourquoi « advisory »

« Consultatif » : PostgreSQL ne fait respecter aucune règle métier derrière ce verrou. Il ne bloquera pas un UPDATE concurrent, ne protégera aucune table. Il se contente de garantir l'exclusion mutuelle entre les sessions qui prennent la peine de demander la même clé. Le jour où un script oublie de la demander, il passe 👉 c'est une convention entre vos processus, arbitrée par la base.

🔑 Comment ça marche

Un advisory lock s’identifie par un bigint, ou par deux int4.
Ce sont deux espaces de clés distincts : pg_advisory_lock(1) et pg_advisory_lock(0, 1) ne se bloquent pas mutuellement.

-- Une clé sur 64 bits
SELECT pg_advisory_lock(42);

-- Deux clés sur 32 bits : pratique pour préfixer par domaine
-- 1 = « facturation », 987 = l'identifiant de la facture
SELECT pg_advisory_lock(1, 987);

La variante à deux entiers est celle que je recommande par défaut : le premier entier joue le rôle de namespace applicatif, ce qui rend les collisions bien plus faciles à éviter.

La famille de fonctions se lit sur trois axes :

  • la portée (session ou transaction),
  • le comportement en cas de conflit (bloquant ou non),
  • et le mode (exclusif ou partagé).
Fonction Portée Si déjà pris Libération
pg_advisory_lock(k) Session Bloquant / Attend pg_advisory_unlock(k)
pg_try_advisory_lock(k) Session Non Bloquant / Retourne false pg_advisory_unlock(k)
pg_advisory_xact_lock(k) Transaction Bloquant / Attend Automatique
pg_try_advisory_xact_lock(k) Transaction Bloquant / Retourne false Automatique
pg_advisory_unlock_all() Session Relâche tout

Chacune existe aussi en version _shared (pg_advisory_lock_shared), qui autorise plusieurs porteurs simultanés en lecture mais exclut tout porteur exclusif 👉 la logique classique lecteurs/écrivain.

Aller plus loin — transformer une clé texte en entier

On veut presque toujours verrouiller un nom ("rapport-mensuel"), pas un nombre. Il faut donc le hacher.

PostgreSQL expose hashtext(text), qui rend un integer. C'est tentant, mais cette fonction est interne et non documentée : son algorithme a déjà changé entre versions majeures. Si vos clés sont calculées en base d'un côté et dans l'application de l'autre, une montée de version peut les désaligner silencieusement.

Plus sûr : hacher côté applicatif avec un algorithme stable et explicite (CRC32, FNV-1a), et n'envoyer que l'entier à PostgreSQL. Le calcul devient reproductible, testable, et indépendant de la version du serveur.

⏱️ Session ou transaction : le choix qui compte

C’est la décision la plus importante de tout l’article, et celle qui cause le plus de dégâts quand elle est prise à la légère.

Un verrou de transaction est libéré automatiquement au COMMIT ou au ROLLBACK. Vous ne pouvez pas oublier de le relâcher, et il n’existe d’ailleurs aucune fonction pour le faire manuellement.

Un verrou de session vit tant que la connexion vit. Il traverse les transactions, survit à un ROLLBACK, et n’est relâché que par un appel explicite à pg_advisory_unlock ou par la fermeture de la connexion.

Comparaison de la durée de vie d'un advisory lock de transaction et d'un advisory lock de sessionpg_advisory_xact_lock(k) — portée transaction🔒 verrou tenuBEGINCOMMIT → libéré tout seulpg_advisory_lock(k) — portée session🔒 verrou tenu — la même connexion, du début à la finpg_advisory_lockpg_advisory_unlocktx 1tx 2tx 3
Le verrou de transaction se libère seul. Le verrou de session traverse les transactions mais impose de garder la même connexion pendant toute sa durée de vie.

La règle qui découle de ce schéma tient en une phrase : prenez un verrou de transaction chaque fois que c’est possible.
C’est le seul des deux qui ne peut pas fuir.

Le verrou de session n’a qu’un seul argument en sa faveur : protéger un traitement qui dure plus longtemps qu’une transaction raisonnable. Et il vient avec une contrainte que la plupart des applications modernes violent sans le savoir : le verrou appartient à la connexion, pas à votre code. Nous y revenons dans les pièges.

Un dernier détail qui surprend : les verrous de session sont réentrants avec comptage. Prendre deux fois la même clé dans une session demande deux unlock pour la relâcher.

SELECT pg_advisory_lock(42);   -- compteur = 1
SELECT pg_advisory_lock(42);   -- compteur = 2, ne bloque pas
SELECT pg_advisory_unlock(42); -- compteur = 1 → toujours tenu !

🛠️ Trois cas d’usage concrets

Sérialiser une opération d’initialisation entre replicas

C’est le cas d’usage le plus solide, et celui pour lequel je recommande les advisory locks sans réserve.

Quand cinq instances d’un service démarrent en même temps et exécutent chacune CREATE TABLE IF NOT EXISTS, elles ne se bloquent pas proprement : le DDL concurrent sous PostgreSQL peut échouer avec une erreur d’unicité sur le catalogue, parce que le IF NOT EXISTS n’est pas atomique vis-à-vis d’une autre transaction qui crée le même objet au même instant.

Un pg_advisory_xact_lock autour de l’opération règle le problème en trois lignes :

BEGIN;
-- Toutes les instances demandent la même clé : une seule passe à la fois,
-- les autres attendent leur tour puis constatent que le travail est fait
SELECT pg_advisory_xact_lock(640401);

CREATE SCHEMA IF NOT EXISTS applock;
CREATE TABLE IF NOT EXISTS applock.register (
    key         TEXT        NOT NULL PRIMARY KEY,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMIT;  -- 🔓 le verrou tombe ici, quoi qu'il arrive

Tout est réuni pour que ce soit sûr : l’opération est courte, elle tient dans une transaction, et le verrou se libère même si le CREATE échoue.
La même recette s’applique aux migrations de schéma, c’est d’ailleurs ce que font en interne la plupart des outils de migration.

Faire respecter une règle d’unicité qu’aucun index ne peut exprimer

Reprenons le cas de l’abonnement.
La règle est : « un client ne peut avoir qu’un seul abonnement actif, sauf s’il s’agit d’un compte de démonstration ».
Un index unique partiel couvrirait ce cas précis, mais dès que la règle croise plusieurs tables, il n’y a plus d’index possible.

BEGIN;
-- On verrouille le client, pas une ligne : personne d'autre ne travaillera
-- sur ce client_id tant que la transaction n'est pas terminée
SELECT pg_advisory_xact_lock(1, 42);

-- Ici, la vérification est fiable : aucune session concurrente ne peut
-- s'intercaler entre le SELECT et l'INSERT
SELECT count(*) FROM accounts WHERE client_id = 42 AND statut = 'ACTIF';

INSERT INTO accounts (client_id, statut, ...) VALUES (42, 'ACTIF', ...);
COMMIT;

L’intérêt par rapport à un LOCK TABLE est évident : on sérialise par client, pas globalement.
Deux clients différents continuent de travailler en parallèle.

⚠️ Ce verrou ne protège que les chemins de code qui le demandent.
Une correction en base faite à la main passera à travers sans rien remarquer.
Quand un index unique peut exprimer la règle, préférez toujours l’index.

Ne lancer un job planifié que sur un seul replica

Le cas qui a probablement amené la plupart des lecteurs ici.
Trois instances du même service, un cron interne dans chacune, et l’envie très raisonnable que le rapport ne parte qu’une fois.

// TryLock tente de réserver le job. Renvoie false si un autre replica l'a déjà.
func TryLock(ctx context.Context, db *sql.DB, key int64) (bool, error) {
    var acquis bool
    row := db.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", key)
    if err := row.Scan(&acquis); err != nil {
        return false, fmt.Errorf("échec de la prise du verrou : %w", err)
    }
    return acquis, nil
}

C’est court, c’est lisible, et ça a l’air de marcher. Une seule instance obtient true, les deux autres passent leur tour au lieu d’attendre.

Ce code a pourtant un défaut sérieux, invisible en développement, qui ne se manifeste qu’en production sous la forme de warnings dans les logs de PostgreSQL et, plus rarement, d’un job exécuté deux fois mais c’est le sujet du deuxième article de cette série 🫠.


⚠️ Les pièges

1. L’espace de clés est global à la base

Il n’y a pas de namespace, pas de préfixe par schéma, pas d’isolation par application.
La clé 42 prise par votre service de facturation bloque la clé 42 de votre service de reporting, dans la même base.

La parade tient en deux habitudes : utiliser systématiquement la variante à deux entiers en réservant le premier à un identifiant d’application, et documenter les clés dans le code, à un seul endroit, comme des constantes nommées.

2. Le pool de connexions

Voici le piège qui coûte le plus cher, et il découle directement de la portée session.

Un verrou de session appartient à la connexion PostgreSQL. Or la quasi-totalité des applications modernes parlent à la base à travers un pool : database/sql en Go, HikariCP en Java, sqlx en Rust.
Quand vous exécutez une requête, le pool vous prête une connexion, puis la reprend immédiatement.

// ❌ Deux connexions différentes, très probablement
db.Exec("SELECT pg_advisory_lock($1)", cle)   // verrou posé sur conn A
travailLong()
db.Exec("SELECT pg_advisory_unlock($1)", cle) // unlock envoyé sur conn B

Le second appel part sur une autre connexion, qui ne détient rien. PostgreSQL journalise alors un ⚠:

WARNING: you don't own a lock of type ExclusiveLock

Le verrou reste accroché à la connexion A jusqu’à ce qu’elle soit recyclée.

Si vous devez utiliser un verrou de session, il faut réserver explicitement une connexion.
db.Conn(ctx) en Go, et poser le verrou, faire le travail et le relâcher sur cet objet-là. Sur un pool étroit et un traitement long, cela peut poser un problème.

3. PgBouncer en mode transaction casse tout

Corollaire direct du point précédent, et souvent découvert trop tard. En mode transaction ou statement, PgBouncer réattribue la connexion serveur à un autre client entre deux transactions. Un verrou de session posé à travers PgBouncer atterrit donc sur une connexion partagée, invisible pour votre prochaine requête.

Les verrous de transaction, eux, fonctionnent parfaitement avec PgBouncer en mode transaction, encore un argument pour les préférer.

4. Ni file d’attente équitable, ni délai d’expiration

pg_advisory_lock attend indéfiniment. Il n’y a aucune garantie d’ordre : rien ne dit que la session qui attend depuis le plus longtemps sera servie en premier.

Pour éviter qu’une instance reste bloquée pour toujours, deux options : préférer la variante pg_try_... et gérer le refus dans le code, ou poser un lock_timeout avant l’acquisition bloquante.

BEGIN;
SET LOCAL lock_timeout = '5s';  -- échoue proprement au bout de 5 secondes
SELECT pg_advisory_xact_lock(1, 42);

5. Le verrou ne survit ni au redémarrage, ni au basculement

Les advisory locks vivent en mémoire partagée. Un redémarrage de PostgreSQL, un failover vers un secondaire, et tous les verrous disparaissent d’un coup sans que les applications qui les détenaient en soient informées.

Ils ne sont pas non plus répliqués : un verrou pris sur le primaire n’existe pas sur le standby.
Deux instances qui parlent à deux serveurs différents ne se coordonnent pas.

6. Ils sont observables, encore faut-il regarder

Contrairement à une idée reçue, un advisory lock n’est pas invisible. Il apparaît dans pg_locks comme n’importe quel verrou lourd :

SELECT l.pid, l.objid, l.granted, a.application_name, a.state
  FROM pg_locks l
  JOIN pg_stat_activity a USING (pid)
 WHERE l.locktype = 'advisory';

Les colonnes classid et objid portent la clé décomposée en deux int4 pour la variante à deux entiers, ce qui rend la lecture peu confortable 👉 raison de plus pour garder ses clés documentées 🫠.


📊 Tableau récapitulatif

SELECT FOR UPDATE Advisory — transaction Advisory — session
Verrouille Une ligne existante Une clé arbitraire Une clé arbitraire
Portée Transaction Transaction Connexion
Libération Automatique Automatique Explicite
Peut fuir Non Non Oui
Compatible pool Oui Oui Connexion réservée
Compatible PgBouncer transaction Oui Oui Non
Durée conseillée Courte Courte Longue
Survit au redémarrage Non Non Non

📝 En résumé

  • SELECT FOR UPDATE ne verrouille que des lignes qui existent déjà 👉 dès que la ressource est ailleurs ou pas encore créée, il ne peut rien pour vous.
  • Un advisory lock verrouille une clé arbitraire. Sa signification n’existe que dans votre code : c’est une convention entre vos processus, arbitrée par PostgreSQL.
  • La portée transaction est le choix par défaut : libération automatique, compatible avec les pools et avec PgBouncer, impossible à oublier.
  • La portée session ne se justifie que pour un traitement plus long qu’une transaction et impose alors de réserver une connexion pour toute sa durée.
  • Ces verrous ne survivent ni au redémarrage ni au basculement, et ne sont pas répliqués. Ils coordonnent des processus, ils ne garantissent pas une invariante.

✅ La bonne question n’est pas « advisory lock ou pas », mais combien de temps le verrou doit-il être tenu.
En dessous de quelques secondes, pg_advisory_xact_lock est presque toujours la bonne réponse ; au-delà, il faut changer d’outil et c’est le sujet du prochain article.

Crédits & Ressources