Saltar al contenido

Gestión de conexiones de base de datos en producción

Cómo configurar y monitorizar pools de conexiones: dimensionado, detección de fugas, failover y los errores de configuración más habituales.

5 min de lectura
Diagrama que muestra un pool de conexiones gestionando múltiples conexiones de base de datos entre una aplicación y un servidor de base de datos

Todas las caídas de base de datos en producción que he investigado empezaron de la misma forma. La aplicación abre más conexiones de las que la base de datos permite, las nuevas peticiones se encolan esperando una conexión, los timeouts se encadenan y todo el sistema se bloquea. La solución es siempre la misma: una configuración adecuada del pool de conexiones.

Los pools de conexiones mantienen un conjunto de conexiones reutilizables a la base de datos. En lugar de abrir y cerrar una conexión por cada consulta (costoso — handshake TCP, negociación TLS, autenticación), la aplicación toma prestada una conexión del pool y la devuelve cuando la consulta termina.

Fundamentos del pool

Un pool de conexiones tiene tres parámetros críticos: tamaño mínimo, tamaño máximo y timeout de inactividad.

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

El pool elimina la sobrecarga de crear conexiones por petición. Una conexión ya activa completa una consulta simple en 1-5 ms. Crear una conexión nueva añade 30-100 ms.

Dimensionar el pool correctamente

El error más común es poner max demasiado alto. Más conexiones no significa más rendimiento. El rendimiento de PostgreSQL se degrada cuando max_connections supera lo que el hardware puede manejar.

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 }

Un pool de 2-5 conexiones por instancia maneja miles de peticiones por segundo. Las conexiones se reutilizan: una consulta tarda 5 ms, así que una conexión atiende 200 consultas por segundo.

Detección de fugas de conexiones

Una fuga de conexiones ocurre cuando el código toma una conexión del pool pero nunca la devuelve. El pool se acaba agotando y las nuevas peticiones se bloquean o fallan.

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]
  );
});

Monitorización de la salud del pool

Las métricas del pool revelan problemas antes de que se conviertan en caídas. Monitoriza estos valores:

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

Cuando waitingCount es consistentemente mayor que cero, el pool es demasiado pequeño o hay fugas de conexiones. Cuando idleCount es igual a totalCount la mayor parte del tiempo, el pool es demasiado grande.

Estrategia de timeouts de conexión

Tres timeouts distintos protegen contra distintos modos de fallo:

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);
  }
}

Pooling de conexiones con PgBouncer

En entornos con muchas conexiones (muchas instancias de aplicación, funciones serverless), coloca PgBouncer entre la aplicación y PostgreSQL. PgBouncer mantiene menos conexiones reales a la base de datos mientras atiende muchas conexiones de clientes.

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 en modo transaction multiplexa cientos de conexiones de cliente sobre un pool pequeño de conexiones de servidor. Esto es esencial en entornos serverless, donde cada invocación de función abre su propia conexión.

Conclusiones clave

  1. Usa siempre un pool de conexiones — las conexiones por petición no aguantan la carga de producción
  2. Dimensiona los pools según los núcleos de CPU de la base de datos — (cores * 2) + 1 repartido entre instancias, no max: 100 en todas partes
  3. Usa helpers como withTransaction — garantiza la liberación de conexiones con patrones try/finally
  4. Monitoriza waitingCount — si las peticiones se encolan esperando conexiones, el pool está agotado
  5. Configura tres timeouts — el timeout de adquisición, el de consulta y el de inactividad protegen contra fallos distintos
  6. Usa PgBouncer en entornos con muchas conexiones — multiplexa cientos de conexiones de cliente sobre un pool de servidor pequeño
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX