Ein Lock, der eine Connection hielt: Advisory Locks, Pool von vier
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:
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:
- Die Arbeit innerhalb jedes Locks verlangte vom Pool eine Connection für ihre Queries und wartete.
- Requests verlangten vom Pool eine Connection und warteten.
- Die Readiness-Probe führte
SELECT 1mit einer Grenze von einer Sekunde aus, bekam keine Connection und meldete die Datenbank als fehlerhaft. Die Instanz antwortete auf/health/readymit 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:
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:
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:
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_lockbraucht eine Transaktion, die über die ganze Runde offen ist: wieder eine gehaltene Connection, die unseridle_in_transaction_session_timeoutvon 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
- PostgreSQL documentation: Explicit Locking, Advisory Locks, PostgreSQL Global Development Group, gelesen am
- PostgreSQL documentation: Advisory Lock Functions, PostgreSQL Global Development Group, gelesen am
- PostgreSQL documentation: pg_locks, PostgreSQL Global Development Group, gelesen am
- PostgreSQL documentation: pg_stat_activity, PostgreSQL Global Development Group, gelesen am
- PostgreSQL documentation: INSERT, ON CONFLICT clause, PostgreSQL Global Development Group, gelesen am
- PostgreSQL documentation: Client Connection Defaults (idle_in_transaction_session_timeout), PostgreSQL Global Development Group, gelesen am
- pgxpool package documentation, pkg.go.dev, gelesen am
- PgBouncer features: pooling modes and what they support, PgBouncer, gelesen am
- How to do distributed locking, Martin Kleppmann, gelesen am
Geschrieben von Samuel Krauss, Founder. Abgelegt unter postgresql, go, pgx, advisory-locks, connection-pool, background-jobs, leases.
Übersetzt aus dem Englischen. Zum englischen Original