Was ist ein Connection Pool?
Ein Connection Pool ist ein Cache aus offenen Datenbankverbindungen, aus dem sich die Anwendung Verbindungen leiht und an den sie diese wieder zurückgibt. Anstatt für jede Abfrage eine neue Verbindung zu öffnen, checkt ein Request eine Verbindung aus, führt seine Statements aus und gibt sie anschließend wieder zurück. Der nächste Request verwendet denselben Socket, der bereits authentifiziert und einsatzbereit ist.
Der Grund, warum dies wichtig ist: Eine Datenbankverbindung ist nicht „billig“. Bei einer lokalen Testdatenbank mag sich das augenblicklich anfühlen, aber in der Produktion verursacht jede neue Verbindung fixe Setup-Kosten, bevor sie überhaupt nützliche Arbeit verrichten kann. Ein Pool zahlt diese Kosten einmal pro Verbindung statt einmal pro Request – deshalb ist er eines der ersten Infrastruktur-Elemente, die fast jedes Backend implementiert.
Der Pool dient zudem als Sicherheitsmechanismus. Da er ein hartes Maximum besitzt, kann er während eines Traffic-Spikes nicht versehentlich zehntausend Verbindungen öffnen. Aufrufer, die eintreffen, während der Pool voll ist, warten in einer Queue. Das ist zwar langsam, aber überlebbar – im Gegensatz zum Überlasten der Datenbank, was fatal wäre.
Ein Pool in wenigen Zeilen
In Node liefert der pg-Treiber einen einsatzbereiten Pool mit. Die Erstellung erfolgt über einen einzigen Konstruktor-Aufruf, und jede Abfrage, die darüber ausgeführt wird, leiht sich automatisch eine Verbindung aus und gibt sie anschließend wieder zurück.
import { Pool } from "pg";
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
const { rows } = await pool.query("SELECT id, email FROM users WHERE id = $1", [id]);
Das ist das gesamte Konzept. Der Pool öffnet Verbindungen lazy, sobald Bedarf besteht, hält sie zwischen den Abfragen offen und schließt sie, wenn sie zu lange im Leerlauf waren. Die Anwendung sieht niemals einen Socket, sondern nur eine query-Methode.
Dasselbe Muster existiert in jedem Ökosystem. Java hat HikariCP, Python hat den Pool von SQLAlchemy und asyncpg, Go hat database/sql mit SetMaxOpenConns, und jedes ORM kapselt eines dieser Tools. Die Namen variieren, aber die Einstellungen sind immer dieselben wenigen: ein Maximum, ein Minimum, ein Idle-Timeout und ein Checkout-Timeout.
Warum Verbindungen teuer sind
Die Kosten setzen sich aus einer Reihe von Schritten zusammen, bei denen jeweils das Netzwerk oder der Datenbankserver aktive Arbeit leisten muss.
- TCP handshake — ein Roundtrip, um den Socket zu etablieren. Bei einer Remote-Datenbank mit einer RTT von 30 ms sind das allein schon 30 ms.
- TLS negotiation — wenn die Verbindung verschlüsselt ist, sind mehrere weitere Roundtrips nötig, um sich auf Keys zu einigen. Weitere 50 bis 100 ms sind hier üblich.
- Authentication — der Client weist seine Identität nach, oft mittels eines Passwort-Hashes, den der Server berechnen muss. Die SCRAM-Authentifizierung ist bewusst rechenintensiv gestaltet.
- Backend process — dies ist ein Postgres-spezifischer Kostenfaktor. Postgres forkt für jede Verbindung einen neuen Betriebssystem-Prozess mit eigenem Speicher. MySQL verwendet einen Thread, was zwar leichtgewichtiger ist, aber dennoch nicht kostenlos.
- Session setup — Search Paths, Zeitzone, Application Name und andere Einstellungen müssen angewendet werden.
Rechnet man dies zusammen, kann eine neue Verbindung bereits einige zehn Millisekunden kosten, bevor die erste Query überhaupt ausgeführt wird. Wenn eine Seite zehn Queries absetzt, würde das Öffnen einer Verbindung pro Query die gesamte Request-Zeit dominieren. Schlimmer noch: Das Process-per-Connection-Modell bedeutet, dass auch inaktive Verbindungen Speicher verbrauchen, sodass einige hundert davon den Server spürbar belasten können.
Ein Pool verwandelt all dies in einmalige Kosten. Die Verbindung wird einmal erstellt, für tausende Queries verwendet und erst geschlossen, wenn der Pool entscheidet, dass sie zu alt oder zu lange inaktiv ist.
Der Lebenszyklus einer gepoolten Verbindung
Jeder Checkout folgt denselben vier Schritten. Wenn man diese versteht, lassen sich fast alle Pool-Verhaltensweisen erklären, die man im Debugging analysiert.
Acquire. Der Aufrufer fordert eine Verbindung vom Pool an. Wenn eine Verbindung im Leerlauf (idle) ist, wird sie sofort übergeben. Wenn alle belegt sind, der Pool aber unter max liegt, wird eine neue Verbindung geöffnet. Wenn der Pool bei max liegt, wartet der Aufrufer in einer FIFO-Queue, bis eine Verbindung freigegeben wird. Diese Wartezeit ist das Signal dafür, dass der Pool gesättigt ist.
Use. Der Aufrufer führt eine oder mehrere Queries über die Verbindung aus. Hier leistet die Verbindung tatsächlich ihren Beitrag. Hier passieren aber auch Fehler: eine offene gelassene Transaktion, ein client, das nie freigegeben wurde, oder eine Query ohne Timeout.
Release. Der Aufrufer gibt die Verbindung an den Pool zurück. Dies muss in einem finally-Block geschehen, da eine Exception zwischen Acquire und Release dazu führt, dass die Verbindung dauerhaft verloren geht (Leak). Eine geleakte Verbindung bleibt unsichtbar, bis der Pool leerläuft und jede Anfrage in einen Timeout läuft.
Reap. Verbindungen, die länger als idleTimeoutMillis im Leerlauf sind oder eine maximale Lebensdauer überschreiten, werden geschlossen. Dies verhindert, dass der Pool Ressourcen hortet, die die Datenbank an anderer Stelle nutzen könnte, und sorgt dafür, dass veraltete Verbindungen von vor einem Neustart ausgemustert werden.
const client = await pool.connect();
try {
return await client.query("SELECT now()");
} finally {
client.release();
}
Viele Treiber erlauben es, den expliziten Checkout bei einfachen Queries zu überspringen — pool.query() übernimmt das Acquire und Release für Sie —, was die häufigste Quelle für Leaks eliminiert. Verwenden Sie die explizite Form nur dann, wenn Sie mehrere Statements auf derselben Verbindung benötigen.
Checkout, Query, Release – sicher implementiert
Das oben gezeigte Drei-Zeilen-Muster ist die Grundlage für jede sichere Pool-Interaktion, aber produktiver Code erfordert an den Rändern etwas mehr Sorgfalt.
Kapseln Sie den gesamten Checkout-Prozess in einem Helper, damit kein Aufrufer das Release vergessen kann. Der Helper übernimmt die Verwaltung von try/finally sowie das Error-Handling, während der Rest der Codebase lediglich eine Funktion awaitet.
export async function withClient<T>(
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
return await fn(client);
} finally {
client.release();
}
}
await withClient((client) =>
client.query("UPDATE jobs SET status = 'done' WHERE id = $1", [id]),
);
Bedenken Sie, was passiert, wenn die Verbindung selbst unterbrochen wird. Eine Query kann fehlschlagen, weil das SQL falsch ist – was das Problem des Aufrufers ist – oder weil der Socket gestorben ist, was das Problem des Pools ist. Der zweite Fall rechtfertigt einen Retry, aber nur, wenn die Operation sicher zu wiederholen ist. Ein SELECT ist dies; ein INSERT ohne Idempotency-Key hingegen nicht.
async function queryWithRetry(text: string, params: unknown[], retries = 2) {
for (let attempt = 0; ; attempt++) {
try {
return await pool.query(text, params);
} catch (err) {
const isConnectionError = (err as { code?: string }).code === "ECONNRESET";
if (!isConnectionError || attempt >= retries) throw err;
}
}
}
Setzen Sie abschließend ein Statement-Timeout für die Verbindung, damit eine einzelne außer Kontrolle geratene Query nicht dauerhaft einen Pool-Slot belegt. Bei Postgres bewirkt statement_timeout, dass die Query serverseitig abgebrochen wird; ohne diese Einstellung wartet der Client genauso lange wie die Datenbank.
Den Pool dimensionieren
Man ist oft versucht, max so hoch wie möglich anzusetzen, damit nie etwas warten muss. Das ist jedoch genau das Gegenteil von dem, was man erreichen möchte. Eine Datenbank verfügt über eine begrenzte Anzahl an CPU-Kernen und eine begrenzte Menge an Arbeitsspeicher; sie kann nicht mehr Abfragen parallel ausführen, als ihre Kapazität zulässt. Zusätzliche Verbindungen steigern nicht den Durchsatz, sondern führen zu mehr Context Switching, Lock Contention und Speicherdruck.
Die klassische Faustregel stammt aus dem PostgreSQL-Wiki:
connections = (cores × 2) + effective_spindle_count
Für eine Datenbank mit vier Kernen und SSD-Speicher sind das etwa acht bis zehn Verbindungen. Das klingt alarmierend wenig, ist aber korrekt: Eine gut indexierte Abfrage ist in ein oder zwei Millisekunden abgeschlossen, sodass eine Handvoll Verbindungen Tausende von Anfragen pro Sekunde bedienen kann. Die Formel ist ein Ausgangspunkt, kein Gesetz – messen und anpassen.
Der zweite Teil der Berechnung ist der, den viele vergessen. Jede Applikations-Instanz betreibt ihren eigenen Pool, daher muss das Budget der Datenbank durch die Anzahl der Instanzen geteilt werden:
pool max per instance = database budget / number of instances
Zehn Instanzen mit einem Pool von zwanzig fordern zweihundert Verbindungen von der Datenbank an, was fast sicher über max_connections liegt. In diesem Fall sollten Sie entweder den Pool pro Instanz reduzieren oder einen Pooler vorschalten.
Prüfen Sie abschließend die eigenen Limits der Datenbank. Postgres setzt max_connections standardmäßig auf 100, und reservierte Verbindungen für Superuser und Replikation reduzieren die tatsächlich verfügbare Menge. Wenn Sie mehr als diesen Wert anfordern, erhalten Sie Fehlermeldungen statt einer kontrollierten Leistungsreduzierung (graceful degradation).
Das Pool-per-Instance Fan-out Problem
Dies ist die am häufigsten auftretende Verbindungsproblem in modernen Deployments, und es geschieht schleichend.
Ein Service startet auf einer Instanz mit einem Pool von zwanzig Verbindungen. Es funktioniert. Der Traffic steigt, also skaliert der Service auf fünf Instanzen – und nun sieht die Datenbank einhundert Verbindungen, genau am Limit. Skaliert man auf zehn Instanzen, sind es zweihundert, also weit über dem Limit. An der Anwendung hat sich nichts geändert; nur das Fan-out hat zugenommen.
Dieses Muster wiederholt sich in Kubernetes, in Serverless-Umgebungen und überall dort, wo sich Prozesse vervielfachen. Ein Pool begrenzt die Verbindungen pro Prozess, nicht pro System. Die Lösung ist entweder ein kleiner Pool pro Instanz, der auf die Flotte abgestimmt ist, oder ein serverseitiger Pooler, der der Datenbank unabhängig von der Anzahl der verbundenen Clients nur einen einzigen kleinen Pool präsentiert.
10 instances × pool max 20 = 200 database connections
↓
database max_connections = 100
↓
"too many clients already"
Ein praxisnahes Beispiel zur Dimensionierung
Zahlen machen die Trade-offs greifbar. Angenommen, die Datenbank ist eine verwaltete Instanz mit vier Kernen, SSD-Speicher und dem Standard-max_connections von 100, und der Service läuft auf acht Applikationsinstanzen.
Die Faustregel ergibt ein Budget von etwa zehn Verbindungen, die die Datenbank tatsächlich parallel nutzen kann. Verteilt auf acht Instanzen bedeutet das einen Pool von einer oder zwei Verbindungen pro Instanz – weit weniger als die zehn, die üblicherweise eingestellt werden, und oft genau richtig für eine schnelle, gut indexierte Workload. Wenn sich das zu knapp anfühlt, ist die Lösung nicht, den Pool zu vergrößern, sondern einen Pooler davorzuschalten, sodass die acht kleinen Pools einen kontrollierten Satz an Backends gemeinsam nutzen.
database budget ≈ 10 connections
app instances = 8
pool max per instance = 10 / 8 ≈ 1
too small to be useful → add PgBouncer
PgBouncer default_pool_size = 10
app pool max (per instance) = 5 # clients may wait; backends stay bounded
Die entscheidende Erkenntnis ist, dass der Applikations-Pool und das Datenbank-Budget unterschiedliche Zahlen sind. Der App-Pool steuert, wie viele Anfragen jede Instanz gleichzeitig ausführen kann; der Pooler steuert, wie viele Serververbindungen tatsächlich existieren. Wenn man den App-Pool etwas größer einstellt als den Anteil pro Instanz, kann eine Instanz kurzzeitig Spitzen abfangen, während der Pooler die Datenbank schützt.
Lassen Sie immer einen Puffer. Verwaltete Datenbanken reservieren einige Verbindungen für die Administration und Replikation, und ein Failover benötigt kurzzeitig mehr. Wenn man 70 bis 80 Prozent von max_connections anpeilt, bleibt genügend Spielraum für Migrationen, Monitoring und einen fehlerhaften Deploy.
Verbindungen gesund halten
Langfristige Verbindungen können instabil werden. Ein Datenbank-Neustart, ein Failover, eine Netzwerkpartitionierung oder ein Firewall-Idle-Timeout können den Socket schließen, ohne dass die Anwendung dies bemerkt. Die nächste Abfrage über diese Verbindung schlägt fehl, und ohne entsprechende Behandlung ist dieser Fehler schwer nachvollziehbar.
Drei Einstellungen steuern dies:
idleTimeoutMillisschließt Verbindungen, die eine Zeit lang nicht genutzt wurden. Kurze Werte halten den Server sauber, bergen aber das Risiko, dass in ruhigen Phasen ständig neue Verbindungen geöffnet werden müssen; dreißig Sekunden sind hier ein gängiger Kompromiss.- Eine maximale Lebensdauer (Maximum Lifetime) führt Verbindungen nach einem festgelegten Alter aus, unabhängig von ihrer Nutzung. Dies verteilt die Neuverbindungen gleichmäßig, anstatt zuzulassen, dass bei einem Failover alle Verbindungen gleichzeitig abbrechen.
- Validierung führt vor der Ausgabe einer Verbindung eine einfache Prüfung durch, wie zum Beispiel
SELECT 1, sodass ein toter Socket verworfen wird, anstatt an den Aufrufer weitergegeben zu werden.
Auch bei Nutzung all dieser Maßnahmen sollten Verbindungsfehler explizit behandelt werden. Ein im Leerlauf befindlicher Client, der einen Fehler verursacht, muss aus dem Pool entfernt werden. Der Treiber feuert normalerweise genau für diesen Fall ein error-Event:
pool.on("error", (err) => {
console.error("unexpected idle client error", err);
});
Das Ignorieren dieses Events verwandelt eine behebbare kurze Störung in eine unbehandelte Exception, die den gesamten Prozess zum Absturz bringen kann.
Transaktionen benötigen eine einzige Verbindung
Eine Transaktion ist an eine einzige Verbindung gebunden. Jedes BEGIN, Statement und COMMIT muss auf demselben Client ausgeführt werden, da der Transaktionsstatus in diesem Backend-Prozess lebt. Hier interagieren Pooling und Transaktionen, und hier scheitert naiver Code.
Das Fehlermuster ist subtil: Wenn Sie BEGIN über pool.query() ausführen, kann der Pool Ihnen für das nächste Statement eine andere Verbindung zuweisen, wodurch der COMMIT entweder fehlschlägt oder nichts committet. Die Regel ist einfach – checken Sie einen Client für die gesamte Transaktion aus und geben Sie ihn erst nach dem Commit oder Rollback wieder frei.
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("UPDATE accounts SET balance_cents = balance_cents - $1 WHERE id = $2", [100, from]);
await client.query("UPDATE accounts SET balance_cents = balance_cents + $1 WHERE id = $2", [100, to]);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
Da eine Transaktion eine Verbindung über ihre gesamte Dauer belegt, verringern lange Transaktionen den effektiven Pool für alle anderen. Halten Sie diese kurz, vermeiden Sie Netzwerkaufrufe innerhalb der Transaktionen und setzen Sie ein statement_timeout, damit eine außer Kontrolle geratene Query eine Verbindung nicht auf unbestimmte Zeit blockieren kann.
Prepared Statements und der Pool
Prepared Statements sind ein Performance-Feature: Die Datenbank parst und plant eine Query einmalig und verwendet diesen Plan anschließend wieder. Hier wird das Pooling jedoch komplex, da ein Prepared Statement an eine spezifische Backend-Verbindung gebunden ist.
Bei einem In-Process-Pool ist dies meist unproblematisch. Treiber wie pg bereiten ein Statement auf der Verbindung vor, die gerade ausgecheckt ist; der Plan wird nur dann wiederverwendet, wenn dieselbe Verbindung die Query erneut ausführt. Es geht nichts kaputt, aber der Performance-Gewinn ist ungleichmäßig und der Treiber muss einen wachsenden Cache aus benannten Statements verwalten.
Probleme entstehen, wenn man benannte Prepared Statements mit einem Pooler im Transaction-Mode kombiniert. Der Pooler sendet die Query möglicherweise an ein anderes Backend als das, welches das Statement vorbereitet hat. Der Server erkennt den Namen daher nicht und gibt einen Fehler zurück. Dies ist die häufigste Überraschung bei der Nutzung von PgBouncer.
Die Lösungen sind simpel:
- Deaktivieren Sie serverseitige Prepared Statements im Treiber, wenn Sie Transaction Pooling verwenden, sodass nur unbenannte Statements gesendet werden. Viele Treiber haben genau dafür ein entsprechendes Flag.
- Oder nutzen Sie Session Pooling, wodurch ein Client an einem Backend bleibt und Prepared Statements wieder sicher werden.
- Oder setzen Sie
max_prepared_statementsin modernen PgBouncer-Versionen, welche das Prepare-Protokoll korrekt proxien.
Das allgemeine Prinzip ist: Alles, was in der Server-Session gespeichert wird, ist unter Transaction Pooling fragil. Prepared Statements, SET-Variablen, temporäre Tabellen und Advisory Locks gehören alle zu einer Verbindung – und ein Pooler kann Ihnen beim nächsten Mal problemlos eine andere zuweisen.
Serverless und Connection Exhaustion
Serverless-Funktionen stellen den Worst Case für Connection Pooling dar. Jeder Aufruf kann in einem frischen, kurzlebigen Container ohne gemeinsamen Speicher ausgeführt werden, sodass ein von einem anderen Aufruf erstellter Pool nicht wiederverwendet werden kann. Unter Last öffnen hunderte gleichzeitige Funktionen jeweils eine Verbindung, und die Datenbank sieht sich einem „Connection Storm“ gegenüber, den sie nicht überlebt.
Die Lösung ist ein serverseitiger Pooler zwischen den Funktionen und der Datenbank. PgBouncer oder ein verwaltetes Äquivalent wie RDS Proxy bzw. der integrierte Pooler eines Providers hält eine kleine Anzahl echter Verbindungen bereit und multiplexed die vielen kurzlebigen Client-Verbindungen darauf.
1000 concurrent functions ──► PgBouncer ──► 20 Postgres connections
(clients) (pooler) (backends)
Ein Pool innerhalb einer Funktion ist immer noch sinnvoll, muss aber winzig sein – oft nur eine einzige Verbindung – und so konfiguriert werden, dass er schnell geschlossen wird. Ein Container, der Verbindungen im Leerlauf offen hält, verschwendet nämlich das Budget der Datenbank. Der Pooler ist das Element, das die Zahlen am Ende stimmig macht.
PgBouncer und Transaction Pooling
PgBouncer ist der Standard-External-Pooler für Postgres. Er spricht das Postgres-Wire-Protokoll, sodass sich Anwendungen genauso mit ihm verbinden, wie sie es mit der Datenbank tun würden. Es gibt drei Pooling-Modi, und die Wahl des richtigen Modus hat weitreichende Konsequenzen.
Session Pooling weist dem Client für die gesamte Dauer seiner Session eine Serververbindung zu. Dies ist der kompatibelste Modus — SET, LISTEN, Advisory Locks und Prepared Statements verhalten sich alle normal — aber er bietet das geringste Multiplexing, da ein Client seine Verbindung auch im Leerlauf beibehält.
Transaction Pooling weist eine Serververbindung nur für die Dauer einer Transaktion zu und gibt diese nach dem Commit an den Pool zurück. Tausend Clients können sich so zwanzig Backends teilen, weshalb dies die Standardwahl für Webanwendungen ist. Der Nachteil ist, dass der Session-Status zwischen Transaktionen nicht erhalten bleibt: Ein SET landet beim nächsten Mal möglicherweise auf einem anderen Backend, Advisory Locks, die über mehrere Statements hinweg gehalten werden, funktionieren nicht und serverseitige Prepared Statements können kollidieren.
Statement Pooling gibt die Verbindung nach jedem einzelnen Statement zurück. Dieser Modus bietet das höchste Multiplexing, ist aber am restriktivsten; Transaktionen über mehrere Statements hinweg sind nicht erlaubt. In den meisten Fällen ist dies nicht die gewünschte Lösung.
[databases]
shop = host=127.0.0.1 port=5432 dbname=shop
[pgbouncer]
pool_mode = transaction
default_pool_size = 20
max_client_conn = 1000
server_idle_timeout = 60
Wenn Sie den Transaction-Modus verwenden, prüfen Sie Ihr ORM und Ihre Queries. Verwenden Sie SET LOCAL innerhalb einer Transaktion anstelle von SET, vermeiden Sie es, Advisory Locks über mehrere Statements hinweg zu halten, und konfigurieren Sie den Driver so, dass serverseitige Prepared Statements deaktiviert werden oder verwenden Sie nicht benannte Statements.
Überwachung der Pool-Health
Ein Pool hat vier Kennzahlen, die man im Auge behalten sollte. Zusammen geben sie Aufschluss darüber, ob die Pool-Größe korrekt dimensioniert ist.
- Wartezeit für eine Verbindung. Das direkteste Signal für eine Sättigung. Wenn Aufrufer regelmäßig warten müssen, ist der Pool für die Last zu klein oder etwas hält Verbindungen zu lange besetzt.
- Aktive gegenüber inaktiven (idle) Verbindungen. Ein Pool, der ständig sein Maximum erreicht und alle Verbindungen aktiv sind, ist unterdimensioniert. Ein Pool, der größtenteils im Idle-Zustand ist, ist überdimensioniert und verschwendet den Arbeitsspeicher der Datenbank.
- Gesamtanzahl gegenüber Maximum. Wie nah der Pool an seiner Obergrenze operiert. Wenn dieser Wert konsistent nahe bei
maxliegt, wird der nächste Lastspitzen-Peak zu Warteschlangen führen. - Fehler und Timeouts. Verbindungsfehler, Validierungsfehler und
connectionTimeoutMillis-Abläufe. Eine steigende Anzahl deutet auf Probleme im Netzwerk, mit der Datenbank oder auf veraltete Verbindungen (stale connections) hin.
Auf der Datenbankseite zeigt pg_stat_activity jede Verbindung und ihren Status an – dies ist der schnellste Weg, um zu sehen, ob sich inaktive Verbindungen ansammeln. Kombinieren Sie beide Ansichten: Die Metriken der Anwendung sagen Ihnen, wie sich der Pool verhält, und die Datenbank-Metriken sagen Ihnen, welche Auswirkungen dies auf den Server hat.
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY state
ORDER BY count(*) DESC;
Ein gesunder Anwendungs-Pool zeigt eine geringe Anzahl an active-Verbindungen und einige idle-Verbindungen. Ein wachsender Stapel an idle in transaction-Zeilen ist ein gefährliches Muster: Diese Verbindungen sind ausgecheckt, tun aber nichts – oft, weil eine Transaktion geöffnet, aber nie mit einem Commit abgeschlossen wurde. Sie belegen Slots im Pool und können bei Postgres den Vacuum-Prozess blockieren. Behandeln Sie eine steigende Anzahl dieser Verbindungen als Bug und nicht als Tuning-Problem.
ORM-Pools und der Pooler
ORMs machen Pooling nicht überflüssig; sie verstecken es lediglich. Prisma, TypeORM, Drizzle und Knex verwalten jeweils ihren eigenen Pool und bieten eine connectionLimit- oder pool-Option an. Das ist zwar praktisch, bedeutet aber, dass dieselbe Berechnungslogik für die Größe gilt und dass dasselbe Fan-out-Problem besteht.
Der Fehler besteht darin, einen ORM-Pool und einen Pooler zu betreiben, ohne diese aufeinander abzustimmen. Der ORM-Pool legt fest, wie viele Verbindungen eine einzelne Instanz anfordert; der Pooler legt fest, wie viele die Datenbank zulässt. Wenn der ORM-Pool groß ist und das default_pool_size des Poolers klein, wird das ORM Verbindungen anfordern, die der Pooler nicht bereitstellen kann, wodurch Anfragen am Pooler statt am ORM in die Warteschlange gelangen. Konfigurieren Sie beides bewusst und bevorzugen Sie einen moderaten ORM-Pool hinter einem Pooler gegenüber einem großen Pool ohne Pooler.
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
Ein Pool ist kein Cache
Es lohnt sich, den Unterschied zu benennen, da die beiden oft verwechselt werden. Ein Cache speichert Ergebnisse, damit Sie es vermeiden können, eine Abfrage erneut auszuführen. Ein Pool speichert Verbindungen, damit Sie Abfragen kostengünstiger ausführen können. Das Hinzufügen eines Pools reduziert nicht die Anzahl der Abfragen; es sorgt lediglich dafür, dass jede einzelne Abfrage schneller gestartet werden kann.
Wenn eine Seite zwanzig Abfragen ausführt, macht ein Pool diese zwanzig Abfragen schnell, aber er lässt sie nicht verschwinden. Die nächste Optimierungsebene ist das Caching der teuren Ergebnisse, worum es im Guide zum Caching geht. Beides lässt sich gut kombinieren: Ein Pool hält die verbleibenden Abfragen kostengünstig, während ein Cache diejenigen entfernt, die Sie komplett vermeiden können.
Diese Unterscheidung erklärt auch eine häufige Enttäuschung. Teams implementieren einen Pool, stellen fest, dass die Latenz sinkt, und fragen sich dann, warum der Durchsatz unter hoher Last unverändert bleibt. Der Pool hat den Overhead für den Verbindungsaufbau eliminiert, aber die Datenbank muss immer noch die gesamte Arbeit erledigen. Nur ein Cache, ein besserer Index oder weniger Abfragen können das ändern.
Testen mit einem Pool
Tests sollten denselben Pooling-Pfad nutzen wie die Produktion, da die kritischen Bugs – wie ein geleakter Client oder eine Transaktion auf der falschen Verbindung – nur über den Pool auftreten.
Verwenden Sie einen einzigen, gemeinsam genutzten Pool für die gesamte Test-Suite und schließen Sie diesen einmal am Ende. Das Öffnen eines Pools pro Testdatei ist langsam und kann das Connection-Limit der Datenbank überschreiten, wenn Tests parallel ausgeführt werden.
import { afterAll } from "vitest";
import { pool } from "../src/db.js";
afterAll(async () => {
await pool.end();
});
test("findUser returns null for a missing id", async () => {
const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", ["nope"]);
expect(rows).toHaveLength(0);
});
Richten Sie die Tests auf eine Wegwerf-Datenbank oder eine Transaktion, die zurückgerollt wird, und prüfen Sie das Pool-Verhalten dort, wo es wichtig ist: Ein Test, der pool.totalCount und pool.idleCount vor und nach einer Operation überprüft, findet einen geleakten Client, den eine normale Assertion übersehen würde. Bei Postgres kann pg_stat_activity bestätigen, dass die Anzahl der Verbindungen wieder auf den Ausgangswert zurückgekehrt ist.
Best Practices
- Verwenden Sie immer einen Pool; öffnen Sie niemals eine Verbindung pro Request.
- Dimensionieren Sie den Pool basierend auf der Kapazität der Datenbank und teilen Sie diesen dann durch die Anzahl der Instanzen.
- Setzen Sie ein
connectionTimeoutMillis, damit Aufrufer schnell fehlschlagen, anstatt hängen zu bleiben. - Legen Sie ein Idle-Timeout und eine maximale Lebensdauer fest, damit veraltete Verbindungen zurückgezogen werden.
- Geben Sie Verbindungen in einem
finally-Block frei und behandeln Sie daserror-Event des Pools. - Halten Sie eine Verbindung für eine gesamte Transaktion und halten Sie Transaktionen kurz.
- Schalten Sie einen Pooler vor Postgres, sobald die Anzahl der Instanzen steigt oder Funktionen serverless sind.
- Nutzen Sie Transaction Pooling für Web-Apps und prüfen Sie Features, die auf Session-Scope basieren.
- Überwachen Sie die Wartezeit, die Anzahl aktiver gegenüber inaktiver Verbindungen sowie Timeouts, nicht nur die Query-Latency.
- Koordinieren Sie die Pool-Größe des ORM mit der Pool-Größe des Poolers.
Häufige Fehler
- Erstellen eines
Clientpro Request anstatt eines Pools zu verwenden. maxauf hunderte setzen und dies als Tuning bezeichnen.- Vergessen, einen Client freizugeben, wodurch der Pool langsam leerläuft.
- Ausführen von
BEGINundCOMMITüberpool.query()auf verschiedenen Verbindungen. - Eine Transaktion über einen HTTP-Aufruf oder eine Benutzerinteraktion hinweg offen halten.
connectionTimeoutMillisauf Null lassen, sodass Requests ewig warten.- Das
error-Event des Pools ignorieren und bei einem toten Idle-Client abstürzen. - Betrieb eines In-Process-Pools in Serverless-Funktionen ohne vorgeschalteten Pooler.
- Die Annahme, dass der Transaction-Mode von PgBouncer Prepared Statements und Session-State unterstützt.
- Die Query-Latency beobachten, während die Pool-Wartezeit unbemerkt ansteigt.
Wie geht es weiter?
Pooling ist untrennbar mit der Datenbank verbunden, die es bedient. Daher behandelt der PostgreSQL-Guide max_connections, PgBouncer und das Process-per-Connection-Modell im Detail. Bevor Sie den Pool optimieren, prüfen Sie, ob die Abfrage überhaupt ausgeführt werden muss – der Caching-Guide zeigt, wie man die Last direkt an der Quelle reduziert. Wenn Sie Worker einsetzen, erklärt Batch Processing, wie man verhindert, dass eine ganze Flotte davon die Datenbank überlastet. Zudem lohnt sich ein erneuter Blick auf Node.js, um zu verstehen, wie der Event Loop und asynchrones I/O mit einem Pool interagieren.