---
title: "Ein Lock, der eine Connection hielt: Advisory Locks, Pool von vier"
summary: "Ein Advisory Lock belegt eine Connection, solange er gehalten wird. Vier Locks und ein Pool von vier legten eine Instanz lahm. Leases in einer Tabelle halfen."
author: "Samuel Krauss"
author_title: "Founder"
publisher: "Elchi Studios"
published: 2026-10-03
updated: 2026-10-09
url: https://elchi.dev/de/journal/ein-lock-der-eine-connection-hielt
language: de
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: 1132
---

# Ein Lock, der eine Connection hielt: Advisory Locks, Pool von vier

*Von Samuel Krauss, Founder. Veröffentlicht am 3. Oktober 2026.*

> Ein Advisory Lock belegt eine Connection, solange er gehalten wird. Vier Locks und ein Pool von vier legten eine Instanz lahm. Leases in einer Tabelle halfen.

Kurz nachdem wir das Backend auf unseren eigenen Cluster umgezogen hatten, fiel einer der zwei Anwendungsknoten aus der Rotation, und der andere bediente alles. Die Instanz auf diesem Knoten lebte, antwortete auf `/up` und fiel durch ihre Readiness-Probe, weil sie keine Datenbank-Connection bekam. Sie hatte vier, und alle vier hielten Locks.

Hier steht, wie das passiert und was die Locks ersetzt hat: Leases in einer Tabelle, die überhaupt keine Connection halten.

## Die Hintergrundarbeit

Vier Arten von Arbeit im Backend dürfen nicht auf allen Instanzen gleichzeitig laufen:

- der stündliche Sweep, der unbeantwortete Passkey-Challenges, abgelaufene Single-Sign-on-Anfragen und alles entfernt, was unsere Aufbewahrungsregeln nur befristet behalten;
- die Alarmauswertung für Uptime-Monitore, alle 30 Sekunden;
- die Mail-Sicherheitsprüfungen, die alle 15 Minuten nach Domains suchen, die in den letzten 23 Stunden nicht geprüft wurden;
- die Uptime-Checks selbst, alle 10 Sekunden, einmal pro Knoten, damit zwei Instanzen auf einem Knoten einen Check nie doppelt zählen.

Jede Runde begann gleich. Das alte `TryExclusive` nahm eine Connection aus dem Pool, rief darauf `pg_try_advisory_lock` auf und behielt diese Connection, bis die Arbeit erledigt war:

```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()
```

Die Instanz, die den Lock bekam, machte die Runde, die anderen liessen sie aus. Das ist das Muster aus dem Lehrbuch.

## Warum ein Lock eine Connection ist

Die Dokumentation von PostgreSQL sagt es in einem Satz: Ein Advisory Lock, der auf Session-Ebene geholt wurde, wird gehalten, bis er ausdrücklich freigegeben wird oder die Session endet. Eine Session ist eine Connection. Wer den Lock behält, behält die Connection, und eine Connection, die jemand behält, kann der Pool niemand anderem geben.

Auf dem Cluster bekommt jede Instanz vier Connections, aus einem Budget, das sich alle Anwendungen auf dem Cluster teilen: drei Rollen auf zwei Knoten, doppelt gezählt für die Überlappung während eines Deploys, belegen 48 von 120.

Nun zu den Locks. Drei gelten clusterweit: Sweep, Alarme, Mail-Prüfungen. Der vierte ist der Uptime-Lock des eigenen Knotens. Eine Instanz, die alle drei clusterweiten Locks gewonnen hatte und dazu den Lock ihres Knotens, hatte vier Connections aus dem Pool genommen. Dann:

1. Die Arbeit innerhalb jedes Locks verlangte vom Pool eine Connection für ihre Queries und wartete.
2. Requests verlangten vom Pool eine Connection und warteten.
3. Die Readiness-Probe führte `SELECT 1` mit einer Grenze von einer Sekunde aus, bekam keine Connection und meldete die Datenbank als fehlerhaft. Die Instanz antwortete auf `/health/ready` mit 503, und die Edges nahmen sie aus der Rotation.

Weil die Arbeit auf eine Connection wartete, die ihr eigener Lock hielt, wurden die Locks nie freigegeben. Nicht langsam: festgefahren.

`pg_locks` macht das sichtbar, wenn man weiss, wo man schauen muss. Advisory Locks erscheinen mit `locktype = 'advisory'`, und ein Join mit `pg_stat_activity` zeigt, von wo aus jeder Halter verbunden ist:

```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';
```

Alle vier Locks gehörten Backends eines einzigen Knotens. Diese Backends erscheinen als idle, weil der Lock-Aufruf längst zurückgekehrt ist, und genau deshalb verdächtigt sie niemand.

## Die erste Lösung, und warum sie keine war

Die schnelle Lösung war ein Pool von acht. Das funktionierte, belegte 96 der 120 Connections und machte die Poolgrösse davon abhängig, wie viele Arten von Hintergrundarbeit es gibt: Kommt ein fünfter Job dazu, kommt der Fehler zurück. Ein Lock sollte überhaupt keine Connection kosten, also habe ich den Lock aus der Connection herausgenommen.

## Leases in einer Tabelle

Der Ersatz ist eine Tabelle mit einer Zeile pro Art von Arbeit:

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

Eine Lease zu nehmen, ist ein einziges Statement:

```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
```

Hält niemand den Namen, wird die Zeile eingefügt. Hält ihn jemand und ist die Lease abgelaufen, wird sie übernommen. Hält ihn jemand und ist sie nicht abgelaufen, ist das `WHERE` falsch, PostgreSQL liefert keine Zeile, und der Aufrufer lässt die Runde aus. `ON CONFLICT DO UPDATE` garantiert eines der beiden Ergebnisse atomar, auch bei gleichzeitigen Zugriffen, also können zwei Instanzen, die im selben Moment fragen, nicht beide gewinnen.

Eine Lease dauert zwei Minuten. Während die Arbeit läuft, erneuert eine Goroutine sie alle 30 Sekunden, also nach einem Viertel davon, mit einem `UPDATE ... WHERE name = $1 AND holder = $2`. Endet die Arbeit, gibt ein `DELETE` mit derselben Bedingung sie zurück. Jedes Statement leiht sich eine Connection für einen Moment.

Drei Details sind wichtig:

- **Jeder Aufruf ist sein eigener Halter.** Der Halter besteht aus Hostname, Prozess-ID und 64 zufälligen Bits, neu bei jedem Aufruf. Auch zwei Goroutines im selben Prozess schliessen sich gegenseitig aus, und eine Freigabe, die zu spät kommt, kann nie eine Lease löschen, die inzwischen jemand anderes genommen hat.
- **Nur die Uhr der Datenbank zählt.** Jede Zeit kommt von `now()` auf dem Server, die Uhren der Knoten dürfen also folgenlos voneinander abweichen.
- **Ein Lock auf Transaktionsebene hätte nicht geholfen.** `pg_try_advisory_xact_lock` braucht eine Transaktion, die über die ganze Runde offen ist: wieder eine gehaltene Connection, die unser `idle_in_transaction_session_timeout` von 30 Sekunden mitten in der Runde beenden würde.

Der Pool ging zurück auf vier.

## Was es kostet

**Ein abgestürzter Halter verzögert die nächste Runde um bis zu zwei Minuten.** Niemand gibt seine Lease frei, also muss sie ablaufen. Beim stündlichen Sweep ändert das nichts. Bei der Alarmauswertung, die alle 30 Sekunden läuft, kann es bis zu vier Runden ohne Auswertung bedeuten.

**Arbeit kann doppelt laufen.** Verliert der Halter die Datenbank für länger als zwei Minuten, während eine andere Instanz sie noch erreicht, läuft die Lease ab, und die andere Instanz übernimmt sie. Die erste merkt es, wenn ihre nächste Erneuerung keine Zeile trifft; sie loggt eine Warnung, und die laufende Runde geht weiter. Einen Fencing Token, den Mechanismus, den Martin Kleppmann für genau diesen Fall beschreibt, gibt es hier nicht.

Ich nehme das in Kauf, weil keiner der vier Jobs Schaden nimmt, wenn er doppelt läuft. Der Sweep löscht, was abgelaufen ist, und ein zweites Löschen löscht nichts. Ein zusätzlicher Uptime-Check ist ein Ergebnis mehr. Die Alarmauswertung vergleicht, was sie sieht, mit dem Zustand, den sie gespeichert hat, und die Mail-Prüfungen nehmen nur Domains, die seit 23 Stunden nicht geprüft wurden. Ein zweiter Lauf findet die Arbeit also erledigt vor und tut nichts; nur zwei Läufe im exakt selben Moment könnten eine Meldung doppelt verschicken. Ein Job, bei dem ein zweiter Lauf Schaden anrichtet, bräuchte einen Fencing Token oder einen Unique Constraint auf seinem Ergebnis. Keiner dieser Jobs braucht das.

## Der Test

`TestLeasesHoldNoConnection` bildet den Fehler in seiner kleinsten Form nach: ein Pool mit einer Connection, vier genommene Leases (Sweep, Alarme, Mail-Prüfungen, Uptime-Runner) und danach der Schreibzugriff der Readiness-Probe, der innerhalb von drei Sekunden committen muss. Mit dem alten `TryExclusive` kommt der Test nie so weit: Der zweite Lock wartet auf die Connection, die der erste hält. Zwei weitere Tests prüfen, dass eine Lease genau einen Halter hat, dass eine Lease, die niemand erneuert, übernommen wird, und dass eine, die erneuert wird, nicht übernommen wird.

## Wo es noch Advisory Locks gibt

Migrationen nehmen beim Start weiterhin einen, bevor die Instanz sich bereit meldet, und kurze Transaktionen nehmen welche auf Transaktionsebene. Die Regel ist enger gefasst: Kein Lock wird über lange Arbeit hinweg auf einer Connection aus dem Pool gehalten. Mit dem Transaction Pooling von PgBouncer stellt sich die Frage gar nicht, denn Advisory Locks auf Session-Ebene funktionieren dort überhaupt nicht.

## Quellen

1. [PostgreSQL documentation: Explicit Locking, Advisory Locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS), PostgreSQL Global Development Group, gelesen am 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, gelesen am 2026-10-03
3. [PostgreSQL documentation: pg_locks](https://www.postgresql.org/docs/current/view-pg-locks.html), PostgreSQL Global Development Group, gelesen am 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, gelesen am 2026-10-03
5. [PostgreSQL documentation: INSERT, ON CONFLICT clause](https://www.postgresql.org/docs/current/sql-insert.html), PostgreSQL Global Development Group, gelesen am 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, gelesen am 2026-10-03
7. [pgxpool package documentation](https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool), pkg.go.dev, gelesen am 2026-10-03
8. [PgBouncer features: pooling modes and what they support](https://www.pgbouncer.org/features.html), PgBouncer, gelesen am 2026-10-03
9. [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html), Martin Kleppmann, gelesen am 2026-10-03
