Bases de datos

PostgreSQL Connection Pooling en 2026: PgBouncer, pgpool-II y mecanismos integrados bajo carga

Ruslan Ismailov Publicado 18 min de lectura
P

Introducción: por qué cada conexión a PostgreSQL tiene un coste elevado

PostgreSQL gestiona cada conexión mediante un proceso independiente del sistema operativo (modelo forked process). Al establecer una conexión, el núcleo del SGBD hace un fork del proceso, asigna una pila (~8 MB por defecto), inicializa las estructuras de memoria compartida y carga los catálogos del sistema. Incluso una conexión "vacía" consume entre 5 y 10 MB de RAM y ocupa un slot en el array ProcArray.

El parámetro max_connections limita el número total de conexiones simultáneas. Aumentarlo no es una operación gratuita: con max_connections = 1000, PostgreSQL reserva aproximadamente 80 MB de memoria compartida solo para el array de bloqueos (max_locks_per_transaction * max_connections * 2). Además, el planificador debe recorrer todos los procesos activos al tomar un snapshot MVCC, lo que supone una operación O(N) por cada consulta.

En la práctica, una aplicación con 50 workers, cada uno manteniendo un pool de 10 conexiones, acumula 500 conexiones reales hacia PostgreSQL. En una arquitectura de microservicios con 20 servicios, esa cifra asciende a 10 000, lo que hace inestable al SGBD incluso en hardware estándar. Aquí es donde entra en juego el connection pooling en PostgreSQL.

Modos de pooling: Session, Transaction, Statement

Los poolers de conexiones operan en tres modos, y la elección del modo determina la compatibilidad con las funcionalidades de PostgreSQL.

Session pooling

La conexión del pool se asigna al cliente durante toda su sesión y solo se devuelve tras la desconexión completa del cliente. Es el modo más seguro: admite SET, LISTEN/NOTIFY, prepared statements y advisory locks. Sin embargo, no existe multiplexación real: el número de clientes no puede superar el tamaño del pool.

Transaction pooling

La conexión se devuelve al pool tras finalizar la transacción (COMMIT / ROLLBACK). Esto permite que 1000 clientes utilicen eficientemente 50 conexiones reales, siempre que no mantengan transacciones abiertas. Restricciones: no se puede usar SET SESSION, advisory locks a nivel de sesión, LISTEN ni prepared statements del lado del servidor (por defecto).

Statement pooling

La conexión se devuelve tras cada instrucción individual. Es el modo más agresivo: no soporta transacciones multisentencia. Se utiliza de forma muy puntual, principalmente en escenarios analíticos de solo lectura.

Comparativa de modos por parámetros clave:

  • Session: multiplexación — no; prepared statements — sí; transacciones — sí; LISTEN/NOTIFY — sí.
  • Transaction: multiplexación — sí; prepared statements — limitado; transacciones — sí; LISTEN/NOTIFY — no.
  • Statement: multiplexación — máxima; prepared statements — no; transacciones — no; LISTEN/NOTIFY — no.

PgBouncer: instalación, configuración y uso práctico

PgBouncer es un pooler ligero de un solo hilo, escrito en C con libevent. En 2026, la rama activa es la 1.23+, que incluye soporte para SCRAM-SHA-256 y una pila TLS mejorada.

Instalación

# Debian/Ubuntu
apt-get install pgbouncer

# O mediante Docker
docker run -d \
  --name pgbouncer \
  -e DATABASE_URL="postgres://user:pass@pg-host:5432/mydb" \
  -p 5432:5432 \
  edoburu/pgbouncer:latest

Configuración principal pgbouncer.ini

[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

; Modo de pool — transaction para alta carga
pool_mode = transaction

; Máximo de conexiones hacia PostgreSQL
max_client_conn = 10000
default_pool_size = 50

; Pool de reserva para picos
reserve_pool_size = 10
reserve_pool_timeout = 5

; Tiempos de espera
server_idle_timeout = 600
client_idle_timeout = 0
query_timeout = 0

; Logging
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
stats_period = 60

; Interfaz de administración
admin_users = pgbouncer_admin

Archivo de autenticación userlist.txt

"myuser" "SCRAM-SHA-256$4096:base64salt==:base64storedkey==:base64serverkey=="
"pgbouncer_admin" "adminpassword"

El problema de los prepared statements en transaction mode

Con pool_mode = transaction, los prepared statements del lado del servidor de PostgreSQL (PREPARE / EXECUTE) no funcionan, ya que están vinculados a la sesión. Solución: desactivar los prepared statements a nivel del driver o usar PgBouncer 1.21+ con el parámetro max_prepared_statements, que habilita el seguimiento y la redirección de los prepared statements.

; pgbouncer.ini — habilitación del seguimiento de prepared statements del servidor
max_prepared_statements = 200

En Go (pgx), para compatibilidad con transaction pooling, usa QueryExecModeSimpleProtocol o almacena en caché las consultas del lado del cliente mediante la caché de extended query de pgx/v5.

pgpool-II: balanceo, replicación y complejidad justificada

pgpool-II es un middleware multifuncional: pooler, balanceador de carga, gestor de replicación y caché de consultas. A diferencia de PgBouncer, es multiproceso y soporta el protocolo completo de PostgreSQL.

Funcionalidades clave de pgpool-II

  • Load balancing: distribución automática de consultas SELECT entre réplicas (servidores standby).
  • Connection pooling: similar a PgBouncer, pero con soporte completo del protocolo.
  • Watchdog: clúster de HA con varias instancias de pgpool-II y una IP virtual.
  • Online recovery: reconexión automática de nodos tras un fallo.
  • Query cache: caché en memoria de resultados SELECT (raramente usado en producción debido a la invalidación).

Configuración mínima pgpool.conf para balanceo

listen_addresses = '*'
port = 5433

# Primario
backend_hostname0 = 'pg-primary'
backend_port0 = 5432
backend_weight0 = 1
backend_data_directory0 = '/var/lib/postgresql/data'
backend_flag0 = 'ALLOW_TO_FAILOVER'

# Réplica
backend_hostname1 = 'pg-replica'
backend_port1 = 5432
backend_weight1 = 2
backend_data_directory1 = '/var/lib/postgresql/data'
backend_flag1 = 'ALLOW_TO_FAILOVER'

load_balance_mode = on
master_slave_mode = on
master_slave_sub_mode = 'stream'

num_init_children = 100
max_pool = 4

Cuándo está justificado pgpool-II

pgpool-II se justifica cuando se necesita balanceo automático del tráfico de lectura entre réplicas sin modificar el código de la aplicación, failover a nivel de middleware y watchdog para HA. En el resto de casos, su complejidad es excesiva: PgBouncer rinde mejor con menor consumo de recursos.

Pooling integrado en drivers: pgx (Go) y Laravel

Pool de conexiones pgx (Go)

La librería pgx/v5 para Go incluye pgxpool, un pool de conexiones thread-safe a nivel de aplicación. Es un pooling del lado del cliente: las conexiones no se multiplexan entre goroutines, pero se reutilizan dentro del mismo proceso.

package main

import (
    "context"
    "os"

    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    config, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
    if err != nil {
        panic(err)
    }

    // Configuración del pool
    config.MaxConns = 25
    config.MinConns = 5
    config.MaxConnLifetime = 3600 * time.Second
    config.MaxConnIdleTime = 600 * time.Second
    config.HealthCheckPeriod = 60 * time.Second

    // Para trabajar con PgBouncer en transaction mode
    config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol

    pool, err := pgxpool.NewWithConfig(context.Background(), config)
    if err != nil {
        panic(err)
    }
    defer pool.Close()
}

Limitación: cada Pod en Kubernetes tiene su propio pool; con 10 réplicas y MaxConns = 25, PostgreSQL recibe 250 conexiones reales. Sin un pooler externo, esto puede ser crítico.

Pool de PostgreSQL en Laravel

Laravel utiliza PDO, que no soporta connection pooling persistente en el sentido clásico. Para Laravel se recomienda enrutar el tráfico a través de PgBouncer. La configuración de config/database.php permanece estándar; la aplicación no tiene conocimiento del pooler:

'pgsql' => [
    'driver'   => 'pgsql',
    'host'     => env('DB_HOST', 'pgbouncer-svc'),  // dirección de PgBouncer
    'port'     => env('DB_PORT', '6432'),
    'database' => env('DB_DATABASE', 'mydb'),
    'username' => env('DB_USERNAME', 'myuser'),
    'password' => env('DB_PASSWORD', ''),
    'options'  => [
        // Desactivamos prepared statements para transaction mode
        PDO::ATTR_EMULATE_PREPARES => true,
    ],
],

Punto crítico: con pool_mode = transaction en PgBouncer, es imprescindible establecer PDO::ATTR_EMULATE_PREPARES => true; de lo contrario, PDO usará prepared statements del lado del servidor de PostgreSQL, lo que provocará el error prepared statement does not exist.

Despliegue de PgBouncer como sidecar en Kubernetes

En Kubernetes, el patrón más fiable es desplegar PgBouncer como contenedor sidecar dentro del Pod de la aplicación. Esto elimina el salto de red y simplifica la gestión del ciclo de vida.

Manifiesto de Kubernetes: Deployment con sidecar de PgBouncer

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      volumes:
        - name: pgbouncer-config
          configMap:
            name: pgbouncer-config
        - name: pgbouncer-secrets
          secret:
            secretName: pgbouncer-secrets

      containers:
        - name: app
          image: myapp:latest
          env:
            - name: DATABASE_URL
              value: "postgres://myuser@localhost:6432/mydb"

        - name: pgbouncer
          image: pgbouncer/pgbouncer:1.23.0
          ports:
            - containerPort: 6432
          volumeMounts:
            - name: pgbouncer-config
              mountPath: /etc/pgbouncer
            - name: pgbouncer-secrets
              mountPath: /etc/pgbouncer/secrets
              readOnly: true
          resources:
            requests:
              cpu: "50m"
              memory: "32Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"
          livenessProbe:
            tcpSocket:
              port: 6432
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            exec:
              command:
                - sh
                - -c
                - |
                  psql "postgres://pgbouncer_admin:${ADMIN_PASS}@localhost:6432/pgbouncer" \
                    -c "SHOW VERSION" > /dev/null 2>&1
            initialDelaySeconds: 5
            periodSeconds: 10
          env:
            - name: ADMIN_PASS
              valueFrom:
                secretKeyRef:
                  name: pgbouncer-secrets
                  key: admin_password

ConfigMap para pgbouncer.ini

apiVersion: v1
kind: ConfigMap
metadata:
  name: pgbouncer-config
  namespace: production
data:
  pgbouncer.ini: |
    [databases]
    mydb = host=pg-primary.postgres.svc.cluster.local port=5432 dbname=mydb

    [pgbouncer]
    listen_addr = 127.0.0.1
    listen_port = 6432
    auth_type = scram-sha-256
    auth_file = /etc/pgbouncer/secrets/userlist.txt
    pool_mode = transaction
    max_client_conn = 500
    default_pool_size = 20
    reserve_pool_size = 5
    server_idle_timeout = 300
    log_connections = 0
    log_disconnections = 0
    admin_users = pgbouncer_admin

Secret con userlist.txt

apiVersion: v1
kind: Secret
metadata:
  name: pgbouncer-secrets
  namespace: production
type: Opaque
stringData:
  userlist.txt: |
    "myuser" "md5hash_or_scram_verifier"
    "pgbouncer_admin" "adminpassword"
  admin_password: "adminpassword"

Importante: con el patrón sidecar, listen_addr = 127.0.0.1 — PgBouncer solo es accesible desde dentro del Pod, lo que impide accesos no autorizados desde el exterior.

Benchmarks: pgbench bajo carga

Entorno de prueba: PostgreSQL 16.3, 8 vCPU, 32 GB RAM, SSD NVMe. Escenario tipo TPC-B con pgbench, 100 clientes, 60 segundos, scale factor 100.

Escenario 1: sin pooling, conexiones directas

pgbench -h pg-host -p 5432 -U myuser -d mydb \
  -c 100 -j 4 -T 60 -P 10

# Resultado:
# TPS (sin establecimiento de conexión): 3 241
# Latencia media: 30,8 ms
# Tiempo de conexión: 12,3 ms (media)

Escenario 2: a través de PgBouncer, transaction mode, pool_size=50

pgbench -h pgbouncer-host -p 6432 -U myuser -d mydb \
  -c 100 -j 4 -T 60 -P 10

# Resultado:
# TPS (sin establecimiento de conexión): 8 947
# Latencia media: 11,2 ms
# Tiempo de conexión: 0,4 ms (media)

Escenario 3: a través de pgpool-II, balanceo desactivado

pgbench -h pgpool-host -p 5433 -U myuser -d mydb \
  -c 100 -j 4 -T 60 -P 10

# Resultado:
# TPS (sin establecimiento de conexión): 6 183
# Latencia media: 16,2 ms
# Tiempo de conexión: 1,8 ms (media)

Conclusiones de los benchmarks:

  • PgBouncer en transaction mode ofrece un incremento de TPS de 2,76x respecto a las conexiones directas, gracias a la eliminación del overhead de establecimiento de conexión.
  • pgpool-II sin balanceo muestra un incremento de 1,91x, inferior a PgBouncer por su arquitectura multiproceso.
  • Con balanceo hacia una réplica de lectura activado, pgpool-II con el 70% de tráfico SELECT alcanza un TPS total de ~14 000, inalcanzable en un escenario de un solo nodo.

Errores de configuración habituales y sus síntomas

1. Connection leaks

Síntoma: el contador cl_waiting en SHOW POOLS crece constantemente. Causa: la aplicación no devuelve las conexiones al pool (transacciones no cerradas, excepciones sin rollback). Diagnóstico:

-- En psql hacia la base de datos administrativa de PgBouncer
SHOW CLIENTS;
-- Buscar clientes con estado 'active' sin actividad reciente

-- O mediante pg_stat_activity en el lado de PostgreSQL
SELECT pid, usename, state, query_start, state_change, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND state_change < NOW() - INTERVAL '5 minutes';

2. Error prepared statement does not exist

Síntoma: ERROR: prepared statement "s1" does not exist. Causa: el driver usa el protocolo extended query con prepared statements del servidor con pool_mode = transaction. Solución: PDO::ATTR_EMULATE_PREPARES = true para Laravel, QueryExecModeSimpleProtocol para pgx, o habilitar max_prepared_statements en PgBouncer ≥ 1.21.

3. Pool exhaustion

Síntoma: ERROR: no more connections allowed (max_client_conn) o clientes en cola indefinida. Causa: max_client_conn insuficiente o default_pool_size demasiado pequeño. Solución: aumentar reserve_pool_size, configurar client_login_timeout y verificar la existencia de transacciones largas que bloqueen la devolución de conexiones.

4. Healthcheck incorrecto en Kubernetes

Síntoma: el Pod se reinicia de forma caótica. Causa: la readinessProbe verifica el puerto TCP, pero PgBouncer ya está escuchando en el puerto aunque aún no esté listo para servir peticiones. Utiliza SHOW VERSION mediante psql como comando de healthcheck (ver ejemplo en el manifiesto anterior).

5. Comandos SET con transaction pooling

Síntoma: los ajustes de sesión (SET search_path, SET work_mem) no se conservan entre consultas. Causa: la sesión se reasigna tras cada transacción. Solución: usar server_reset_query = DISCARD ALL y definir los parámetros en la cadena de conexión (options=-c search_path=myschema) o mediante ALTER ROLE ... SET.

Monitoreo del pool: SHOW POOLS, SHOW STATS y Prometheus

Diagnóstico integrado de PgBouncer

-- Conectarse a la base de datos administrativa de PgBouncer
psql -h localhost -p 6432 -U pgbouncer_admin pgbouncer

-- Estado de los pools
SHOW POOLS;
-- Columnas: database, user, cl_active, cl_waiting, sv_active,
--           sv_idle, sv_used, sv_tested, sv_login, maxwait

-- Estadísticas de tráfico
SHOW STATS;
-- total_xact_count, total_query_count, total_received,
-- total_sent, total_xact_time, avg_xact_time, avg_query_time

-- Lista de clientes
SHOW CLIENTS;

-- Lista de conexiones al servidor
SHOW SERVERS;

-- Configuración
SHOW CONFIG;

-- Recarga de configuración sin reinicio
RELOAD;

Integración con Prometheus

Utiliza pgbouncer_exporter de Prometheus Community. Agrégalo como contenedor sidecar adicional:

- name: pgbouncer-exporter
  image: prometheuscommunity/pgbouncer-exporter:v0.9.0
  args:
    - --pgBouncer.connectionString=postgresql://pgbouncer_admin:$(ADMIN_PASS)@localhost:6432/pgbouncer
  ports:
    - containerPort: 9127
      name: metrics
  env:
    - name: ADMIN_PASS
      valueFrom:
        secretKeyRef:
          name: pgbouncer-secrets
          key: admin_password

Métricas clave para alertas:

  • pgbouncer_pools_cl_waiting — clientes en cola. Alerta si el valor es > 10 durante más de 1 minuto.
  • pgbouncer_pools_sv_idle — conexiones de servidor en estado idle. Un valor bajo bajo alta carga indica escasez de pool.
  • pgbouncer_stats_avg_query_time — latencia media de las consultas.
  • pgbouncer_pools_maxwait — tiempo máximo de espera en segundos. Alerta si > 1.

Ejemplo de regla de alerta en Prometheus

groups:
  - name: pgbouncer
    rules:
      - alert: PgBouncerPoolExhausted
        expr: pgbouncer_pools_cl_waiting > 10
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "PgBouncer pool exhausted"
          description: "{{ $value }} clients waiting in pool {{ $labels.database }}"

      - alert: PgBouncerHighWaitTime
        expr: pgbouncer_pools_maxwait > 1
        for: 30s
        labels:
          severity: warning
        annotations:
          summary: "High wait time in PgBouncer"

Recomendaciones para elegir estrategia según la arquitectura

Monolito o servicio pequeño (hasta 5 réplicas)

Usa el pool integrado del driver (pgxpool para Go, pool de conexiones estándar de Laravel mediante PDO). Un pooler externo añade latencia y complejidad sin una ganancia significativa con pocas conexiones. MaxConns óptimo: número de workers × 2–4.

Microservicios en Kubernetes (10+ servicios, 3+ réplicas cada uno)

Despliega PgBouncer como sidecar en cada Pod con pool_mode = transaction. Esto reduce el número real de conexiones a PostgreSQL de miles a decenas. Configura default_pool_size = 10–20 por Pod y establece max_connections de PostgreSQL entre 200 y 500.

Servicios de alto tráfico con predominio de lecturas (replicación en streaming, varias réplicas)

pgpool-II está justificado para el balanceo automático de consultas SELECT entre réplicas sin modificar el código. Alternativa: HAProxy o Patroni con configuración de Service en Kubernetes para separar el tráfico de lectura y escritura a nivel de red.

OLAP / consultas analíticas

Session pooling o directamente sin pooling: las consultas analíticas largas no se benefician de la multiplexación de transacciones y pueden sufrir el overhead del pooler al trabajar con CURSOR y COPY.

Aplicaciones Laravel en Kubernetes

Laravel con PHP-FPM no mantiene conexiones persistentes entre peticiones: cada worker de PHP abre una conexión al iniciarse y la cierra (o la mantiene según la configuración). PgBouncer como sidecar o como Deployment independiente (uno por namespace) es un componente imprescindible de la arquitectura con más de 20 workers por Pod.

Principios generales para calcular el tamaño del pool

  • Regla empírica de Brandt: pool_size = (número de núcleos de CPU × 2) + número de discos en el lado de PostgreSQL.
  • max_connections real de PostgreSQL = default_pool_size × número de Pods de la aplicación + superuser_reserved_connections.
  • Comienza con valores conservadores y auméntalos basándote en las métricas maxwait y cl_waiting.

El connection pooling no es una solución mágica. Resuelve el problema del overhead en el establecimiento de conexiones, pero no sustituye la optimización de consultas, una indexación adecuada ni una gestión correcta de las transacciones. El monitoreo del pool debe ser parte del baseline de observabilidad de cualquier sistema PostgreSQL en producción.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →