Le premier article de cette série se terminait sur un bout de code Go qui avait l’air correct, et une promesse : il ne l’était pas.
Voici ce qui s’est passé.
Une plateforme SaaS, plusieurs services, chacun déployé en plusieurs replicas. Dans chaque service, un ordonnanceur interne, et un verrou PostgreSQL censé garantir qu’un job planifié ne parte que sur une seule instance.
Ça tournait comme ça depuis des mois, personne ne s’était plaint.
Cet article est un retour d’expérience.
Le mécanisme décrit ici a remplacé l’advisory lock sur un cas d’usage précis : des jobs longs, sur des replicas, avec un pool de connexions étroit. Sur d’autres cas, l’advisory lock reste le bon outil, et on l’a gardé par ailleurs.
🔍 Le symptôme
Tout est parti des logs de PostgreSQL, pas de ceux de l’application :
WARNING: you don't own a lock of type ExclusiveLock
Sur vingt-quatre heures, plusieurs milliers d’occurrences pour le service le plus touché. Quelques centaines par heure, en continu.
Ce qui a rendu ce chiffre intéressant, ce n’est pas son volume : c’est sa régularité. Un incident produit un pic puis retombe.
Un débit constant, jour et nuit, week-end compris, décrit autre chose : un comportement systémique, présent depuis toujours, que personne n’avait relié à quoi que ce soit.
Le signal qu'on avait sous les yeux
Ce warning était en réalité une information de qualité : PostgreSQL nous disait que notre code libérait un verrou qu'il ne détenait pas. Il a fallu des mois pour que quelqu'un, moi, le lise.
La leçon n'est pas technique : les logs du serveur de base de données ne sont presque jamais branchés sur les mêmes alertes que ceux de l'application, et c'est exactement là que ce genre de défaut se cache.
🧩 Le code incriminé
Voici le verrou, réduit à l’essentiel.
L’interface est celle qu’attendait l’ordonnanceur : Lock réserve le job, et rend un objet dont le Unlock sera appelé à la fin de l’exécution.
type PGLocker struct {
db *sql.DB
}
// Lock tries to reserve the job. If another replica already holds it,
// we return ErrAlreadyLocked and the scheduler simply skips its turn.
func (l *PGLocker) Lock(ctx context.Context, key string) (Lock, error) {
var acquired bool
hash := hashKey(key)
row := l.db.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", hash)
if err := row.Scan(&acquired); err != nil {
return nil, fmt.Errorf("failed to acquire lock: %w", err)
}
if !acquired {
return nil, ErrAlreadyLocked
}
return &pgLock{db: l.db, hash: hash}, nil
}
type pgLock struct {
db *sql.DB
hash uint32
}
// Unlock is called by the scheduler once the job is done.
func (l *pgLock) Unlock(ctx context.Context) error {
_, err := l.db.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", l.hash)
return err
}
// hashKey projects a business key onto the numeric key space advisory locks require.
// A 32-bit hash keeps the value within the positive range of a bigint, so the same key
// always maps to the same lock, whatever the replica and whatever the PostgreSQL version.
func hashKey(key string) uint32 {
h := fnv.New32a()
_, _ = h.Write([]byte(key))
return h.Sum32()
}
Relisez-le, la logique est juste, les erreurs sont enveloppées et le cas « déjà pris » est traité proprement. En revue, il passe et il est passé 😉
Et en développement, il marche : une seule instance, un pool inactif, la même connexion réutilisée d’un appel à l’autre par pure coïncidence.
💥 Le vrai coupable : le pool de connexions
Le défaut ne tient pas dans le SQL. Il tient dans le mot sql.DB.
En Go, *sql.DB n’est pas une connexion : c’est un pool. Quand on appelle QueryRowContext, le pool prête une connexion, exécute la requête, et la reprend immédiatement dès que le résultat est lu. L’appel suivant obtiendra une connexion arbitraire, souvent une autre.
Or pg_try_advisory_lock pose un verrou de portée session 👉 il n’appartient pas à l’application mais à la connexion qui l’a posé.
Unlock part sur la connexion B, qui n'a jamais rien verrouillé. Le verrou, lui, reste accroché à la connexion A.Cette désynchronisation produit deux défaillances, et la plus grave n’est pas celle qu’on voit.
La défaillance bruyante.
Le pg_advisory_unlock part sur une connexion qui ne détient rien. PostgreSQL le refuse, journalise le warning, et rend false.
Le code ignorait, puisqu’il ne lisait pas la valeur de retour.
C’est le cas heureux : au moins, il laisse une trace.
La défaillance silencieuse.
Le verrou reste posé sur la connexion A, orphelin. Personne ne le relâchera, sauf quand cette connexion sera fermée au bout de ConnMaxLifetime, ou lors d’une purge du pool.
À ce moment-là, PostgreSQL libère automatiquement tous les verrous de session de la connexion, sans un mot.
Le job devient alors réservable par n’importe quel replica, à un instant qui n’a aucun rapport avec son état d’avancement. Deux instances peuvent exécuter le même traitement en parallèle, et rien ne le signale (ni log, ni métrique, ni alerte).
🔍 C’est le pire type de défaut : celui dont le symptôme visible (un warning) et la conséquence réelle (une double exécution) sont si éloignés l’un de l’autre qu’on ne les relie jamais spontanément.
🔧 La première correction : épingler la connexion
La correction évidente, et celle qui a été livrée en premier, consiste à respecter le contrat du verrou : garder la même connexion du Lock au Unlock.
// Reserve a dedicated connection, taken out of the pool for the whole job
conn, err := l.db.Conn(ctx)
if err != nil {
return nil, fmt.Errorf("failed to reserve a connection: %w", err)
}
row := conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", hash)
Le warning disparaît. La sémantique est enfin correcte. Et pourtant, ce n’était pas la bonne réponse.
Parce qu’un job planifié peut tourner longtemps : dix minutes, une heure pour une réconciliation nocturne.
Épingler une connexion pendant tout ce temps, c’est la retirer du pool durant tout ce temps.
Sur un service dont le pool est dimensionné avec très peu de connexions (2 - 5), ce qui est courant quand on multiplie les replicas et qu’on doit tenir sous la limite max_connections du serveur, un job long va monopoliser une connexion entière sans la relâcher.
Bonne intuition, mauvaise forme
Épingler une connexion est la façon correcte d'utiliser un advisory lock de session. Le problème n'est donc pas le correctif, mais le choix initial. Un verrou de session est fait pour protéger une section critique qui dure plus qu'une transaction, pas pour couvrir l'exécution complète d'un job de plusieurs dizaines de minutes.
On corrigeait la mise en œuvre d'un mécanisme qui n'avait pas la bonne forme pour cet usage.
🧭 Changer de mécanisme
Plutôt qu’un verrou tenu par une connexion, on écrit une ligne : un bail (lease), avec un porteur, une date d’expiration et un compteur.
Le renversement est complet :
le verrou ne dépend plus d’un état de connexion vivant côté serveur, mais d’une donnée, avec sa propre durée de vie.
CREATE TABLE IF NOT EXISTS lease
(
key TEXT PRIMARY KEY,
holder TEXT NOT NULL,
fence BIGINT NOT NULL DEFAULT 1,
acquired_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
expires_at TIMESTAMPTZ NOT NULL,
CONSTRAINT lease_validity CHECK (expires_at > acquired_at)
);
Deux colonnes méritent un mot:
acquired_atn’est pas de la décoration : couplée auCHECK, elle interdit d’écrire un bail déjà expiré à sa naissance. Une erreur de signe dans le calcul de la durée devient une violation de contrainte au lieu d’un verrou fantôme.fenceest un compteur monotone, incrémenté chaque fois que le bail change de mains. C’est le jeton de cloisonnement (fencing token), sur lequel on reviendra à la fin, correspondant à la seule chose qui permette à une ressource de reconnaître et rejeter le travail périmé.
Revendiquer le bail en une seule requête
Toute la sûreté du mécanisme tient dans cette requête.
Elle doit répondre à « prends le bail s’il est libre ou expiré, sinon ne fais rien » en le faisant de façon atomique, sans transaction explicite ni verrou de ligne.
INSERT INTO lease (key, holder, expires_at)
VALUES ($1, $2, clock_timestamp() + make_interval(secs => $3))
ON CONFLICT (key) DO UPDATE
SET holder = EXCLUDED.holder,
expires_at = EXCLUDED.expires_at,
acquired_at = CASE WHEN lease.holder = EXCLUDED.holder AND lease.expires_at > clock_timestamp()
THEN lease.acquired_at
ELSE clock_timestamp()
END,
fence = CASE WHEN lease.holder = EXCLUDED.holder AND lease.expires_at > clock_timestamp()
THEN lease.fence
ELSE lease.fence + 1
END
WHERE lease.holder = EXCLUDED.holder OR lease.expires_at <= clock_timestamp()
RETURNING fence, expires_at;
Le WHERE du DO UPDATE fait tout le travail. Il dit deux choses :
lease.expires_at <= clock_timestamp()autorise la reprise d’un bail expiré,lease.holder = EXCLUDED.holderrend l’appel réentrant : le porteur courant peut re-revendiquer son propre bail sans se le voler à lui-même.
Si le bail est encore valide et détenu par quelqu’un d’autre, l’
UPDATEne s’applique pas, leRETURNINGne rend aucune ligne, et l’appelant en déduit qu’un autre replica le détient. Un seul aller-retour réseau, et la connexion repart au pool immédiatement.
Les deux CASE protègent l’invariant du fence tant que c’est le même porteur sur un bail encore valide, car acquired_at et fence ne bougent pas. Dès qu’il y a rupture (bail expiré, changement de porteur) fence est incrémenté.
👉 Un
fencequi avance, c’est l’aveu qu’il a pu y avoir discontinuité, et c’est précisément l’information qu’on veut propager en aval.
Toutes les dates sont calculées avec clock_timestamp(), donc avec l’horloge du serveur PostgreSQL.
Aucune horloge applicative n’entre dans la décision et c’est indispensable dès qu’on parle de plusieurs machines.
clock_timestamp() plutôt que now()
now() renvoie l'heure de début de transaction, figée pour toute sa durée.
Pour une transaction un peu longue, un bail calculé avec now() naîtrait déjà vieux de plusieurs secondes, et un bail expiré depuis peu passerait encore pour valide.
En conclusion, on privilégie ici clock_timestamp() et pas now() car la seconde fabriquerait des vieux baux dès leur naissance en renvoyant l'heure de début de transaction.
Côté Go, la revendication tient en une requête et un Scan.
Le point à retenir est le traitement de sql.ErrNoRows : ce n’est pas une erreur, c’est la réponse que le bail est détenu par quelqu’un d’autre.
var (
fence int64
expiresAt time.Time
)
row := l.db.QueryRowContext(ctx, claimQuery, key, l.holderID, leaseDuration.Seconds())
err := row.Scan(&fence, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAlreadyLocked
}
if err != nil {
return nil, fmt.Errorf("failed to claim lease: %w", err)
}
Ces deux valeurs sont celles qui doivent voyager avec le verrou :
fencepour cloisonner les écritures en aval,expiresAtpour savoir jusqu’à quand on est couvert, car l’échéance vient du serveur, jamais d’untime.Now()local.
Garder le bail vivant
Un bail de soixante secondes ne suffit pas à couvrir un job d’une heure. C’est voulu : on ne veut pas d’un verrou dont la durée dépendrait d’une estimation de la durée du travail. À la place, une goroutine le prolonge tant que le job tourne.
const (
leaseDuration = 60 * time.Second
heartbeatInterval = 20 * time.Second // leaseDuration / 3
)
// The heartbeat must outlive the caller's context: it runs until Unlock,
// not until the end of the request that triggered the job.
heartbeatCtx, stop := context.WithCancel(context.Background())
lease := &leaseLock{db: l.db, key: key, holderID: l.holderID, fence: fence, expiresAt: expiresAt, stop: stop}
go lease.heartbeat(heartbeatCtx, heartbeatInterval)
Le fence rendu par la revendication voyage avec le bail : c’est lui qu’on passera en aval, et c’est lui qui scope le prolongement.
Car prolonger n’est pas revendiquer et on ne veut surtout pas qu’un battement ressuscite un bail qu’on a laissé expirer :
UPDATE lease
SET expires_at = clock_timestamp() + make_interval(secs => $4)
WHERE key = $1
AND holder = $2
AND fence = $3
AND expires_at > clock_timestamp();
Les quatre conditions se lisent comme une seule question : « suis-je toujours le porteur légitime de ce bail ? ».
holder et fence écartent le cas où le bail a changé de mains, expires_at > clock_timestamp() écarte celui où il a simplement expiré sans que personne l’ait repris. C’est une situation dans laquelle on n’a plus le droit de le prolonger en douce, puisqu’une reprise devrait faire avancer le fence.
Si cette requête affecte zéro ligne, c’est donc qu’on n’est plus le détenteur légitime.
Le battement s’arrête et on journalise en ERROR : le job continue de tourner alors qu’il ne tient plus rien, et il faut le savoir.
La distinction est importante dans la gestion d’erreur : une erreur réseau transitoire se réessaie au battement suivant, alors que zéro ligne affectée est un état définitif.
func (l *leaseLock) renew(ctx context.Context) bool {
result, err := l.db.ExecContext(ctx, renewQuery, l.key, l.holderID, l.fence, leaseDuration.Seconds())
if err != nil {
l.logger.WarnContext(ctx, "lease heartbeat failed, will retry",
slog.String("job", l.key), slog.String("error", err.Error()))
return true // transient: we will retry on the next heartbeat
}
rows, err := result.RowsAffected()
if err != nil || rows > 0 {
return true
}
l.logger.ErrorContext(ctx, "lease lost to another replica",
slog.String("job", l.key), slog.String("holder", l.holderID), slog.Int64("fence", l.fence))
return false // definitive: the lease was stolen from us
}
Aller plus loin — pourquoi un battement à un tiers de la durée du bail
Avec un bail de 60 secondes et un battement toutes les 20 secondes, il faut trois battements manqués consécutifs pour perdre le bail. Deux ratés (pic de latence réseau, pause GC, basculement de connexion) sont absorbés sans conséquence.
Un battement à la moitié de la durée ne tolère qu'un seul raté, ce qui est trop juste sous charge. Un battement au dixième multiplie les requêtes inutiles pour un gain marginal. Le tiers est le compromis habituel, et c'est celui qu'on retrouve dans la plupart des implémentations de baux distribués.
Le vrai paramètre à régler n'est pas l'intervalle, mais la durée du bail, car c'est elle qui fixe le temps pendant lequel un job restera bloqué après le crash brutal d'un replica.
Relâcher, sans écraser le voisin
La libération est un DELETE, scopé lui aussi au porteur et au fence :
DELETE FROM lease WHERE key = $1 AND holder = $2 AND fence = $3;
Sans la condition sur holder, un replica qui a perdu son bail et termine son travail en retard supprimerait la ligne d’un autre replica en train de travailler légitimement. Le bail serait alors libre alors qu’un job tourne encore. Cette clause n’est pas une précaution, c’est une condition de correction.
fence ferme le dernier interstice : celui du porteur qui perd son bail, le reprend plus tard sous la même identité, et voit arriver le Unlock tardif de sa première exécution. Même key, même holder, mais un fence différent 👉 le DELETE ne touche rien, et le bail en cours survit.
L’identité du porteur, pouvant être calculé à partir du nom d’hôte + suffixe aléatoire, est fixée au démarrage.
Le nom d’hôte sert au diagnostic, le suffixe garantit qu’un redémarrage produit bien une nouvelle identité.
⚠️ Ce que le lease change, et ce qu’il coûte
Le lease n’est pas « mieux » que l’advisory lock. Il déplace le compromis, et il faut savoir ce qu’on achète.
On n’a pas abandonné les advisory locks. C’est le point qui surprend le plus. Il fallait bien créer la table lease, et cette création se fait au démarrage, simultanément sur tous les replicas, exactement le cas d’usage du premier article.
👉 Un pg_advisory_xact_lock dans une transaction, et le problème disparaît : opération courte, libération automatique au COMMIT, aucune connexion épinglée.
// Creating the lease table is itself serialized by a transaction-scoped
// advisory lock: the right tool, at the right scope.
if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock($1)", migrationKey); err != nil {
return fmt.Errorf("failed to acquire migration lock: %w", err)
}
Les jobs doivent être idempotents. Un bail expire. Un job qui dépasse la durée du bail sans réussir à le prolonger se le fait légitimement voler, et un autre replica démarre le même travail. C’est le prix à payer pour qu’un replica mort ne bloque pas un job pour toujours, mais ça se paie en conception, pas en configuration.
🪪 Le fencing token
Il faut être clair là-dessus : un bail avec expiration ne garantit pas l’exclusion mutuelle.
Un porteur peut continuer à croire qu’il détient le bail après son expiration, pendant qu’un autre l’a repris (pause GC, etc)
💥👉les deux travaillent en même temps.
C’est précisément la critique que Martin Kleppmann adresse à Redlock : sans jeton de cloisonnement (fencing token) vérifié par la ressource écrite en aval, aucun verrou distribué à durée de vie ne peut promettre l’exclusion mutuelle.
Il en réduit la fenêtre de violation, il ne la supprime pas.
C’est exactement pour ça que la table porte une colonne fence. Le lease décrit ici expose ce jeton : il est incrémenté à chaque changement de main, et rendu au porteur au moment de la revendication. Mais exposer n’est que la moitié du chemin. La garantie ne devient réelle que si la ressource écrite en aval, elle, mémorise le dernier jeton vu et rejette tout ce qui arrive avec un jeton inférieur. Un UPDATE ... WHERE last_fence < $1 sur la table de destination, et la double exécution devient inoffensive : le porteur périmé écrit dans le vide.
Tant que cette vérification n’existe pas en aval, on n’a qu’une fenêtre de violation bornée, pas une exclusion mutuelle.
C’était déjà le cas de l’advisory lock qu’on remplace avec, en prime, une libération silencieuse et imprévisible, et rien qui ressemble de près ou de loin à un jeton. Passer au lease rend le compromis explicite, paramétrable et cloisonnable mais ça ne le fait pas disparaître tout seul.
📝 Pour résumer
📷 La solution du bail
Trois clauses portent toute la correction :
- la revendication via
WHERE ... OR expires_at <= clock_timestamp(), - le prolongement et la libération via la clause
AND holder = $2 AND fence = $3. - Le lease ne garantit pas l’exclusion mutuelle à lui seul, pas plus que l’advisory lock. Il borne la fenêtre de violation et expose un
fence; la garantie n’existe que si la ressource en aval rejette les jetons périmés. D’où l’exigence d’idempotence tant que ce n’est pas le cas.
📚 Comparatif entre advisory lock et lease
| Advisory lock (session) | Lease en table | |
|---|---|---|
| Tenu par | Une connexion PostgreSQL | Une ligne, avec une expiration |
| Coût pendant le job | Une connexion épinglée | Une requête toutes les 20 s |
| Si le porteur meurt | Libéré à la fermeture de la connexion, à un instant imprévisible | Libéré après expiration du bail, borné et connu |
| Observable | pg_locks, clés numériques |
SELECT * FROM lease |
| Survit au redémarrage de la base | Non | Oui |
Compatible PgBouncer transaction |
Non | Oui |
| Jeton de cloisonnement | Aucun | fence, rendu à la revendication |
| Exclusion mutuelle garantie | Non | Non — sauf si l’aval vérifie le fence |
📊 Advisory lock ou lease : que choisir
La question ne se tranche pas sur la sûreté, car aucun des deux ne garantit à lui seul l’exclusion mutuelle.
Elle se tranche sur la durée et sur ce qui se passe quand le porteur disparaît.
| Votre besoin | Le bon outil |
|---|---|
| Sérialiser un DDL, une initialisation, une migration | pg_advisory_xact_lock |
| Protéger une section critique dans une transaction | pg_advisory_xact_lock |
| Empêcher une insertion concurrente le temps d’une vérification | pg_advisory_xact_lock |
| Réserver un job de plusieurs minutes sur N replicas | Lease |
| Élire un porteur qui doit survivre à un redémarrage de la base | Lease |
Coordonner à travers PgBouncer en mode transaction |
pg_advisory_xact_lock ou lease |
En conclusion
J’ai pu tirer plusieurs leçons de ce problème de prod :
- Un advisory lock de session appartient à la connexion, pas à l’application 👉 le warning est le symptôme visible, la double exécution est la vraie conséquence.
- Épingler une connexion corrige la sémantique, mais confisque une ressource rare pendant toute la durée du job 👉 c’est le bon correctif pour un mauvais choix de mécanisme.
- Un lease remplace l’état de connexion par une donnée : une ligne, un porteur, une expiration, un jeton.
La connexion repart au pool aussitôt.
✅ La vraie leçon de ce retour d’expérience n’est pas « les advisory locks sont mauvais ».
Ils sont excellents, et on les a gardés là où ils sont bons : les opérations courtes, en portée transaction.
La leçon, c’est qu’un verrou dont on ne sait pas dire qui le détient et quand il tombe n’est pas un verrou sur lequel on peut raisonner.
Crédits & Ressources
- Les advisory locks PostgreSQL : verrouiller autre chose qu’une ligne — le premier article de cette série, à lire avant celui-ci
- How to do distributed locking — Martin Kleppmann sur les fencing tokens et les limites des verrous à durée de vie
- Go —
database/sqlet la gestion du pool — pourquoi*sql.DBn’est pas une connexion, et à quoi sertDB.Conn - PostgreSQL — Advisory Lock Functions — la référence des fonctions utilisées ici
- Gérer ses transactions en base de données — ce qu’une transaction longue coûte vraiment

