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.

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.
// ❌ 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// ✅ 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 queriesEl 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.
# 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// 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.
// ❌ 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
}
}// 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:
// 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);# 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: warningCuando 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:
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);
});// 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.
; 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 = 60Without 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
- Usa siempre un pool de conexiones — las conexiones por petición no aguantan la carga de producción
- Dimensiona los pools según los núcleos de CPU de la base de datos —
(cores * 2) + 1repartido entre instancias, nomax: 100en todas partes - Usa helpers como
withTransaction— garantiza la liberación de conexiones con patrones try/finally - Monitoriza
waitingCount— si las peticiones se encolan esperando conexiones, el pool está agotado - Configura tres timeouts — el timeout de adquisición, el de consulta y el de inactividad protegen contra fallos distintos
- Usa PgBouncer en entornos con muchas conexiones — multiplexa cientos de conexiones de cliente sobre un pool de servidor pequeño


