Zum Inhalt springen

Verwaltung von Datenbankverbindungen in der Produktion

So konfigurierst und überwachst du Datenbank-Connection-Pools: Dimensionierung, Leak-Erkennung, Failover-Handling und typische Fehlkonfigurationen.

5 Min. Lesezeit
Diagramm, das einen Connection-Pool zeigt, der mehrere Datenbankverbindungen zwischen einer Anwendung und einem Datenbankserver verwaltet

Jeder Produktionsausfall einer Datenbank, den ich untersucht habe, begann auf dieselbe Weise. Die Anwendung öffnet mehr Verbindungen, als die Datenbank erlaubt, neue Anfragen stauen sich in der Warteschlange, Timeouts kaskadieren, und das gesamte System blockiert. Die Lösung ist immer dieselbe: eine saubere Konfiguration des Connection-Pools.

Connection-Pools halten einen Satz wiederverwendbarer Datenbankverbindungen vor. Statt für jede Abfrage eine Verbindung zu öffnen und zu schließen (teuer — TCP-Handshake, TLS-Negotiation, Authentifizierung), leiht sich die Anwendung eine Verbindung aus dem Pool und gibt sie nach Abschluss der Abfrage zurück.

Pool-Grundlagen

Ein Connection-Pool hat drei kritische Parameter: Mindestgröße, Maximalgröße und Idle-Timeout.

tstypescript
// ❌ No pool — new connection per query
import { Client } from 'pg';
 
async function getUser(id: string) {
  const client = new Client({ connectionString: DATABASE_URL });
  await client.connect();     // TCP + TLS + auth = ~50ms
  const result = await client.query('SELECT * FROM users WHERE id = $1', [id]);
  await client.end();          // Connection discarded
  return result.rows[0];
}
// 1000 requests/sec = 1000 connection setups/sec
// Database refuses connections after hitting max_connections
tstypescript
// ✅ Connection pool — reuse existing connections
import { Pool } from 'pg';
 
const pool = new Pool({
  connectionString: DATABASE_URL,
  min: 5,          // Keep 5 connections warm at all times
  max: 20,         // Never exceed 20 connections
  idleTimeoutMillis: 30000,       // Close idle connections after 30s
  connectionTimeoutMillis: 5000,  // Fail if no connection available in 5s
});
 
async function getUser(id: string) {
  const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
  return result.rows[0];
  // Connection automatically returned to pool
}
// 1000 requests/sec = 20 connections handling all queries

Der Pool eliminiert den Verbindungs-Overhead pro Request. Eine warme Verbindung beantwortet eine einfache Abfrage in 1-5 ms. Der Aufbau einer neuen Verbindung kostet 30-100 ms zusätzlich.

Den Pool richtig dimensionieren

Der häufigste Fehler ist ein zu hohes max. Mehr Verbindungen bedeuten nicht mehr Durchsatz. Die Performance von PostgreSQL verschlechtert sich, wenn max_connections über dem liegt, was die Hardware verkraftet.

shbash
# PostgreSQL rule of thumb for max connections:
# max_connections = (CPU cores * 2) + effective_spindle_count
# For a 4-core server with SSD:
# max_connections = (4 * 2) + 1 = 9
 
# But you have 3 application instances, each with a pool:
# Total connections = instances * pool_max
# 3 * 20 = 60 connections — way too many for a 4-core database
 
# Better: 3 * 3 = 9 connections total
# Each instance: max: 3
tstypescript
// Pool configuration based on infrastructure
function calculatePoolSize(config: {
  dbCpuCores: number;
  appInstances: number;
  headroom: number;  // connections for admin, migrations, monitoring
}): { min: number; max: number } {
  const totalConnections = (config.dbCpuCores * 2) + 1;
  const perInstance = Math.floor(
    (totalConnections - config.headroom) / config.appInstances
  );
 
  return {
    min: Math.max(1, Math.floor(perInstance / 2)),
    max: Math.max(2, perInstance),
  };
}
 
// Example: 4-core DB, 3 app instances, 3 reserved for admin
// Total: 9 connections, reserved: 3, per instance: 2
// Result: { min: 1, max: 2 }

Ein Pool von 2-5 Verbindungen pro Instanz bewältigt Tausende Requests pro Sekunde. Die Verbindungen werden wiederverwendet — eine Abfrage dauert 5 ms, also schafft eine Verbindung 200 Abfragen pro Sekunde.

Erkennung von Connection-Leaks

Ein Connection-Leak entsteht, wenn Code eine Verbindung aus dem Pool entnimmt, aber nie zurückgibt. Irgendwann ist der Pool leer, und neue Anfragen blockieren oder schlagen fehl.

tstypescript
// ❌ Connection leak — client never released on error
async function transferFunds(from: string, to: string, amount: number) {
  const client = await pool.connect();
  await client.query('BEGIN');
  await client.query(
    'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
    [amount, from]
  );
  // If this throws, client is never released!
  await client.query(
    'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
    [amount, to]
  );
  await client.query('COMMIT');
  client.release();
}
 
// ✅ Always release in a finally block
async function transferFunds(from: string, to: string, amount: number) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query(
      'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
      [amount, from]
    );
    await client.query(
      'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
      [amount, to]
    );
    await client.query('COMMIT');
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();  // Always runs, even on error
  }
}
tstypescript
// Even better: helper that guarantees release
async function withTransaction<T>(
  fn: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const result = await fn(client);
    await client.query('COMMIT');
    return result;
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}
 
// Usage — impossible to leak
await withTransaction(async (client) => {
  await client.query(
    'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
    [amount, from]
  );
  await client.query(
    'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
    [amount, to]
  );
});

Pool-Gesundheit überwachen

Pool-Metriken zeigen Probleme, bevor sie zu Ausfällen werden. Überwache diese Werte:

tstypescript
// Expose pool metrics for Prometheus
import { Gauge } from 'prom-client';
 
const poolTotal = new Gauge({
  name: 'db_pool_total_connections',
  help: 'Total connections in the pool',
});
 
const poolIdle = new Gauge({
  name: 'db_pool_idle_connections',
  help: 'Idle connections in the pool',
});
 
const poolWaiting = new Gauge({
  name: 'db_pool_waiting_requests',
  help: 'Requests waiting for a connection',
});
 
setInterval(() => {
  poolTotal.set(pool.totalCount);
  poolIdle.set(pool.idleCount);
  poolWaiting.set(pool.waitingCount);
}, 5000);
ymlyaml
# Alert rules for pool health
groups:
  - name: database-pool
    rules:
      # Connection leak: total stays at max, idle is zero
      - alert: ConnectionPoolExhausted
        expr: db_pool_idle_connections == 0 and db_pool_waiting_requests > 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Database connection pool exhausted"
 
      # Leak pattern: total grows but idle doesn't
      - alert: PossibleConnectionLeak
        expr: db_pool_total_connections == db_pool_max and db_pool_idle_connections < 2
        for: 5m
        labels:
          severity: warning

Wenn waitingCount dauerhaft größer als null ist, ist der Pool unterdimensioniert oder Verbindungen lecken. Wenn idleCount die meiste Zeit gleich totalCount ist, ist der Pool überdimensioniert.

Timeout-Strategie für Verbindungen

Drei verschiedene Timeouts schützen vor verschiedenen Fehlerarten:

tstypescript
const pool = new Pool({
  connectionString: DATABASE_URL,
  max: 10,
 
  // 1. Connection acquire timeout
  // How long to wait for a connection from the pool
  connectionTimeoutMillis: 5000,
  // If all connections are busy and pool is at max, wait 5s then fail
  // Without this: requests queue indefinitely → memory exhaustion
 
  // 2. Query timeout (via statement_timeout)
  // How long a single query can run
  // Set per-connection via pool event
  // Without this: a bad query locks a connection forever
 
  // 3. Idle timeout
  // How long an idle connection stays in the pool
  idleTimeoutMillis: 30000,
  // Closes connections that haven't been used in 30s
  // Prevents "connection stale" errors from firewall/load balancer timeouts
});
 
// Set query timeout on each new connection
pool.on('connect', (client) => {
  client.query('SET statement_timeout = 10000');  // 10 second query limit
});
 
// Log connection errors
pool.on('error', (err) => {
  console.error('Unexpected pool error:', err.message);
});
tstypescript
// Application-level timeout for complete operations
async function getUserWithTimeout(id: string): Promise<User | null> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 3000);
 
  try {
    const result = await pool.query(
      'SELECT * FROM users WHERE id = $1',
      [id]
    );
    return result.rows[0] || null;
  } finally {
    clearTimeout(timeout);
  }
}

Connection-Pooling mit PgBouncer

Für Umgebungen mit vielen Verbindungen (viele Anwendungsinstanzen, serverlose Funktionen) setzt du PgBouncer zwischen Anwendung und PostgreSQL. PgBouncer hält weniger echte Datenbankverbindungen, während es viele Client-Verbindungen bedient.

iniini
; pgbouncer.ini
[databases]
mydb = host=postgres port=5432 dbname=mydb
 
[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
 
; Transaction pooling: connection returned after each transaction
pool_mode = transaction
 
; Maximum client connections PgBouncer accepts
max_client_conn = 1000
 
; Maximum server connections to PostgreSQL
default_pool_size = 20
 
; Reserve connections for admin
reserve_pool_size = 5
reserve_pool_timeout = 3
 
; Connection lifetime
server_idle_timeout = 600
server_lifetime = 3600
 
; Logging
log_connections = 1
log_disconnections = 1
stats_period = 60
Without PgBouncer:
[App 1: 20 conn] ──┐
[App 2: 20 conn] ──┼── PostgreSQL (max: 100)
[App 3: 20 conn] ──┤
[Serverless: ???] ──┘   60+ connections used

With PgBouncer:
[App 1: 20 conn] ──┐                       ┌── PostgreSQL (max: 100)
[App 2: 20 conn] ──┼── PgBouncer (20 pool) ┤
[App 3: 20 conn] ──┤                       └── Only 20 actual connections
[Serverless: 500] ──┘   560 client conns, 20 server conns

PgBouncer im Modus transaction multiplext Hunderte von Client-Verbindungen über einen kleinen Pool von Server-Verbindungen. Das ist in serverlosen Umgebungen unverzichtbar, wo jeder Funktionsaufruf eine eigene Verbindung öffnet.

Die wichtigsten Erkenntnisse

  1. Nutze immer einen Connection-Pool — Verbindungen pro Request halten der Produktionslast nicht stand
  2. Dimensioniere Pools anhand der CPU-Kerne der Datenbank — (cores * 2) + 1 auf die Instanzen verteilt, nicht überall max: 100
  3. Nutze withTransaction-Helper — garantiere das Freigeben von Verbindungen mit try/finally-Mustern
  4. Überwache waitingCount — wenn Requests auf Verbindungen warten, ist der Pool erschöpft
  5. Setze drei Timeouts — Acquire-, Query- und Idle-Timeout schützen vor unterschiedlichen Fehlern
  6. Nutze PgBouncer in Umgebungen mit vielen Verbindungen — multiplexe Hunderte von Client-Verbindungen über einen kleinen Server-Pool
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX