Zum Inhalt springen

Connection Pooling für Node.js-Dienste mit hohem Durchsatz

Ein tiefer Blick auf Connection-Pooling in Node.js: Pool-Dimensionierung, Health Checks, Failover-Muster und Fehlkonfigurationen mit Ausfallfolge.

6 Min. Lesezeit
Diagramm eines Connection-Pools, der Datenbankverbindungen auf Worker-Threads verteilt

Warum jede Node.js-App irgendwann an die Verbindungsgrenze stößt

Deine Anwendung läuft in der Entwicklung mit fünf gleichzeitigen Nutzern problemlos. Im Staging mit fünfzig werden die Abfragen langsam. In der Produktion mit fünfhundert beginnt die Datenbank, Verbindungen abzulehnen. Die Fehlermeldung ist immer eine Variante von "too many connections" oder "connection pool exhausted".

Das liegt daran, dass Datenbankverbindungen teuer sind. Jede PostgreSQL-Verbindung verbraucht etwa 10MB Arbeitsspeicher, erfordert einen TCP-Handshake plus TLS-Aushandlung und löst die Erstellung eines Prozesses auf dem Server aus. Ohne Pooling öffnet jede Abfrage eine neue Verbindung, nutzt sie einmal und verwirft sie. Unter Last bricht dieses Muster katastrophal zusammen.

Connection Pooling löst das Problem, indem es einen Satz persistenter Verbindungen vorhält, die sich die Abfragen teilen. Aber ein Pool mit falschen Einstellungen ist manchmal schlimmer als gar kein Pool. Dieser Leitfaden behandelt die Mechanik.

Pool-Dimensionierung: Die Mathematik, die die meisten Teams überspringen

Der größte Fehler ist, die Pool-Größe nach Bauchgefühl festzulegen. "Nehmen wir 20 Verbindungen" ist keine Strategie, sondern eine Vermutung. Die Pool-Größe hängt von deinen Abfragemustern, der Kapazität deines Datenbankservers und der Anzahl der Anwendungsinstanzen ab, die sich diese Datenbank teilen.

tstypescript
// ❌ Bad: Arbitrary pool size with no relationship to workload
import { Pool } from "pg";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 100, // Way too many — most databases can't handle this
});
tstypescript
// ✅ Good: Calculated pool size based on actual constraints
import { Pool, PoolConfig } from "pg";
 
function calculatePoolSize(): number {
  const maxDbConnections = 100; // PostgreSQL max_connections setting
  const reservedConnections = 5; // For admin, migrations, monitoring
  const appInstances = parseInt(process.env.APP_INSTANCES || "4", 10);
  const availableConnections = maxDbConnections - reservedConnections;
 
  return Math.floor(availableConnections / appInstances);
}
 
const poolConfig: PoolConfig = {
  connectionString: process.env.DATABASE_URL,
  max: calculatePoolSize(),
  min: 2,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
  allowExitOnIdle: false,
};
 
const pool = new Pool(poolConfig);

Die Formel ist einfach: Ziehe die reservierten Verbindungen von max_connections ab und teile durch die Anzahl der Anwendungsinstanzen. Erlaubt deine Datenbank 100 Verbindungen, reservierst du 5. Vier Anwendungsinstanzen erhalten jeweils einen Pool von 23. Wer höher geht, riskiert, dass eine Instanz die anderen aushungert.

Bei CPU-gebundenen Datenbanken liegt die optimale Zahl aktiver Verbindungen bei etwa (2 × CPU-Kerne) + Festplattenspindeln. Ein Datenbankserver mit 4 Kernen und SSDs arbeitet mit rund 10 aktiven Verbindungen am besten — unabhängig davon, wie viele dein Pool bereithält.

Health Checks und Verbindungsvalidierung

Ein Pool voller toter Verbindungen ist schlimmer als ein leerer Pool. Verbindungen sterben still: Netzwerkstörungen, Datenbank-Neustarts und Load-Balancer-Timeouts trennen Verbindungen, ohne den Client zu benachrichtigen. Ohne Validierung schlägt deine nächste Abfrage auf einer defekten Verbindung fehl.

tstypescript
import { Pool, PoolClient } from "pg";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});
 
// Validate connections before use
async function getValidClient(): Promise<PoolClient> {
  const client = await pool.connect();
 
  try {
    await client.query("SELECT 1");
    return client;
  } catch {
    client.release(true); // true = destroy this connection
    throw new Error("Database connection validation failed");
  }
}
 
// Connection pool event monitoring
pool.on("error", (err: Error) => {
  console.error("Unexpected pool error:", err.message);
});
 
pool.on("connect", () => {
  console.debug("New connection established");
});
 
pool.on("remove", () => {
  console.debug("Connection removed from pool");
});
 
// Periodic health check
async function healthCheck(): Promise<{ healthy: boolean; stats: object }> {
  try {
    const start = Date.now();
    await pool.query("SELECT 1");
    const latency = Date.now() - start;
 
    return {
      healthy: true,
      stats: {
        totalConnections: pool.totalCount,
        idleConnections: pool.idleCount,
        waitingRequests: pool.waitingCount,
        latencyMs: latency,
      },
    };
  } catch (err) {
    return {
      healthy: false,
      stats: { error: (err as Error).message },
    };
  }
}

Die Metrik pool.waitingCount ist dein Frühwarnsignal. Wenn sich Abfragen in einer Warteschlange auf Verbindungen stauen, ist dein Pool für die aktuelle Last zu klein. Überwache das in der Produktion und alarmiere, sobald der Wert länger als ein paar Sekunden über null bleibt.

Abfragemuster, die Connection Pools töten

Manche Codemuster halten Verbindungen weit länger als nötig und verkleinern deinen Pool faktisch. Lang laufende Transaktionen, vergessene Releases und N+1-Abfragen sind die üblichen Verdächtigen.

tstypescript
// ❌ Bad: Holding a connection during external API call
async function processOrder(orderId: string): Promise<void> {
  const client = await pool.connect();
  try {
    const order = await client.query(
      "SELECT * FROM orders WHERE id = $1",
      [orderId]
    );
 
    // This HTTP call takes 2-5 seconds — connection is held the entire time!
    const shippingRate = await fetch(
      `https://shipping-api.example.com/rates?weight=${order.rows[0].weight}`
    ).then((r) => r.json());
 
    await client.query(
      "UPDATE orders SET shipping_rate = $1 WHERE id = $2",
      [shippingRate.rate, orderId]
    );
  } finally {
    client.release();
  }
}
tstypescript
// ✅ Good: Release connection before external calls
async function processOrder(orderId: string): Promise<void> {
  const order = await pool.query(
    "SELECT * FROM orders WHERE id = $1",
    [orderId]
  );
 
  // Connection already returned to pool
  const shippingRate = await fetch(
    `https://shipping-api.example.com/rates?weight=${order.rows[0].weight}`
  ).then((r) => r.json());
 
  await pool.query(
    "UPDATE orders SET shipping_rate = $1 WHERE id = $2",
    [shippingRate.rate, orderId]
  );
}

Verwende pool.query() für einzelne Anweisungen — es bezieht und gibt eine Verbindung automatisch frei. Nutze pool.connect() nur, wenn du Transaktionssemantik mit BEGIN/COMMIT über mehrere Anweisungen hinweg brauchst.

Transaktionsmanagement und Verbindungslebensdauer

Transaktionen benötigen für ihre gesamte Dauer eine einzige Verbindung. Hier wird der Druck auf den Connection Pool akut: Jede offene Transaktion blockiert eine Verbindung aus dem Pool.

tstypescript
type TransactionCallback<T> = (client: PoolClient) => Promise<T>;
 
async function withTransaction<T>(
  callback: TransactionCallback<T>
): Promise<T> {
  const client = await pool.connect();
 
  try {
    await client.query("BEGIN");
    const result = await callback(client);
    await client.query("COMMIT");
    return result;
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}
 
// Usage: Transaction holds connection only for DB operations
async function transferFunds(
  fromId: string,
  toId: string,
  amount: number
): Promise<void> {
  await withTransaction(async (client) => {
    const from = await client.query(
      "SELECT balance FROM accounts WHERE id = $1 FOR UPDATE",
      [fromId]
    );
 
    if (from.rows[0].balance < amount) {
      throw new Error("Insufficient funds");
    }
 
    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]
    );
  });
}

Der withTransaction-Wrapper garantiert, dass Verbindungen immer freigegeben werden und Transaktionen immer entweder mit COMMIT oder mit ROLLBACK enden. Ohne dieses Muster kann eine mitten in einer Transaktion geworfene Exception sowohl die Transaktion offen als auch die Verbindung geleakt zurücklassen.

Externe Connection Pooler: PgBouncer und darüber hinaus

Bei Anwendungen mit Skalierung sitzt ein externer Connection Pooler zwischen deiner Anwendung und der Datenbank. PgBouncer ist der gängigste für PostgreSQL. Er multiplext Hunderte von Anwendungsverbindungen auf eine Handvoll echter Datenbankverbindungen.

iniini
; pgbouncer.ini — transaction-level pooling
[databases]
myapp = host=db.internal port=5432 dbname=myapp
 
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
 
; Transaction pooling: connection returned after each transaction
pool_mode = transaction
 
; Pool sizing
default_pool_size = 20
max_client_conn = 1000
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
 
; Timeouts
server_idle_timeout = 300
client_idle_timeout = 600
query_timeout = 30
tstypescript
// Application connects to PgBouncer, not directly to PostgreSQL
const pool = new Pool({
  host: "pgbouncer.internal",
  port: 6432,
  database: "myapp",
  max: 50, // Can be higher since PgBouncer manages the real connections
  // IMPORTANT: Disable prepared statements with transaction pooling
  statement_timeout: 30000,
});
 
// PgBouncer transaction pooling breaks prepared statements
// Use this wrapper to disable them
async function query(text: string, values?: unknown[]) {
  return pool.query({ text, values, rowMode: "array" });
}

Transaktionsbasiertes Pooling in PgBouncer bedeutet, dass eine echte Datenbankverbindung nur für die Dauer einer Transaktion zugewiesen wird. Zwischen den Transaktionen steht die Verbindung anderen Clients zur Verfügung. So können 1000 Anwendungsverbindungen sich 20 Datenbankverbindungen teilen.

Der kritische Haken: Transaktions-Pooling bricht PostgreSQL-Funktionen, die vom Sitzungszustand abhängen — Prepared Statements, SET-Befehle, LISTEN/NOTIFY und Advisory Locks. Wenn du diese brauchst, nutze Session Pooling oder behandle sie auf Anwendungsebene.

Monitoring und Alerting für Connection Pools

Probleme mit dem Connection Pool zeigen sich als Latenzspitzen, nicht als Fehler — bis der Pool vollständig erschöpft ist. Proaktives Monitoring erkennt Probleme, bevor Nutzer sie bemerken.

tstypescript
import { Pool } from "pg";
 
interface PoolMetrics {
  totalConnections: number;
  activeConnections: number;
  idleConnections: number;
  waitingClients: number;
  maxConnections: number;
  utilizationPercent: number;
}
 
function collectPoolMetrics(pool: Pool): PoolMetrics {
  const total = pool.totalCount;
  const idle = pool.idleCount;
  const waiting = pool.waitingCount;
  const max = (pool as unknown as { options: { max: number } }).options.max;
 
  return {
    totalConnections: total,
    activeConnections: total - idle,
    idleConnections: idle,
    waitingClients: waiting,
    maxConnections: max,
    utilizationPercent: ((total - idle) / max) * 100,
  };
}
 
// Alert thresholds
function evaluatePoolHealth(metrics: PoolMetrics): string[] {
  const alerts: string[] = [];
 
  if (metrics.utilizationPercent > 80) {
    alerts.push("Pool utilization above 80% — consider scaling");
  }
 
  if (metrics.waitingClients > 0) {
    alerts.push(`${metrics.waitingClients} queries waiting for connections`);
  }
 
  if (metrics.idleConnections === 0 && metrics.totalConnections === metrics.maxConnections) {
    alerts.push("Pool fully saturated — all connections in use");
  }
 
  return alerts;
}

Die drei Metriken, die zählen: Auslastung in Prozent, Anzahl wartender Clients und die Zeit zum Beziehen einer Verbindung. Überschreitet die Auslastung dauerhaft 70%, brauchst du mehr Verbindungen oder weniger Anwendungsinstanzen, die sich den Pool teilen. Warten Clients, verschlechterst du bereits die Nutzererfahrung.

Die wichtigsten Erkenntnisse

Connection Pooling ist eines jener Infrastrukturthemen, die unsichtbar sind, bis sie ausfallen. Die Pool-Größe ist keine magische Zahl — sie ergibt sich aus Datenbankkapazität, Instanzenzahl und Abfragemustern. Validiere Verbindungen vor der Nutzung, gib sie sofort danach wieder frei und halte niemals eine Verbindung, während du auf externe Dienste wartest.

Externe Pooler wie PgBouncer ermöglichen massives Verbindungs-Multiplexing, bringen aber Kompatibilitätseinschränkungen mit. Verstehe, was unter Transaktions-Pooling kaputtgeht, bevor du es ausrollst.

Der verlässlichste Indikator für den Zustand des Pools ist die Anzahl wartender Clients. Wenn sich Abfragen auf Verbindungen stauen, verschlechtert sich bereits alles dahinter — Antwortzeiten, Durchsatz, Nutzererfahrung. Überwache es, richte Alerts darauf ein und nimm es so ernst wie CPU- oder Speicher-Alerts.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX