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.

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.
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 expectGestió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.
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.
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.
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.


