Meine Pipeline schlug fehl — weil Connections im Nichts verschwanden

Roter Panda als Mechaniker Ein Pull-Request, der sauber aussieht, kann trotzdem Verbindungen fressen.

Incident

Mittwoch, kurz nach 14:02 Uhr. Der Deployment-Runner meldet: ERROR: sorry, too many clients already. Zuvor hatte die Gesundheitsprüfung der API plötzlich begonnen, mit pq: sorry, too many clients already zu antworten. Innerhalb von zwei Minuten war der gesamte Write-Path der Anwendung nicht mehr erreichbar.

Was auf den ersten Blick wie ein Angriff aussah, war kein Angriff. Es gab keine Brute-Force-Muster in den Access-Logs, keine Spitze im eingehenden Traffic. Stattdessen zeigte pg_stat_activity etwas, das ich zuerst nicht verstand: 148 von 150 erlaubten Verbindungen befanden sich im Status idle in transaction. Sie warteten nicht auf etwas, sie taten einfach nichts. Die Datenbank selbst war entspannt: CPU, IO, PGA — alles im grünen Bereich.

Ich habe den Vorfall während einer Code-Review bemerkt, als mir auffiel, dass wir kurz zuvor ein kleines Refactoring ausgerollt hatten. Nichts Dramatisches, dachten wir. Tatsächlich hatte es eine Lücke in der Fehlerbehandlung von Transaktionen offengelegt.

Root Cause

Der eigentliche Auslöser war ein vergessenes Rollback bei fehlgeschlagenen Operationen. Unsere Go-Anwendung verwendete Transaktionen in einem Helper-Paket, um Status-Updates atomar durchzuführen. Der neue Code fügte zusätzliche Validierungen hinzu, die in manchen Fällen einen context.Canceled oder sql.ErrNoRows zurückgaben — ohne dass die Transaktion sauber abgeschlossen wurde.

In PostgreSQL bedeutet eine offene Transaktion, dass die Verbindung reserviert bleibt, bis COMMIT oder ROLLBACK erfolgt. Da wir sql.DB mit SetMaxOpenConns(100) verwendeten, konnte der Pool schnell ausgeschöpft werden, sobald nur wenige Dutzend fehlgeschlagene Operationen anhängig blieben.

Warum haben Monitoring und Alarme nicht früher geschlagen? Weil die betroffenen Metriken — pg_stat_activity und Pool-Auslastung — nicht im Fokus standen. Wir überwachten Abfrageleistung und Fehlerraten, aber nicht die Anzahl idle in transaction-Sessions.

Zwei weitere Faktoren verschärften das Problem:

  1. Connection-Pool ohne harte Obergrenze für fehlerhafte Verbindungen. SetMaxIdleConns war auf 0 gesetzt, was normalerweise okay ist, aber bedeutete, dass jede fehlerhafte Verbindung sofort aus dem Pool entfernt und neu aufgebaut wurde — ein Teufelskreis.
  2. Kein Timeout für inaktive Transaktionen. idle_in_transaction_session_timeout war deaktiviert. Sobald eine Transaktion offen blieb, blieb sie das auch unbegrenzt.

Lösung

Der Fix besteht aus drei Teilen: sauberes Rollback in der Anwendung, harte Pool-Limits und eine Schutzschwelle in PostgreSQL.

1. defer tx.Rollback() erzwingen

Der wichtigste Teil ist in der Anwendung. Jede Transaktion muss mit einem Rollback abgesichert werden, selbst wenn der erfolgreiche Pfad ein Commit durchführt. defer ist hier kein Stilmittel, sondern eine Sicherheitsmaßnahme.

// Fehleranfällig: Commit nur bei Erfolg, kein Rollback bei Fehler
func updateStatus(ctx context.Context, db *sql.DB, id int, status string) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }

    _, err = tx.ExecContext(ctx, "UPDATE jobs SET status = $1 WHERE id = $2", status, id)
    if err != nil {
        return err
    }

    return tx.Commit()
}
// Stabil: Rollback per defer, Commit nur bei Erfolg
func updateStatus(ctx context.Context, db *sql.DB, id int, status string) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback() // wird nur wirksam, wenn Commit nicht zuvor aufgerufen wird

    _, err = tx.ExecContext(ctx, "UPDATE jobs SET status = $1 WHERE id = $2", status, id)
    if err != nil {
        return err
    }

    return tx.Commit() // defer wird bei Commit automatisch deaktiviert
}

2. Connection-Pool härten

Go’s database/sql bietet zwei wichtige Einstellungen, um Verbindungslecks zu begrenzen.

// Maximale Gesamtverbindungen: passt zur Datenbank-Einstellung max_connections
db.SetMaxOpenConns(50)

// Maximale Leerverbindungen: begrenzt die Anzahl offener Transaktionen im Leerlauf
db.SetMaxIdleConns(10)

// Maximale Lebensdauer einer Verbindung: erzwingt Recycling nach 5 Minuten
db.SetConnMaxLifetime(5 * time.Minute)

Warum SetMaxOpenConns(50) und nicht 100? Weil die Datenbank selbst oft headroom für administrative Verbindungen und Replikation braucht. Ein aggressiveres Limit verhindert, dass eine fehlerhafte Anwendung die gesamte Kapazität blockiert.

3. PostgreSQL-Schutzschwelle

Als zweite Verteidigungslinie hilft ein Timeout, das PostgreSQL selbst durchsetzt. Es ersetzt keine saubere Anwendungslogik, aber es begrenzt den Schaden, falls Code doch mal schiefgeht.

-- Keine Transaktion soll länger als 10 Sekunden im Leerlauf verharren
ALTER SYSTEM SET idle_in_transaction_session_timeout = '10s';

-- Einzelne Statements brechen nach 30 Sekunden ab
ALTER SYSTEM SET statement_timeout = '30s';

SELECT pg_reload_conf();

Verifikation

Wie prüfst du, dass der Fix wirklich wirkt? Ich verwende zwei einfache Tests — einen im Code, einen direkt auf der Datenbank.

1. Anwendungstest: Simulierter Fehlerpfad

go test ./internal/db -run TestUpdateStatusRollback -v

Erwartete Ausgabe: Der Test löst einen Fehler aus und prüft, dass die Transaktion nicht offen bleibt. In pg_stat_activity sollte die Verbindung nach Abschluss des Tests nicht mehr als idle in transaction angezeigt werden.

# Während und nach dem Test prüfen
psql -U postgres -d mydb -c \
  "SELECT pid, state, now() - state_change AS duration FROM pg_stat_activity WHERE state = 'idle in transaction' AND pid = <betroffene_pid>;"

2. Lasttest: Pool unter Stress

# 20 parallele Goroutinen, die fehlerhafte Transaktionen starten
go run ./cmd/stress-pool -workers 20 -fail-rate 0.3

Erwartetes Ergebnis: Auch bei 30 % Fehlerrate bleibt die Anzahl idle in transaction-Sessions unter 5. Die Anwendung antwortet weiterhin mit sql.ErrNoRows oder Kontextabbruch, aber der Pool erschöpft sich nicht.

Fazit

Dieser Incident war eine lehrreiche Kombination aus zwei Dingen: einem unauffälligen Code-Refactoring und einer fehlenden Sicherheitsmaßnahme. Die Datenbank war nie überlastet — sie wartete einfach nur darauf, dass jemand die Transaktionen beendet.

Die wichtigste Lehre: Jeder Pfad durch eine Transaktion braucht einen Exit. Egal ob Erfolg, Fehler oder Abbruch — defer tx.Rollback() ist die günstigste Versicherung, die es gibt.

Siehe auch: