Zum Inhalt springen

Connection Pooling für Anwendungen mit hohem Durchsatz

Konfiguriere Datenbank-Connection-Pools für Node.js mit hohem Durchsatz: Pool-Größe, Health Checks, Lifecycle und was einen Pool erschöpft.

5 Min. Lesezeit
Diagramm, das mehrere Anwendungsinstanzen zeigt, die einen Connection-Pool teilen, der Verbindungen zu einem Datenbank-Cluster verwaltet

Jede Datenbankabfrage benötigt eine Verbindung. Für jede Abfrage eine neue TCP-Verbindung aufzubauen bedeutet einen TLS-Handshake, einen Authentifizierungs-Roundtrip und Protokollverhandlung – locker 50–100 ms Overhead, bevor das erste Datenbyte ankommt. Bei 1.000 Abfragen pro Sekunde sättigt allein dieser Overhead deine Anwendung.

Connection Pooling löst das Problem, indem es einen Satz vorab aufgebauter Verbindungen bereithält, die Abfragen ausleihen und zurückgeben. Aber ein falsch konfigurierter Pool ist schlimmer als gar kein Pool: Zu klein lässt die Anwendung verhungern, zu groß überlastet die Datenbank, und geleakte Verbindungen degradieren das System still, bis es zusammenbricht.

Warum Connection Pooling wichtig ist

Ohne Pooling erstellt und zerstört jede Anfrage eine Datenbankverbindung. Unter Last summiert sich der Overhead schnell.

tstypescript
// ❌ New connection per query
import { Client } from "pg";
 
async function getUser(id: string) {
  const client = new Client({
    connectionString: process.env.DATABASE_URL,
  });
  await client.connect(); // ~50-100ms overhead
  const result = await client.query(
    "SELECT * FROM users WHERE id = $1",
    [id]
  );
  await client.end(); // Connection destroyed
  return result.rows[0];
}
// At 500 req/s: 500 connections created and
// destroyed per second, each with handshake overhead
tstypescript
// ✅ Shared connection pool
import { Pool } from "pg";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});
 
async function getUser(id: string) {
  const result = await pool.query(
    "SELECT * FROM users WHERE id = $1",
    [id]
  );
  return result.rows[0];
  // Connection returned to pool automatically
}
// At 500 req/s: 20 connections handle all traffic,
// pre-established with zero per-request overhead

Pool-Größe: die kritische Konfiguration

Der häufigste Fehler ist es, die Pool-Größe auf die Anzahl der Anwendungsinstanzen oder CPUs festzulegen. Die Pool-Größe sollte sich an der Kapazität der Datenbank und den Eigenschaften der Abfragen orientieren.

tstypescript
// Pool sizing formula:
// optimal_pool_size = (core_count * 2) + disk_spindles
// For SSDs: roughly core_count * 2 + 1
//
// PostgreSQL with 4 cores:
// (4 * 2) + 1 = 9 connections per database
// Not per application instance — TOTAL across
// all instances
 
interface PoolConfig {
  max: number;
  min: number;
  idleTimeoutMillis: number;
  connectionTimeoutMillis: number;
  maxUses: number;
  allowExitOnIdle: boolean;
}
 
function createOptimizedPool(
  instanceCount: number,
  dbCores: number
): PoolConfig {
  // Total optimal connections for this database
  const totalOptimal = dbCores * 2 + 1;
 
  // Divide across application instances
  const perInstance = Math.max(
    2,
    Math.floor(totalOptimal / instanceCount)
  );
 
  return {
    max: perInstance,
    min: Math.max(1, Math.floor(perInstance / 4)),
    idleTimeoutMillis: 30_000,
    connectionTimeoutMillis: 5_000,
    // Recycle connections after N uses to prevent
    // memory leaks in long-running connections
    maxUses: 7500,
    allowExitOnIdle: true,
  };
}
 
// 3 app instances, database with 4 cores
// Total optimal: 9, per instance: 3
const config = createOptimizedPool(3, 4);
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ...config,
});

Health Checks für Verbindungen

Abgestorbene Verbindungen im Pool verursachen stille Fehler. Die Datenbank könnte neu gestartet worden sein, eine Netzwerkpartition aufgetreten sein oder die Verbindung ein serverseitiges Timeout erreicht haben.

tstypescript
import { Pool, PoolClient } from "pg";
 
class ResilientPool {
  private pool: Pool;
 
  constructor(connectionString: string, max: number) {
    this.pool = new Pool({
      connectionString,
      max,
      idleTimeoutMillis: 30_000,
      connectionTimeoutMillis: 5_000,
    });
 
    // Handle pool-level errors
    this.pool.on("error", (err: Error) => {
      console.error(
        "Unexpected pool error:",
        err.message
      );
    });
 
    // Log when connections are created/removed
    this.pool.on("connect", () => {
      console.debug(
        `Pool connection created. ` +
          `Total: ${this.pool.totalCount}, ` +
          `Idle: ${this.pool.idleCount}`
      );
    });
 
    this.pool.on("remove", () => {
      console.debug(
        `Pool connection removed. ` +
          `Total: ${this.pool.totalCount}`
      );
    });
  }
 
  async getConnection(): Promise<PoolClient> {
    const client = await this.pool.connect();
 
    // Validate connection before returning
    try {
      await client.query("SELECT 1");
    } catch {
      client.release(true); // Destroy bad connection
      // Pool will create a fresh replacement
      return this.pool.connect();
    }
 
    return client;
  }
 
  async query<T>(
    text: string,
    params?: unknown[]
  ): Promise<T[]> {
    const client = await this.getConnection();
    try {
      const result = await client.query(text, params);
      return result.rows;
    } finally {
      client.release();
    }
  }
 
  getStats() {
    return {
      total: this.pool.totalCount,
      idle: this.pool.idleCount,
      waiting: this.pool.waitingCount,
    };
  }
 
  async shutdown(): Promise<void> {
    await this.pool.end();
  }
}

Transaktionsbewusste Pool-Nutzung

Das gefährlichste Pool-Muster ist das Leaken von Verbindungen innerhalb von Transaktionen. Ein vergessenes release() in einem Fehlerpfad entfernt eine Verbindung dauerhaft aus dem Pool.

tstypescript
// ❌ Connection leak 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();
}
tstypescript
// ✅ Safe transaction wrapper
async function withTransaction<T>(
  pool: Pool,
  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(); // Always releases
  }
}
 
// Usage — impossible to leak
async function transferFunds(
  from: string,
  to: string,
  amount: number
) {
  return withTransaction(pool, async (client) => {
    const { rows } = await client.query(
      "SELECT balance FROM accounts WHERE id = $1 FOR UPDATE",
      [from]
    );
 
    if (rows[0].balance < amount) {
      throw new Error("Insufficient funds");
      // ROLLBACK and release happen automatically
    }
 
    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]
    );
 
    return { from, to, amount, status: "completed" };
  });
}

Überwachung der Pool-Gesundheit

Pool-Erschöpfung führt nicht sofort zum Absturz – sie degradiert schrittweise, während sich Anfragen auf eine Verbindung wartend in der Warteschlange stauen. Überwache die Pool-Metriken, um Probleme zu erkennen, bevor sie eskalieren.

tstypescript
import { Pool } from "pg";
 
class MonitoredPool {
  private pool: Pool;
  private metrics = {
    queriesExecuted: 0,
    queryErrors: 0,
    connectionWaits: 0,
    maxWaitingObserved: 0,
  };
 
  constructor(config: Record<string, unknown>) {
    this.pool = new Pool(config);
    this.startMetricsCollection();
  }
 
  private startMetricsCollection() {
    setInterval(() => {
      const stats = {
        total: this.pool.totalCount,
        idle: this.pool.idleCount,
        waiting: this.pool.waitingCount,
        ...this.metrics,
      };
 
      // Track high-water mark
      if (
        stats.waiting > this.metrics.maxWaitingObserved
      ) {
        this.metrics.maxWaitingObserved = stats.waiting;
      }
 
      // Alert on pool pressure
      if (stats.waiting > 0) {
        this.metrics.connectionWaits++;
        console.warn(
          `⚠️ Pool pressure: ` +
            `${stats.waiting} queries waiting, ` +
            `${stats.idle}/${stats.total} idle`
        );
      }
 
      if (stats.idle === 0 && stats.waiting > 5) {
        console.error(
          `🚨 Pool exhaustion risk: ` +
            `0 idle connections, ` +
            `${stats.waiting} waiting`
        );
      }
    }, 5000);
  }
 
  async query<T>(
    text: string,
    params?: unknown[]
  ): Promise<T[]> {
    const start = performance.now();
 
    try {
      const result = await this.pool.query(
        text,
        params
      );
      this.metrics.queriesExecuted++;
 
      const duration = performance.now() - start;
      if (duration > 1000) {
        console.warn(
          `Slow query (${Math.round(duration)}ms): ` +
            `${text.slice(0, 100)}`
        );
      }
 
      return result.rows;
    } catch (error) {
      this.metrics.queryErrors++;
      throw error;
    }
  }
 
  getHealthStatus(): {
    healthy: boolean;
    details: string;
  } {
    const waiting = this.pool.waitingCount;
    const total = this.pool.totalCount;
    const idle = this.pool.idleCount;
 
    if (waiting > total) {
      return {
        healthy: false,
        details:
          `Pool exhausted: ${waiting} waiting, ` +
          `${idle}/${total} idle`,
      };
    }
 
    return {
      healthy: true,
      details:
        `${idle}/${total} idle, ` +
        `${waiting} waiting`,
    };
  }
}

Die wichtigsten Erkenntnisse

Die Pool-Größe sollte aus der Kapazität der Datenbank (cores * 2 + effective_spindles) berechnet und auf die Anwendungsinstanzen aufgeteilt werden – zu hoch überlastet die Datenbank, zu niedrig lässt die Anwendung verhungern, und die Gesamtzahl über alle Instanzen hinweg zählt mehr als die Zahl einer einzelnen Instanz. Verwende immer ein Transaktions-Wrapper-Muster mit try/catch/finally, das garantiert, dass client.release() unabhängig von Erfolg oder Fehlschlag ausgeführt wird, denn eine einzige geleakte Verbindung in einem Fehlerpfad reduziert die Pool-Kapazität still, bis die Anwendung unter Last degradiert. Überwache die Pool-Metriken kontinuierlich: Erfasse waitingCount, idleCount und totalCount als Zeitreihen und alarmiere, sobald wartende Abfragen null überschreiten, denn Pool-Druck ist ein Frühindikator für Kapazitätsprobleme, die sich zu Timeouts hochschaukeln. Validiere Verbindungen in kritischen Pfaden vor der Nutzung – Verbindungen können durch Datenbank-Neustarts, Netzwerkpartitionen oder serverseitige Idle-Timeouts absterben – und gib ungültige Verbindungen mit release(true) frei, damit der Pool sie zerstört und frische Ersatzverbindungen aufbaut.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX