ZERONE
Zurück zu Insights
Data Engineering2026-04-18 · 4 Min Lesezeit

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