Zurück zum Blog
Juli 2026·3 Min. LesezeitKI-generiert
Sprache:DeutschEnglishFrançaisÖsterreichischKlingonisch Originalfassung: English

Datenbank-Connection-Pooling verstehen

#databases#backend#performance

Eine Datenbankverbindung zu öffnen ist teuer: ein TCP-Handshake, Authentifizierung, manchmal TLS-Aushandlung – häufig zig Millisekunden, bevor eine einzige Abfrage läuft. Öffnet man pro Anfrage eine Verbindung, zahlt man diese Kosten jedes Mal, und irgendwann verweigert die Datenbank neue Verbindungen, weil sie eine harte Grenze hat, wie viele sie gleichzeitig offen halten kann. Connection Pooling löst beide Probleme, indem es eine Menge bereits geöffneter Verbindungen zur Wiederverwendung bereithält.

Was ein Pool tatsächlich tut

Ein Connection Pool ist eine feste Menge von Datenbankverbindungen, die für die Lebensdauer deiner Anwendung leben, nicht für die einer einzelnen Anfrage. Muss dein Code eine Abfrage ausführen, leiht er sich eine Verbindung aus dem Pool, nutzt sie und gibt sie zurück – er öffnet oder schließt dabei gar keinen Socket.

const { Pool } = require('pg');

const pool = new Pool({
  host: 'localhost',
  database: 'app',
  max: 10, // maximum number of connections in the pool
  idleTimeoutMillis: 30000,
});

async function getUser(id) {
  const client = await pool.connect();
  try {
    const result = await client.query('SELECT * FROM users WHERE id = $1', [id]);
    return result.rows[0];
  } finally {
    client.release(); // return the connection to the pool, don't close it
  }
}

pool.connect() gibt dir eine vorhandene freie Verbindung, falls eine verfügbar ist, oder öffnet eine neue (bis zu max), falls nicht. client.release() ist der Teil, den man vergisst – ohne ihn wird diese Verbindung nie an den Pool zurückgegeben, und so erschöpft man ihn unter Last.

Warum ein unbegrenzter Pool schlimmer ist als gar keiner

Es ist verlockend, max „zur Sicherheit" hoch zu setzen. Das Problem: Deine Datenbank hat ebenfalls eine harte Verbindungsobergrenze (max_connections in Postgres, oft standardmäßig 100), die sich alle Anwendungsinstanzen teilen, die mit ihr sprechen. Betreibst du 10 App-Instanzen mit je max: 50, kannst du 500 Verbindungen gegen eine Datenbank anfordern, die nur 100 akzeptiert – und ist diese Grenze erreicht, wird jeder andere Client abgelehnt, einschließlich deiner eigenen Admin-Werkzeuge.

Eine vernünftige Poolgröße liegt als Ausgangsheuristik für die Datenbank selbst näher bei (Anzahl CPU-Kerne × 2) + effektive Anzahl Spindeln, geteilt durch die Anzahl der Anwendungsinstanzen, die sich verbinden – nicht „so viele gleichzeitige Anfragen, wie ich erwarte". Mehr Verbindungen, als die CPU-Kerne deiner Datenbank wirklich parallel bedienen können, erzeugen nur Warteschlangen, keinen Durchsatz.

Der häufigste Pooling-Fehler: bei einem Fehler nicht freigeben

// Bad: if query() throws, the connection is never released
async function getUserBroken(id) {
  const client = await pool.connect();
  const result = await client.query('SELECT * FROM users WHERE id = $1', [id]);
  client.release();
  return result.rows[0];
}

Wirft client.query() eine Exception, läuft client.release() nie, und diese Verbindung bleibt dauerhaft ausgeliehen. Macht man das oft genug, schrumpft der Pool stillschweigend auf null nutzbare Verbindungen, während er von außen völlig gesund aussieht. Die try/finally-Variante oben ist die Lösung – finally läuft, egal ob die Abfrage gelingt oder wirft.

pool.query() für einzelne Anweisungen

Die meisten Treiber bieten eine Abkürzung, die eine Verbindung ausleiht, eine Abfrage ausführt und sie automatisch freigibt:

const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);

Nutze das für einzelne, unabhängige Abfragen. Für Transaktionen ist es ungeeignet, denn jeder Aufruf von pool.query() kann auf einer anderen Verbindung laufen – und eine Transaktion ergibt nur auf einer bestimmten Verbindung für ihre gesamte Dauer Sinn.

Transaktionen brauchen eine ausdrückliche Verbindung

async function transferFunds(fromId, toId, amount) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, fromId]);
    await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, toId]);
    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Hier führt derselbe client BEGIN, beide Updates und COMMIT aus – das hält sie in einer Transaktion zusammen. Würde man pool.query()-Aufrufe in die Mitte mischen, liefen manche Anweisungen stillschweigend auf anderen Verbindungen, außerhalb der Transaktion.

Idle-Timeouts und veraltete Verbindungen

Verbindungen, die lange untätig herumliegen, können von der Datenbank, einem Load Balancer oder einer Firewall dazwischen stillschweigend gekappt werden, ohne dass die Anwendung es merkt. idleTimeoutMillis weist den Pool an, Verbindungen, die zu lange untätig waren, proaktiv zu schließen und zu ersetzen, statt zu warten, bis man versucht, eine tote zu nutzen, und mitten in der Anfrage einen verwirrenden Fehler bekommt.

const pool = new Pool({
  max: 10,
  idleTimeoutMillis: 30000, // close idle connections after 30s
  connectionTimeoutMillis: 5000, // fail fast if no connection is available within 5s
});

connectionTimeoutMillis ist ebenso wichtig: Ohne es hängt eine Anfrage, die keine Verbindung bekommt, weil der Pool erschöpft ist, unbegrenzt, statt schnell mit einem klaren Fehler zu scheitern.

Serverless ändert die Rechnung komplett

In einem klassischen langlaufenden Server lebt ein Pool für die gesamte Lebensdauer des Prozesses, was genau das ist, wofür Pooling gedacht ist. In Serverless-Umgebungen (Lambda, Cloud Functions) kann jeder Aufruf einen frischen Prozess starten, und einen vollständigen Pool pro Aufruf zu erzeugen verfehlt den Zweck – man zahlt wieder ständig den Verbindungs-Overhead, und ein Schwall gleichzeitiger Aufrufe kann jeweils einen eigenen Pool öffnen und gemeinsam das Verbindungslimit der Datenbank sprengen. Die übliche Lösung ist ein externer Pooler (PgBouncer, RDS Proxy), der zwischen deinen Funktionen und der Datenbank sitzt und viele kurzlebige Clients auf eine kleine Zahl echter Datenbankverbindungen multiplext.

Fazit

Ein Connection Pool tauscht die Kosten, pro Anfrage eine Verbindung zu öffnen, gegen eine feste, wiederverwendete Menge bereits geöffneter Verbindungen – aber er ist nur so gut wie seine Dimensionierung und seine Disziplin beim Freigeben von Verbindungen. Dimensioniere ihn danach, was die Datenbank tatsächlich bedienen kann, gib immer in einem finally frei und halte Transaktionen an einer einzigen ausdrücklichen Verbindung fest.

NeuerEin praktischer Leitfaden zur API-PaginationÄlterDeine erste GitHub Action schreiben