---
title: "Un verrou qui retenait une connexion : advisory locks et pool de 4"
summary: "Un advisory lock occupe une connexion du pool tant qu'il est détenu. Quatre verrous, un pool de quatre : une instance bloquée. Des baux en table ont réglé cela."
author: "Samuel Krauss"
author_title: "Founder"
publisher: "Elchi Studios"
published: 2026-10-03
updated: 2026-10-09
url: https://elchi.dev/fr/journal/un-verrou-qui-retenait-une-connexion
language: fr
translation_of: https://elchi.dev/en/journal/a-lock-that-held-a-connection
tags: ["postgresql","go","pgx","advisory-locks","connection-pool","background-jobs","leases"]
words: 1288
---

# Un verrou qui retenait une connexion : advisory locks et pool de 4

*Par Samuel Krauss, Founder. Publié le 3 octobre 2026.*

> Un advisory lock occupe une connexion du pool tant qu'il est détenu. Quatre verrous, un pool de quatre : une instance bloquée. Des baux en table ont réglé cela.

Juste après le passage du backend sur notre propre cluster, l'un de ses deux nœuds applicatifs est sorti de la rotation et l'autre a tout servi. L'instance de ce nœud était vivante, répondait sur `/up`, et échouait à sa sonde de disponibilité parce qu'elle n'obtenait pas de connexion à la base de données. Elle en avait quatre, et toutes les quatre détenaient des verrous.

Voici comment on en arrive là, et ce qui a remplacé les verrous : des baux dans une table, qui ne retiennent aucune connexion.

## Le travail de fond

Quatre types de travail du backend ne doivent pas tourner sur toutes les instances à la fois :

- le nettoyage horaire, qui supprime les challenges de passkey restés sans réponse, les requêtes de single sign-on expirées et tout ce que nos règles de conservation ne gardent que pour un temps ;
- l'évaluation des alertes des moniteurs de disponibilité, toutes les 30 secondes ;
- les contrôles de sécurité des mails, qui cherchent toutes les 15 minutes les domaines non vérifiés au cours des 23 dernières heures ;
- les contrôles de disponibilité eux-mêmes, toutes les 10 secondes, une fois par nœud, pour que deux instances sur un même nœud ne comptent jamais un contrôle deux fois.

Chaque tour commençait de la même manière. L'ancien `TryExclusive` prenait une connexion dans le pool, y appelait `pg_try_advisory_lock`, et gardait cette connexion jusqu'à la fin du travail :

```go
conn, err := p.Acquire(ctx)
// ...
err = conn.QueryRow(ctx, `SELECT pg_try_advisory_lock(hashtext($1))`, name).Scan(&ok)
// release: pg_advisory_unlock, then conn.Release()
```

L'instance qui obtenait le verrou faisait le tour, les autres le sautaient. C'est le schéma des manuels.

## Pourquoi un verrou est une connexion

La documentation de PostgreSQL le dit en une phrase : une fois acquis au niveau de la session, un advisory lock est détenu jusqu'à ce qu'il soit explicitement libéré ou que la session se termine. Une session, c'est une connexion. Pour garder le verrou, vous gardez la connexion, et une connexion que vous gardez, le pool ne peut la donner à personne d'autre.

Sur le cluster, chaque instance reçoit quatre connexions, sur un budget que partagent toutes les applications du cluster : trois rôles sur deux nœuds, comptés deux fois pour le chevauchement pendant un déploiement, en prennent 48 sur 120.

Comptez maintenant les verrous. Trois valent pour tout le cluster : nettoyage, alertes, contrôles des mails. Le quatrième est le verrou de disponibilité propre au nœud. Une instance qui avait remporté les trois verrous du cluster, plus celui de son propre nœud, avait sorti quatre connexions du pool. Ensuite :

1. Le travail sous chaque verrou demandait au pool une connexion pour exécuter ses requêtes, et attendait.
2. Les requêtes HTTP demandaient au pool une connexion, et attendaient.
3. La sonde de disponibilité exécutait `SELECT 1` avec une limite d'une seconde, n'obtenait pas de connexion et déclarait la base de données en échec. L'instance répondait 503 sur `/health/ready`, et les points d'entrée la sortaient de la rotation.

Comme le travail attendait une connexion que son propre verrou retenait, les verrous n'étaient jamais libérés. Pas lent : bloqué.

`pg_locks` rend cela visible, pour peu qu'on sache où regarder. Les advisory locks y apparaissent avec `locktype = 'advisory'`, et une jointure avec `pg_stat_activity` montre d'où se connecte chaque détenteur :

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

Les quatre verrous appartenaient à des backends d'un seul nœud. Ces backends apparaissent comme inactifs, parce que l'appel de verrouillage a rendu la main depuis longtemps, et c'est exactement pour cela que personne ne les soupçonne.

## La première correction, et pourquoi ce n'en était pas une

La correction rapide a été un pool de huit. Elle a fonctionné, a pris 96 des 120 connexions, et a fait de la taille du pool une fonction du nombre de types de travail de fond : ajoutez une cinquième tâche, et la panne revient. Un verrou ne devrait coûter aucune connexion, alors j'ai sorti le verrou de la connexion.

## Des baux dans une table

Le remplaçant est une table avec une ligne par type de travail :

```sql
CREATE TABLE leases (
    name       text        PRIMARY KEY,
    holder     text        NOT NULL,
    taken_at   timestamptz NOT NULL DEFAULT now(),
    expires_at timestamptz NOT NULL
);
```

Prendre un bail tient en une instruction :

```sql
INSERT INTO leases (name, holder, taken_at, expires_at)
VALUES ($1, $2, now(), now() + make_interval(secs => $3))
ON CONFLICT (name) DO UPDATE
   SET holder = excluded.holder, taken_at = excluded.taken_at, expires_at = excluded.expires_at
 WHERE leases.expires_at < now()
RETURNING true
```

Si personne ne détient le nom, la ligne est insérée. Si quelqu'un le détient et que le bail a expiré, il est repris. Si quelqu'un le détient et qu'il n'a pas expiré, le `WHERE` est faux, PostgreSQL ne renvoie aucune ligne, et l'appelant saute le tour. `ON CONFLICT DO UPDATE` garantit l'une des deux issues de façon atomique, même en concurrence : deux instances qui demandent au même instant ne peuvent pas gagner toutes les deux.

Un bail dure deux minutes. Pendant que le travail tourne, une goroutine le renouvelle toutes les 30 secondes, soit un quart de cette durée, avec un `UPDATE ... WHERE name = $1 AND holder = $2`. Quand le travail se termine, un `DELETE` avec la même condition le rend. Chaque instruction n'emprunte une connexion qu'un instant.

Trois détails comptent :

- **Chaque appel est son propre détenteur.** Le détenteur est formé du nom d'hôte, de l'id du processus et de 64 bits aléatoires, nouveaux à chaque appel. Deux goroutines d'un même processus s'excluent donc aussi, et une libération qui arrive en retard ne peut jamais supprimer un bail que quelqu'un d'autre a pris entre-temps.
- **Seule l'horloge de la base de données compte.** Chaque heure vient de `now()` sur le serveur : les horloges des nœuds peuvent diverger sans conséquence.
- **Un verrou au niveau de la transaction n'aurait pas aidé.** `pg_try_advisory_xact_lock` exige une transaction ouverte pendant tout le tour : de nouveau une connexion retenue, que notre `idle_in_transaction_session_timeout` de 30 secondes couperait en plein tour.

Le pool est revenu à quatre.

## Ce que cela coûte

**Un détenteur qui plante retarde le tour suivant de deux minutes au plus.** Personne ne libère son bail, il doit donc expirer. Pour le nettoyage horaire, cela ne change rien. Pour l'évaluation des alertes, qui tourne toutes les 30 secondes, cela peut signifier jusqu'à quatre tours sautés.

**Un travail peut tourner deux fois.** Si le détenteur perd la base de données pendant plus de deux minutes alors qu'une autre instance l'atteint encore, le bail expire et l'autre instance le prend. La première s'en aperçoit quand son renouvellement suivant ne touche aucune ligne ; elle journalise un avertissement, et le tour en cours continue. Il n'y a pas ici de fencing token, le mécanisme que Martin Kleppmann décrit précisément pour ce cas.

Je l'accepte, parce qu'aucune des quatre tâches ne souffre de tourner deux fois. Le nettoyage supprime ce qui a expiré, et le supprimer encore ne supprime rien. Un contrôle de disponibilité de plus, c'est un résultat de plus. L'évaluation des alertes compare ce qu'elle voit à l'état qu'elle a enregistré, et les contrôles des mails ne prennent que les domaines non vérifiés depuis 23 heures : une exécution qui arrive en second trouve le travail enregistré et ne fait rien ; seules deux exécutions exactement simultanées pourraient envoyer un avis deux fois. Une tâche qu'une seconde exécution abîmerait aurait besoin d'un fencing token ou d'une contrainte d'unicité sur son résultat. Aucune de celles-ci n'en a besoin.

## Le test

`TestLeasesHoldNoConnection` reproduit la panne sous sa forme minimale : un pool d'une connexion, quatre baux pris (nettoyage, alertes, contrôles des mails, exécuteur de disponibilité), puis l'écriture que fait la sonde de disponibilité, qui doit être validée en moins de trois secondes. Avec l'ancien `TryExclusive`, le test n'arrive jamais jusque-là : le deuxième verrou attend la connexion que retient le premier. Deux autres tests vérifient qu'un bail n'a qu'un détenteur, qu'un bail que personne ne renouvelle est repris, et qu'un bail renouvelé ne l'est pas.

## Où il reste des advisory locks

Les migrations en prennent toujours un au démarrage, avant que l'instance ne se déclare prête, et les transactions courtes prennent des verrous au niveau de la transaction. La règle est plus étroite : aucun verrou n'est détenu pendant un long travail sur une connexion du pool. En transaction pooling avec PgBouncer, la question ne se pose pas, puisque les advisory locks au niveau de la session n'y fonctionnent pas du tout.

## Sources

1. [PostgreSQL documentation: Explicit Locking, Advisory Locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS), PostgreSQL Global Development Group, consulté le 2026-10-03
2. [PostgreSQL documentation: Advisory Lock Functions](https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS), PostgreSQL Global Development Group, consulté le 2026-10-03
3. [PostgreSQL documentation: pg_locks](https://www.postgresql.org/docs/current/view-pg-locks.html), PostgreSQL Global Development Group, consulté le 2026-10-03
4. [PostgreSQL documentation: pg_stat_activity](https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ACTIVITY-VIEW), PostgreSQL Global Development Group, consulté le 2026-10-03
5. [PostgreSQL documentation: INSERT, ON CONFLICT clause](https://www.postgresql.org/docs/current/sql-insert.html), PostgreSQL Global Development Group, consulté le 2026-10-03
6. [PostgreSQL documentation: Client Connection Defaults (idle_in_transaction_session_timeout)](https://www.postgresql.org/docs/current/runtime-config-client.html), PostgreSQL Global Development Group, consulté le 2026-10-03
7. [pgxpool package documentation](https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool), pkg.go.dev, consulté le 2026-10-03
8. [PgBouncer features: pooling modes and what they support](https://www.pgbouncer.org/features.html), PgBouncer, consulté le 2026-10-03
9. [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html), Martin Kleppmann, consulté le 2026-10-03
