Saltar al contenido

Pooling de conexiones: ajuste del rendimiento bajo carga

Análisis a fondo de la configuración del pool: fórmulas de dimensionado, ciclo de vida, comprobaciones de salud y diagnóstico del agotamiento en producción.

4 min de lectura
Diagrama de un pool de conexiones que muestra conexiones inactivas, activas y en espera con indicadores de profundidad de cola

Por qué existen los pools de conexiones

Abrir una conexión de base de datos es costoso: el handshake TCP, la negociación TLS, la autenticación y la inicialización de la sesión añaden entre 50 y 200 ms por conexión. Sin un pool, cada consulta a la base de datos paga ese costo. Un pool de conexiones mantiene un conjunto de conexiones preestablecidas, las presta al código de la aplicación bajo demanda y las recupera cuando terminan.

El pool parece sencillo hasta que el tráfico de producción expone sus fallas de configuración: muy pocas conexiones provocan colas de solicitudes, demasiadas saturan el servidor de base de datos, y las conexiones fugadas vacían silenciosamente el pool hasta que la aplicación se congela.

Dimensionamiento del pool: la fórmula que realmente funciona

El tamaño óptimo del pool no es "el mayor posible". La documentación de PostgreSQL y sus benchmarks internos muestran de forma consistente que un pool pequeño con solicitudes en cola supera a un pool grande con alta concurrencia. La fórmula del wiki de PostgreSQL parte de los núcleos de CPU.

tstypescript
interface PoolConfig {
  minimumIdle: number;
  maximumPoolSize: number;
  connectionTimeout: number;    // ms to wait for a connection
  idleTimeout: number;          // ms before idle connections close
  maxLifetime: number;          // ms before connections are recycled
  validationQuery: string;
}
 
// ❌ "More connections = faster" — overwhelms the database
const naiveConfig: PoolConfig = {
  minimumIdle: 50,
  maximumPoolSize: 200,
  connectionTimeout: 30000,
  idleTimeout: 600000,
  maxLifetime: 1800000,
  validationQuery: "SELECT 1",
};
 
// ✅ Sized based on database server capacity
function calculatePoolSize(
  dbCpuCores: number,
  effectiveSpindleCount: number = 1  // 1 for SSD
): number {
  // PostgreSQL recommended formula
  // connections = (cores * 2) + effective_spindle_count
  return (dbCpuCores * 2) + effectiveSpindleCount;
}
 
function buildPoolConfig(dbCpuCores: number): PoolConfig {
  const poolSize = calculatePoolSize(dbCpuCores);
 
  return {
    minimumIdle: Math.floor(poolSize / 2),
    maximumPoolSize: poolSize,
    connectionTimeout: 5000,       // Fail fast — 5 seconds
    idleTimeout: 300000,           // 5 minutes
    maxLifetime: 1800000,          // 30 minutes
    validationQuery: "SELECT 1",
  };
}
 
// Example: 4-core database server
// Pool size = (4 * 2) + 1 = 9 connections
// This handles far more concurrent requests than you expect

Gestión del ciclo de vida de las conexiones

Las conexiones se degradan con el tiempo: fugas de memoria en los drivers, sesiones obsoletas, timeouts de firewall que cierran sockets TCP inactivos. Un pool bien configurado recicla las conexiones de forma proactiva antes de que fallen.

tstypescript
class ManagedConnectionPool {
  private connections: PooledConnection[] = [];
  private waitQueue: Array<{
    resolve: (conn: PooledConnection) => void;
    reject: (err: Error) => void;
    enqueuedAt: number;
  }> = [];
 
  constructor(private readonly config: PoolConfig) {}
 
  async acquire(): Promise<PooledConnection> {
    // Try to find a healthy idle connection
    const idle = this.connections.find(
      (c) => c.state === "idle" && this.isHealthy(c)
    );
 
    if (idle) {
      idle.state = "active";
      idle.lastUsed = Date.now();
      return idle;
    }
 
    // Create new if under maximum
    if (this.connections.length < this.config.maximumPoolSize) {
      return this.createConnection();
    }
 
    // Queue the request with timeout
    return new Promise((resolve, reject) => {
      const entry = { resolve, reject, enqueuedAt: Date.now() };
      this.waitQueue.push(entry);
 
      setTimeout(() => {
        const idx = this.waitQueue.indexOf(entry);
        if (idx >= 0) {
          this.waitQueue.splice(idx, 1);
          reject(new Error(
            `Connection acquisition timeout after ${this.config.connectionTimeout}ms. ` +
            `Pool: ${this.getActiveCount()} active, ${this.getIdleCount()} idle, ` +
            `${this.waitQueue.length} waiting`
          ));
        }
      }, this.config.connectionTimeout);
    });
  }
 
  release(connection: PooledConnection): void {
    // Check if connection should be retired
    if (this.shouldRetire(connection)) {
      this.destroyConnection(connection);
      return;
    }
 
    // Serve from wait queue first
    if (this.waitQueue.length > 0) {
      const waiter = this.waitQueue.shift()!;
      connection.lastUsed = Date.now();
      waiter.resolve(connection);
      return;
    }
 
    connection.state = "idle";
  }
 
  private isHealthy(conn: PooledConnection): boolean {
    const age = Date.now() - conn.createdAt;
    const idle = Date.now() - conn.lastUsed;
 
    return (
      age < this.config.maxLifetime &&
      idle < this.config.idleTimeout &&
      !conn.hasError
    );
  }
 
  private shouldRetire(conn: PooledConnection): boolean {
    return (
      Date.now() - conn.createdAt > this.config.maxLifetime ||
      conn.hasError ||
      conn.queryCount > 10000
    );
  }
 
  private getActiveCount(): number {
    return this.connections.filter((c) => c.state === "active").length;
  }
 
  private getIdleCount(): number {
    return this.connections.filter((c) => c.state === "idle").length;
  }
 
  private async createConnection(): Promise<PooledConnection> {
    // Create and register new connection
    return {} as PooledConnection;
  }
 
  private destroyConnection(conn: PooledConnection): void {
    const idx = this.connections.indexOf(conn);
    if (idx >= 0) this.connections.splice(idx, 1);
  }
}

Detección de fugas de conexiones

Una conexión fugada —adquirida pero nunca liberada— es el modo de fallo más común de un pool. Ocurre cuando un error se lanza antes de la llamada a release, o cuando una ruta del código olvida devolver la conexión. Sin detección, el pool se vacía silenciosamente hasta que la aplicación se cuelga.

tstypescript
class LeakDetector {
  private activeConnections = new Map<string, {
    acquiredAt: number;
    stackTrace: string;
  }>();
 
  private readonly leakThresholdMs = 30000; // 30 seconds
 
  onAcquire(connectionId: string): void {
    this.activeConnections.set(connectionId, {
      acquiredAt: Date.now(),
      stackTrace: new Error().stack || "unknown",
    });
  }
 
  onRelease(connectionId: string): void {
    this.activeConnections.delete(connectionId);
  }
 
  checkForLeaks(): LeakReport[] {
    const now = Date.now();
    const leaks: LeakReport[] = [];
 
    for (const [id, info] of this.activeConnections) {
      const held = now - info.acquiredAt;
      if (held > this.leakThresholdMs) {
        leaks.push({
          connectionId: id,
          heldForMs: held,
          acquiredAt: new Date(info.acquiredAt).toISOString(),
          stackTrace: info.stackTrace,
        });
      }
    }
 
    return leaks;
  }
}
 
// Safe connection usage pattern
async function withConnection<T>(
  pool: ManagedConnectionPool,
  fn: (conn: PooledConnection) => Promise<T>
): Promise<T> {
  const conn = await pool.acquire();
  try {
    return await fn(conn);
  } finally {
    pool.release(conn); // Always releases, even on error
  }
}

Métricas y monitoreo del pool

No puedes ajustar lo que no puedes medir. Instrumenta el pool para exponer las conexiones activas, las inactivas, la profundidad de la cola de espera, el tiempo de adquisición y las advertencias de fugas.

tstypescript
interface PoolMetrics {
  activeConnections: number;
  idleConnections: number;
  totalConnections: number;
  waitQueueDepth: number;
  averageAcquisitionTimeMs: number;
  connectionsCreated: number;
  connectionsDestroyed: number;
  timeouts: number;
  leakWarnings: number;
}
 
function diagnosePoolHealth(metrics: PoolMetrics, config: PoolConfig): string[] {
  const issues: string[] = [];
 
  if (metrics.waitQueueDepth > 0 && metrics.idleConnections === 0) {
    issues.push(
      "Pool exhausted — all connections active with requests waiting. " +
      "Check for connection leaks or increase pool size."
    );
  }
 
  const utilization = metrics.activeConnections / config.maximumPoolSize;
  if (utilization > 0.9) {
    issues.push(
      `Pool utilization at ${Math.round(utilization * 100)}% — ` +
      "approaching saturation"
    );
  }
 
  if (metrics.averageAcquisitionTimeMs > 100) {
    issues.push(
      `Average acquisition time ${metrics.averageAcquisitionTimeMs}ms — ` +
      "connections are contended"
    );
  }
 
  if (metrics.leakWarnings > 0) {
    issues.push(
      `${metrics.leakWarnings} potential connection leaks detected`
    );
  }
 
  return issues;
}

Conclusiones clave

El dimensionamiento del pool sigue una fórmula, no la intuición: (CPU cores * 2) + effective_spindle_count para PostgreSQL. Un pool de 9 conexiones en un servidor de 4 núcleos maneja más carga que un pool de 200, porque el servidor de base de datos dedica su tiempo a las consultas en lugar de a gestionar la sobrecarga de las conexiones.

Usa siempre un patrón withConnection que garantice la liberación en un bloque finally: las conexiones fugadas son el modo de fallo más común de un pool y el más difícil de depurar sin detección proactiva. Implementa una detección de fugas que registre los stack traces de las conexiones retenidas más allá de un umbral.

Monitorea continuamente la utilización del pool, la profundidad de la cola de espera y el tiempo de adquisición. Un pool sano tiene una cola de espera corta, tiempos de adquisición por debajo de 10 ms y una utilización inferior al 80 %. Cuando estas métricas se degradan, investiga las fugas antes de aumentar el tamaño del pool: un pool más grande enmascara el problema sin resolverlo.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX