Commit vor async I/O: Wie ein Enricher den ganzen PgBouncer-Pool idle hielt
Eine Transaktion, die auf eine HTTP-Antwort wartet, ist im Connection-Pool unsichtbar — aber sie hält den Slot. Mit zwölf parallelen Daemons reicht das, um ein komplettes Backend auf 502 zu schicken.
Das Bild sah unschuldig aus: Ein Email-Enricher-Daemon liest einen Batch Kandidaten aus der Datenbank, prüft die E-Mail-Domains gegen eine externe Validation-API, schreibt das Ergebnis zurück. Auf Dev: läuft. Einzelne Testläufe: unauffällig. Im Produktions-Cluster mit zwölf parallel laufenden Partitionen: Backend-502 im Minuten-Takt.
Der Stack-Trace zeigte nicht den Enricher — er zeigte fremde Endpoints, die auf eine Connection warteten, die nie kam. PgBouncer-Pool: erschöpft. Aber: der Enricher hatte per Monitor nur ~4 aktive Queries. Wie?
Der stille Killer
Der Code sah so aus:
async def enrich_batch():
async with db.begin() as conn:
rows = await conn.execute(
"SELECT id, email FROM job_advertisement "
"WHERE email_verified IS NULL LIMIT 200"
)
for row in rows:
# Async HTTP — dauert 200-1200 ms pro Call
result = await validate_email(row.email)
await conn.execute(
"UPDATE job_advertisement SET email_verified=:v WHERE id=:id",
{"v": result, "id": row.id},
)
Problem: Der async with db.begin() öffnet eine Transaktion und hält die Connection, bis der Block verlassen wird. Innerhalb des Blocks macht der Loop zweihundertmal einen HTTP-Call zu einer externen API. 200 × ~500 ms = 100 Sekunden pro Batch. Die ganze Zeit: Connection idle in transaction.
PgBouncer wartet in der Regel 30–60 Sekunden, dann kickt er Connections im idle-in-transaction-State. Ergebnis: Backend-Queries bekommen keine Connection, timeout, 502. Der Monitor zeigt nur die drei aktiven SELECTs — die zwölf idle-in-transaction-Enricher-Connections werden von vielen Monitoring-Setups nicht als „aktiv" gezählt.
Der Fix
Die Regel: Commit sofort nach SELECT, bevor irgendein async I/O folgt.
async def enrich_batch():
# Phase 1: SELECT, dann SOFORT commit — Connection wieder frei
async with db.begin() as conn:
rows = await conn.execute(
"SELECT id, email FROM job_advertisement "
"WHERE email_verified IS NULL LIMIT 200"
)
rows = rows.fetchall()
# Transaktion ist zu, Connection zurück in den Pool
# Phase 2: HTTP-Calls ohne offene Connection
results = []
for row in rows:
r = await validate_email(row.email)
results.append((row.id, r))
# Phase 3: UPDATE-Batch in neuer, kurzer Transaktion
async with db.begin() as conn:
for rid, r in results:
await conn.execute(
"UPDATE job_advertisement SET email_verified=:v WHERE id=:id",
{"v": r, "id": rid},
)
Die Connection ist jetzt nur für die reinen DB-Operationen offen — Millisekunden, keine Minuten.
Operative Konsequenz
Wir haben die Regel als Review-Pattern etabliert:
„Jede async-Funktion, die DB + externes I/O macht, hat mindestens zwei Transaktionen."
Zusätzlich: Wir setzen in PgBouncer idle_in_transaction_session_timeout=5s — damit eine vergessene Transaktion maximal fünf Sekunden den Pool blockiert, danach reißt PostgreSQL sie selbst ab. Hart, aber kalkuliert: Besser ein einzelner Daemon-Fehler als ein Ausfall des gesamten Backends.
Warum Tests das nicht fangen
Lokal mit einem einzelnen Worker + lokaler DB: keine Pool-Erschöpfung. Lokal mit Mock-API: kein I/O-Delay. Das Anti-Pattern ist im Code nicht unterscheidbar von korrektem Code — nur im Produktions-Verhalten unter Last. Deshalb gehört das Review-Gate in die Pipeline: PRs, die await db.begin() enthalten UND HTTP-Clients importieren, bekommen automatisch einen Label-Flag und brauchen expliziten Human-Review.
Ähnlicher Brand bei dir?
Wir haben vermutlich schon etwas ähnliches gesehen. Sprich mit uns.
Gespräch starten→