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é.

Le verrou est posé sur une connexion du pool et libéré depuis une autre, ce qui déclenche un avertissement PostgreSQLApplicationreplica 1pool de connexionsconn A🔒 détient le verrouconn Bne détient rienPostgreSQL1. pg_try_advisory_lock3. pg_advisory_unlock2. entre les deux : le job tourne, et la connexion A est rendue au pool⚠️ WARNING: you don't own a lock of type ExclusiveLock
Le 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_at n’est pas de la décoration : couplée au CHECK, 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.
  • fence est 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.holder rend 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’UPDATE ne s’applique pas, le RETURNING ne 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 fence qui 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 :

  • fence pour cloisonner les écritures en aval,
  • expiresAt pour savoir jusqu’à quand on est couvert, car l’échéance vient du serveur, jamais d’un time.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