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

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.
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?
marwankhalil
viertausend verbindungen in elf stunden ist keine pool frage, das ist ein loop der keine verbindung zurückgibt
bilalaydin
war es am ende ein fehlendes close oder ein pool der bei jedem request neu aufgemacht hat?
yaraosman
das dashboard mit latenz und ohne verbindungen ist die beste beschreibung von observability die ich kenne
pialindner
zwanzig gleichzeitig und niemand liest die zahl, ich habe sie auch erst gelesen als es zu spät war
gretasander
ein größerer pool hätte das problem auch gelöst, nur später, das ist keine falsche antwort sondern eine verschiebung
mariehoffmann
bei uns war es der load balancer der die verbindungen offen hielt, das hat zwei tage gedauert
benosei
ein fehlendes close im exception pfad, der pool hat brav weitergegeben was er hatte