Um 03:12 kam die Meldung, und sie war nicht höflich: FATAL: sorry, too many clients already. Mein Bot hatte bis dahin elf Stunden gearbeitet, 214 Trades gemacht und dabei insgesamt 4.000 Verbindungen zur Datenbank aufgebaut. Die Datenbank erlaubte 20 gleichzeitig. Ich hatte die Zahl nie gelesen, weil ich sie nie für relevant hielt.

Was mich daran am meisten geärgert hat, war nicht der Ausfall. Es war die Tatsache, dass ich drei Tage vorher stolz ein Dashboard gebaut hatte, das die Latenz in Millisekunden anzeigte, und keine einzige Zahl, die mir gesagt hätte, wie viele Verbindungen offen sind.

Der Server war nicht zu schwach, er war zu beliebt

Die Instanz kostet fünf Euro im Monat und hat 20 Verbindungen. Das ist keine schlechte Konfiguration, das ist eine Zusage: viel Speicher pro Verbindung, wenig Verwaltung. Mein Code hat diese Zusage gebrochen, weil ich in jedem Durchlauf eine neue Verbindung geöffnet und am Ende nicht geschlossen habe. Python schließt irgendwann auf, wenn der Garbage Collector Lust hat. Bis dahin sammelt sich alles.

Das Perfide daran ist die Verzögerung. Zwei Prozesse hätten 20 Verbindungen nie erreicht. Vier Prozesse plus ein Cronjob plus mein Notizbuch mit einer offenen Jupyter-Session plus ein pgAdmin, das ich beim Debuggen offen ließ, erreichen sie in einer Nacht. Jeder einzelne davon war für sich genommen vernünftig.

Was ein Pool wirklich macht

Bildschirmfoto eines Datenbank-Monitors mit einer Kurve offener Verbindungen, die in der Nacht auf den Grenzwert von 20 zuläuft
Der Graph, den ich mir am nächsten Morgen gebaut habe. Die Linie bei 20 ist die Grenze der Instanz, erreicht um 03:09, drei Minuten vor der ersten Fehlermeldung.

Eine Datenbankverbindung ist nicht billig. Der Server startet dafür einen eigenen Prozess, prüft die Anmeldung, schickt Zertifikate hin und her und reserviert Speicher, auch wenn niemand etwas abfragt. Deshalb ist die maximale Anzahl nicht willkürlich, sondern eine Rechnung aus Arbeitsspeicher und Verwaltungsaufwand. Ein Pool dreht das Modell um: Statt für jede Abfrage eine Verbindung zu öffnen, halte ich eine kleine Zahl offen und verleihe sie reihum.

flowchart LR A[4 Worker] --> B[Pool: 4 Verbindungen] C[Cronjob] --> B D[Notizbuch] --> E[eigener Pool: 2] B --> F[(Postgres, max 20)] E --> F F --> G[2 reserviert für Migrationen]

Der Pool ist damit keine Optimierung, sondern eine Warteschlange mit Obergrenze. Wenn alle Verbindungen vergeben sind, wartet der nächste Aufruf, statt den Server zu ersticken. Das ist der ganze Trick, und ich habe ihn drei Tage lang nicht verstanden, weil ich dachte, ein Pool sei eine Art Cache.

Meine drei falschen Fixes

Der erste Fix war, max_connections auf 100 zu setzen. Es hat funktioniert, für drei Tage. Danach war der Server langsam, weil 100 Prozesse auf einem 1-GB-Speicher konkurrierten, und ich hatte das eigentliche Problem nur verschoben. Der zweite Fix war, jede Verbindung nach der Abfrage sofort zu schließen. Das hat die Verbindungszahl gesenkt und die Latenz verdoppelt, weil jede Abfrage wieder eine Anmeldung brauchte.

Der dritte Fix war der schlechteste und der, auf den ich am stolzesten war: eine Retry-Schleife. Bei einem Fehler warte 200 Millisekunden und versuch es noch mal. Wenn 20 Prozesse gleichzeitig in dieselbe Wand laufen und alle nach 200 Millisekunden wieder anklopfen, hat man keinen Ausfall mehr, sondern einen Rhythmus. Ich habe das live gesehen, in einem Graphen, der wie ein Metronom aussah.

Wie es jetzt aussieht

Heute hat jeder Prozess seinen eigenen kleinen Pool, und die Summe ist absichtlich kleiner als die Grenze. Zwei Verbindungen bleiben für Migrationen frei, weil ich nicht möchte, dass ein Deploy an einer vollen Datenbank scheitert. Die Zahlen sind nicht optimiert, sie sind addiert: vier Worker mit je vier Verbindungen sind sechzehn, plus zwei ist achtzehn, plus Puffer ist zwanzig.

import asyncpg

_pool = None

async def init(dsn: str):
    global _pool
    _pool = await asyncpg.create_pool(
        dsn,
        min_size=2,
        max_size=4,
        max_inactive_connection_lifetime=60,
        command_timeout=5,
    )

async def save_fill(fill: dict):
    async with _pool.acquire() as con:
        await con.execute(
            "insert into fills (ts, symbol, qty, price) values (now(), $1, $2, $3)",
            fill["symbol"], fill["qty"], fill["price"],
        )

Zwei Zeilen darin sind wichtiger als der Rest. max_inactive_connection_lifetime sorgt dafür, dass eine Verbindung nach einer Minute Ruhe zurückgeht, damit ein nächtlicher Job nicht die ganze Nacht eine Verbindung blockiert. Und command_timeout bedeutet: Eine Abfrage, die fünf Sekunden braucht, ist ein Fehler und keine Geduldsfrage. Vorher hätte mein Bot gewartet, bis die Datenbank antwortet, und in der Zeit keine Orders mehr verarbeitet.

Fazit

Der Ausfall hat mich 60 Euro gekostet, weil eine Position ohne Stop-Loss offen blieb, während der Bot nicht schreiben konnte. Der Fix hat zwei Stunden gedauert. Ich hätte ihn im ersten Monat machen können, wenn ich gewusst hätte, dass eine Verbindung ein Betriebsmittel ist und keine Selbstverständlichkeit.

Weißt du bei deiner Datenbank, wie viele Verbindungen gerade offen sind, oder weißt du nur, dass es bisher gut ging?