Las tormentas de conexiones no se anuncian de forma cortés. Llegan como páginas de “base de datos caída”, una estampida de reintentos desde las aplicaciones,
CPU que parece estar bien hasta que no lo está, y un Postgres que de repente actúa como si te hiciera un favor aceptando cualquier conexión.
En Debian 13, las herramientas son buenas, los valores por defecto son razonables, y aun así puedes acabar con cientos o miles de clientes
intentando colarse por la misma puerta estrecha.
Este es el caso nº37 en mi cuaderno mental: el argumento recurrente entre “simplemente añade PgBouncer” y “simplemente afina Postgres”.
Ambos bandos tienen razón—a veces. Más a menudo, uno de ellos está a punto de hacerte perder una semana.
Vamos a cortar la charla con lo que realmente cambia los resultados en producción.
Qué es realmente una tormenta de conexiones (y por qué perjudica)
Una “tormenta de conexiones” en PostgreSQL no es sólo “muchas conexiones”. Es el momento patológico en que la tasa de nuevas conexiones
o los intentos de reconexión abruman alguna parte del sistema: creación de procesos backend de Postgres, autenticación, handshake TLS, programador de CPU,
límites del kernel, latencia de disco, o simplemente la capacidad del servidor para lidiar con los cambios de contexto.
Postgres usa un modelo de proceso-por-conexión (no un modelo de hilo-por-conexión). Es una elección de diseño con beneficios claros:
aislamiento, depuración más simple, contención de fallos más predecible. Pero también significa que cada conexión añade sobrecarga real:
un proceso backend, consumo de memoria (work_mem no es lo único; hay estado por backend), y coste de planificación.
Las tormentas suelen empezar aguas arriba:
- Despliegues de la app que reinician pools de conexión.
- Autoscaling que añade pods que conectan todos a la vez.
- Health checks del balanceador mal configurados que se convierten en un DOS de login.
- Un fallo de red que dispara reintentos en bucle cerrado.
- Una consulta mala que hace subir los tiempos de respuesta; los clientes agotan tiempos y se reconectan, multiplicando la carga.
El modo de fallo es desagradable porque es no lineal. La base de datos puede estar “bien” con 200 conexiones estables, pero colapsar bajo
2000 intentos de conexión por segundo incluso si sólo hay 200 activas en cualquier momento.
Regla práctica: cuando el churn de conexiones es el problema, aumentar max_connections es como ensanchar una puerta mientras
el edificio está en llamas. Moverás más humo.
Broma #1: Una tormenta de conexiones es como pizza gratis en la oficina—nadie “la necesita”, pero de repente todos están muy motivados para aparecer.
Hechos y contexto que cambian tu forma de pensar
- El modelo proceso-por-conexión de Postgres data de hace décadas y sigue siendo una elección arquitectónica central; prioriza robustez sobre un número bruto de conexiones.
pg_stat_activitylleva tiempo existiendo en alguna forma; sigue siendo el primer lugar donde mirar cuando la realidad discrepa de los dashboards.- TLS por todas partes cambió las cuentas: lo que antes era tráfico barato de “connect/auth” ahora incluye handshakes criptográficos más pesados si terminas TLS en Postgres.
- La adopción de SCRAM-SHA-256 mejoró la seguridad de contraseñas, pero una autenticación más fuerte puede hacer que las tormentas sean más sensibles a la CPU que los antiguos MD5 bajo churn extremo.
- Los límites de cgroup y systemd en Linux se convirtieron en una fuente silenciosa de incidentes “funcionaba en el SO antiguo”; las unidades gestionadas por systemd en Debian 13 hacen estos límites más visibles (y aplicables).
- El uso de PgBouncer en pooling transaccional se popularizó no porque Postgres sea “malo”, sino porque las aplicaciones rutinariamente hacen mal uso o crean demasiadas conexiones.
- “Idle in transaction” ha sido una trampa conocida de Postgres durante años: mantiene locks y bloat mientras parece “idle”, lo que hace la respuesta a incidentes engañosamente lenta.
- La observabilidad mejoró: Postgres moderno expone wait events (
pg_stat_activity.wait_event) para que puedas dejar de adivinar si estás limitado por CPU, locks o IO.
Una idea para llevarte en la cabeza viene de un peso pesado de la fiabilidad:
paraphrased idea
— John Allspaw: “En incidentes, el sistema tiene sentido para la gente dentro de él; arregla las condiciones, no la culpa.”
Guion de diagnóstico rápido
Cuando estás de guardia, no tienes tiempo para debates filosóficos sobre poolers. Necesitas encontrar el cuello de botella en minutos,
no en un postmortem. Este es el orden de triaje que funciona en Debian 13 con Postgres en el mundo real.
Primero: determina si fallas al “aceptar conexiones” o al “servir consultas”
- Si los clientes no pueden conectar en absoluto: revisa listen backlog, descriptores de archivos, picos de CPU por auth/TLS y límites de procesos.
- Si los clientes conectan pero las consultas agotan tiempo: revisa locks, IO, saturación de CPU y consultas lentas que causan tormentas de reintentos.
Segundo: clasifica la tormenta
- Tormenta por churn: muchas conexiones/desconexiones, sesiones de corta duración,
pg_stat_activitydominado por sesiones nuevas. - Tormenta de idle: conexiones se acumulan y permanecen, muchas sesiones
idle, probablemente mal pooling en la app. - Tormenta “idle in transaction”: menos sesiones, pero bloquean locks y provocan acumulaciones.
- Tormenta de reintentos: latencia en consultas provoca timeouts, reintentos de la app amplifican la carga; a menudo no es un problema de “conexiones” en la raíz.
Tercero: elige la palanca
- Usa un pooler cuando necesites limitar la dispersión de conexiones o amortiguar el churn.
- Afina Postgres cuando el servidor esté con recursos insuficientes, mal configurado o bloqueado (locks/IO) y las conexiones sean solo el síntoma.
- Arregla la app cuando crea conexiones por petición, no las reutiliza o tiene un comportamiento de reintento/retroceso roto.
Pooler vs tuning: lógica de decisión, no ideología
Puedes “resolver” una tormenta de tres maneras: hacer que Postgres acepte más conexiones, reducir el número de conexiones, o reducir la necesidad de reintentos.
La trampa es asumir que son intercambiables. No lo son.
Qué puede hacer el tuning (y qué no)
El tuning ayuda cuando Postgres está desperdiciando recursos o está bloqueado. Ejemplos:
- Locks: mala higiene de transacciones crea colas de locks; el tuning no “arregla” eso, pero monitorizar locks y timeouts reduce el radio del blast.
- Memoria:
shared_buffersinadecuado y elecciones desbocadas dework_mempueden convertir carga normal en un infierno de swap. - IO: almacenamiento lento, picos de checkpoints o mal comportamiento de autovacuum pueden empujar la latencia a niveles que disparen reintentos.
- CPU: autenticación costosa (TLS/SCRAM) más alto churn puede dominar la CPU; el tuning puede mover piezas (por ejemplo, offload de TLS) pero no cambia el churn en sí.
Lo que el tuning no hará: lograr que 10.000 clientes se comporten responsablemente. Si se conectan como mosquitos al atardecer, necesitarás una mosquitera.
Qué cambia un pooler
Un pooler (más comúnmente PgBouncer) se sitúa entre los clientes y Postgres, colapsando muchas conexiones de cliente en menos conexiones al servidor.
Obtienes:
- Topes rígidos en conexiones al servidor incluso cuando los clientes hacen picos.
- Establecimiento de conexión cliente más rápido (especialmente si el pooler corre cerca de la app y mantiene conexiones servidor calientes).
- Protección contra tormentas de deploy: reinicios de la app no se traducen en churn de backends de Postgres.
Pero los poolers también eliminan algunas garantías:
- El pooling por transacción rompe el estado de sesión: tablas temporales, prepared statements, variables de sesión, advisory locks—manéjalo con cuidado.
- La visibilidad cambia: debes monitorizar métricas del pooler, no solo Postgres.
- Las malas configuraciones pueden ser peores que no tener pooler: tamaños de pool y timeouts erróneos pueden crear colas auto-infligidas.
Si tu carga es web (muchas transacciones cortas), el pooling por transacción suele ser la ganadora. Si tu carga depende mucho de la sesión
(mucho estado de sesión, transacciones largas), aún puedes usar un pooler—pero puede que necesites pooling de sesión o refactorizar la app.
Broma #2: Subir max_connections en respuesta a una tormenta es como comprar más sillas cuando el problema es la agenda de la reunión.
Tareas prácticas (comandos, salidas, decisiones)
Estas son las tareas que realmente ejecuto durante incidentes y en sesiones de “arreglemos esto antes de que vuelva a pasar”.
Cada tarea incluye: comando, salida de ejemplo, qué significa y qué decisión tomar.
Tarea 1: Confirma que estás alcanzando límites de conexiones (lado Postgres)
cr0x@server:~$ sudo -u postgres psql -XAtc "SHOW max_connections; SHOW superuser_reserved_connections;"
200
3
Significado: Solo 197 conexiones no-superusuario están efectivamente disponibles.
Decisión: Si ves errores como “too many clients already”, no aumentes esto de inmediato. Primero encuentra por qué las conexiones aumentan o se quedan pegadas.
Tarea 2: Ver el conteo y estados de conexiones actuales
cr0x@server:~$ sudo -u postgres psql -Xc "SELECT state, count(*) FROM pg_stat_activity GROUP BY 1 ORDER BY 2 DESC;"
state | count
-----------+-------
idle | 160
active | 25
| 3
idle in transaction | 9
(4 rows)
Significado: Muchas sesiones idle. Unas pocas “idle in transaction” son señales de alarma.
Decisión: Si domina idle y el conteo de conexiones está cerca del máximo, céntrate en el pooling de la aplicación y timeouts de idle; un pooler suele ser la contención más rápida.
Tarea 3: Identificar las fuentes de cliente principales (las tormentas suelen venir de un solo lugar)
cr0x@server:~$ sudo -u postgres psql -Xc "SELECT client_addr, usename, count(*) AS conns FROM pg_stat_activity GROUP BY 1,2 ORDER BY conns DESC LIMIT 10;"
client_addr | usename | conns
-------------+---------+-------
10.42.7.19 | appuser | 120
10.42.8.11 | appuser | 95
10.42.9.02 | appuser | 70
(3 rows)
Significado: Unos pocos nodos de la app dominan. Es buena noticia: puedes arreglar o limitar un pequeño conjunto de infractores.
Decisión: Si una dirección cliente está explotando, aisla ese despliegue de la app, sidecar o job runner.
Tarea 4: Medir síntomas de sobrecarga connect/auth vía wait events de Postgres
cr0x@server:~$ sudo -u postgres psql -Xc "SELECT wait_event_type, wait_event, count(*) FROM pg_stat_activity WHERE state='active' GROUP BY 1,2 ORDER BY 3 DESC;"
wait_event_type | wait_event | count
-----------------+---------------------+-------
Lock | transactionid | 12
IO | DataFileRead | 5
CPU | | 3
(3 rows)
Significado: Tu “tormenta de conexiones” podría ser en realidad una tormenta de locks que provoca timeouts y reintentos.
Decisión: Si dominan las esperas por Lock, deja de pensar en poolers y comienza a pensar en la transacción bloqueante y los timeouts.
Tarea 5: Encontrar bloqueadores rápido (los locks causan tormentas de reintentos)
cr0x@server:~$ sudo -u postgres psql -Xc "SELECT blocked.pid AS blocked_pid, blocker.pid AS blocker_pid, blocked.query AS blocked_query, blocker.query AS blocker_query FROM pg_catalog.pg_locks blocked_locks JOIN pg_catalog.pg_stat_activity blocked ON blocked.pid = blocked_locks.pid JOIN pg_catalog.pg_locks blocker_locks ON blocker_locks.locktype = blocked_locks.locktype AND blocker_locks.DATABASE IS NOT DISTINCT FROM blocked_locks.DATABASE AND blocker_locks.relation IS NOT DISTINCT FROM blocked_locks.relation AND blocker_locks.page IS NOT DISTINCT FROM blocked_locks.page AND blocker_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple AND blocker_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid AND blocker_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid AND blocker_locks.classid IS NOT DISTINCT FROM blocked_locks.classid AND blocker_locks.objid IS NOT DISTINCT FROM blocked_locks.objid AND blocker_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid AND blocker_locks.pid != blocked_locks.pid JOIN pg_catalog.pg_stat_activity blocker ON blocker.pid = blocker_locks.pid WHERE NOT blocked_locks.granted;"
blocked_pid | blocker_pid | blocked_query | blocker_query
------------+-------------+------------------------------+-----------------------------
24811 | 20777 | UPDATE accounts SET ... | ALTER TABLE accounts ...
(1 row)
Significado: Una instrucción DDL está bloqueando actualizaciones. Eso puede hacer que se desencadenen timeouts y reconexiones en la app.
Decisión: Mata o pospone al bloqueador si es seguro, e implementa prácticas de migración más seguras (lock timeouts, patrones de migración online).
Tarea 6: Comprobar techo de descriptores de archivo a nivel OS (causa clásica de “fallan conexiones”)
cr0x@server:~$ cat /proc/$(pidof postgres | awk '{print $1}')/limits | grep -E "Max open files"
Max open files 1024 1048576 files
Significado: El límite blando es 1024 para el PID principal. Eso es peligrosamente bajo para una base de datos ocupada.
Decisión: Arregla los límites de la unidad systemd (Tarea 7). Si corres con límites blandos bajos, tendrás fallos aleatorios bajo carga.
Tarea 7: Verificar LimitNOFILE en systemd para PostgreSQL
cr0x@server:~$ systemctl show postgresql --property=LimitNOFILE
LimitNOFILE=1024
Significado: El servicio PostgreSQL está limitado a 1024 archivos abiertos. Eso incluye sockets y archivos de datos.
Decisión: Establece un valor sensato mediante un drop-in. Luego reinicia en una ventana de mantenimiento.
Tarea 8: Aplicar un drop-in de systemd para límites de archivos mayores
cr0x@server:~$ sudo systemctl edit postgresql
# (editor opens)
# add:
# [Service]
# LimitNOFILE=1048576
cr0x@server:~$ sudo systemctl daemon-reload
cr0x@server:~$ sudo systemctl restart postgresql
cr0x@server:~$ systemctl show postgresql --property=LimitNOFILE
LimitNOFILE=1048576
Significado: PostgreSQL ahora hereda un límite alto de descriptores de archivo.
Decisión: Si las fallas de conexión desaparecen, encontraste al menos un techo rígido. Aun así investiga por qué las conexiones hacen picos.
Tarea 9: Inspeccionar drops en listen backlog TCP (dolor a nivel kernel al conectar)
cr0x@server:~$ ss -ltn sport = :5432
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 64 4096 0.0.0.0:5432 0.0.0.0:*
Significado: Recv-Q muestra conexiones en cola. Si está frecuentemente cerca de Send-Q (backlog), estás perdiendo o retrasando el establecimiento de conexiones.
Decisión: Si Recv-Q sube durante tormentas, considera un pooler cerca de los clientes y revisa ajuste del backlog del kernel (somaxconn) y la estrategia de Postgres listen_addresses/tcp_keepalives.
Tarea 10: Comprobar backlog del kernel y manejo de SYN
cr0x@server:~$ sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 4096
Significado: Estos valores son razonablemente altos. Si son bajos (128/256), las tormentas pueden saturar la cola de handshake TCP.
Decisión: Si los valores son bajos y ves pérdidas de conexión, súbelos con cuidado y prueba. Pero recuerda: el tuning del backlog ayuda a los síntomas; un pooler arregla el comportamiento.
Tarea 11: Determinar si la autenticación es cara (pistas en pg_hba.conf)
cr0x@server:~$ sudo -u postgres psql -Xc "SELECT name, setting FROM pg_settings WHERE name IN ('password_encryption','ssl');"
name | setting
---------------------+---------
password_encryption | scram-sha-256
ssl | on
(2 rows)
Significado: SCRAM + TLS es seguro; también puede ser más pesado durante churn masivo.
Decisión: No debilites la autenticación como primera respuesta. Prefiere poolers para reducir el volumen de handshakes, o termina TLS antes si la política lo permite.
Tarea 12: Inspeccionar tasa de churn de conexiones desde estadísticas de Postgres
cr0x@server:~$ sudo -u postgres psql -Xc "SELECT datname, numbackends, xact_commit, xact_rollback, blks_read, blks_hit FROM pg_stat_database ORDER BY numbackends DESC;"
datname | numbackends | xact_commit | xact_rollback | blks_read | blks_hit
-----------+-------------+-------------+---------------+-----------+----------
appdb | 190 | 9921330 | 12344 | 1209932 | 99881233
postgres | 3 | 1200 | 0 | 120 | 22000
(2 rows)
Significado: Alto numbackends cercano a tu máximo efectivo. Combinado con trabajo activo bajo, esto grita “problema de gestión de conexiones”.
Decisión: Contén con un pooler o límites estrictos en el lado de la app; luego arregla retry/backoff y disciplina de pooling.
Tarea 13: Comprobar “idle in transaction” y aplicar timeouts
cr0x@server:~$ sudo -u postgres psql -Xc "SHOW idle_in_transaction_session_timeout;"
idle_in_transaction_session_timeout
-----------------------------------
0
(1 row)
Significado: Timeout deshabilitado. Las sesiones “idle in transaction” pueden mantener locks indefinidamente.
Decisión: Establece un valor sensato (a menudo 1–5 minutos para OLTP) para reducir el riesgo de cola en incidentes.
Tarea 14: Aplicar timeouts (valores por defecto sensatos vencen a la depuración heroica)
cr0x@server:~$ sudo -u postgres psql -Xc "ALTER SYSTEM SET idle_in_transaction_session_timeout = '2min';"
ALTER SYSTEM
cr0x@server:~$ sudo -u postgres psql -Xc "SELECT pg_reload_conf();"
pg_reload_conf
----------------
t
(1 row)
Significado: Config recargada, las nuevas sesiones heredarán el timeout.
Decisión: Si tienes sesiones legítimas de larga duración “idle-in-tx” (raro y generalmente incorrecto), arregla ese camino de código; no desactives el riel de seguridad.
Tarea 15: Verificar que el pooling cliente no es “infinito” (ejemplo: psql muestra application_name)
cr0x@server:~$ sudo -u postgres psql -Xc "SELECT application_name, count(*) FROM pg_stat_activity GROUP BY 1 ORDER BY 2 DESC LIMIT 10;"
application_name | count
------------------+-------
myapi | 180
migration-job | 9
psql | 1
(3 rows)
Significado: La API es la consumidora principal. Necesitas inspeccionar sus ajustes de pool y comportamiento de despliegue.
Decisión: Capa los pools por instancia, añade jitter al arranque y asegura backoff exponencial en los reintentos.
Tarea 16: Comprobar riesgo de memoria antes de aumentar max_connections (la trampa)
cr0x@server:~$ sudo -u postgres psql -Xc "SHOW shared_buffers; SHOW work_mem; SHOW maintenance_work_mem; SHOW max_connections;"
shared_buffers
--------------
4GB
(1 row)
work_mem
--------
16MB
(1 row)
maintenance_work_mem
--------------------
512MB
(1 row)
max_connections
---------------
200
(1 row)
Significado: Si saltas a 800 conexiones dejando work_mem en 16MB, invitas a presión de memoria. No todas las conexiones usan work_mem completo, pero los incidentes aman las cuentas en el peor caso.
Decisión: Si debes aumentar max_connections, revisa la estrategia de memoria y considera primero un pooler. Mejor: evita necesitar el aumento.
Tarea 17: Instalar y comprobar PgBouncer en Debian 13
cr0x@server:~$ sudo apt-get update
cr0x@server:~$ sudo apt-get install -y pgbouncer
Reading package lists... Done
...
Setting up pgbouncer ...
Significado: Pooler instalado. Ahora debes configurarlo de forma intencional; los valores por defecto no son un plan de producción.
Decisión: Coloca PgBouncer cerca de las apps si es posible (mismo nodo/clúster) para reducir overhead de handshake en red y aislar tormentas.
Tarea 18: Comprobaciones mínimas de configuración de PgBouncer que eviten autolesionarse
cr0x@server:~$ sudo grep -E "^(listen_addr|listen_port|pool_mode|max_client_conn|default_pool_size|server_reset_query|ignore_startup_parameters)" /etc/pgbouncer/pgbouncer.ini
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 50
server_reset_query = DISCARD ALL
ignore_startup_parameters = extra_float_digits
Significado: Pooling por transacción con tamaño de pool sensato puede absorber picos de clientes. DISCARD ALL es más seguro, aunque con overhead.
Decisión: Si tu app depende del estado de sesión, el pooling por transacción lo romperá. O refactoriza o usa pooling de sesión para ese subconjunto.
Tarea 19: Observar colas de PgBouncer (para saber si solo estás moviendo el dolor)
cr0x@server:~$ psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"
database | user | cl_active | cl_waiting | sv_active | sv_idle | sv_used | maxwait
----------+---------+-----------+------------+----------+---------+---------+---------
appdb | appuser | 120 | 80 | 50 | 0 | 50 | 12
(1 row)
Significado: Los clientes están esperando. Las conexiones servidor están limitadas a 50 y totalmente utilizadas. Eso es esperado durante picos.
Decisión: Si maxwait sube y la latencia de las peticiones sufre, afina el tamaño del pool con cautela y—más importante—reduce la concurrencia de clientes y arregla consultas lentas.
Tarea 20: Confirmar que la app realmente usa el pooler
cr0x@server:~$ sudo -u postgres psql -Xc "SELECT client_addr, count(*) FROM pg_stat_activity GROUP BY 1 ORDER BY 2 DESC;"
client_addr | count
-------------+-------
127.0.0.1 | 52
(1 row)
Significado: Postgres ahora ve a PgBouncer como el cliente (loopback). Eso es bueno; la dispersión se movió al pooler.
Decisión: Si aún ves muchas IPs de app conectando directamente, tienes un problema de rollout/deriva de configuración, no de tuning.
Tres micro-historias corporativas desde el frente
Micro-historia #1: El incidente causado por una suposición errónea (el mito “las conexiones son baratas”)
Una empresa mediana corría una API de clientes en Debian, Postgres en una VM potente y un service mesh que presumía de mTLS por todas partes.
Alguien hizo las cuentas de CPU e IO, vio mucho margen y declaró la base de datos “sobredimensionada”.
El mayor miedo del equipo eran las consultas lentas. Nadie se preocupó por los connects.
Un despliegue rutinario se propagó a unos cientos de contenedores. Cada contenedor arrancaba, ejecutaba un health check y establecía una nueva conexión a la base de datos
para verificar que las migraciones estaban “bien”. Ese health check corría cada pocos segundos porque “la detección rápida es buena”, y abría
una conexión nueva cada vez porque la ruta de código usaba un cliente de un solo uso.
Postgres no murió inmediatamente. Simplemente se volvió progresivamente menos receptivo. Volumen de handshake de autenticación y TLS se disparó.
Los procesos backend se multiplicaron. La CPU de la VM parecía modestamente ocupada porque el overhead del scheduler y la contabilidad del kernel
eran el verdadero enemigo.
La solución no fue un tuning heroico. Dejaron de hacer connect-per-healthcheck, añadieron un pequeño sidecar pooler para los pods de la API,
y limitaron los readiness checks por tasa. También dejaron de fingir que “connect es barato” en un mundo TLS.
El incidente no volvió.
Micro-historia #2: La optimización que salió mal (subir max_connections)
Otra organización tuvo la clásica página de “too many clients already”. Un ingeniero listo y bienintencionado subió max_connections de un par
de cientos a más de mil. El cambio tomó minutos. La página desapareció. Todos volvieron a trabajar.
Dos semanas después volvieron las quejas de latencia—pero con una vuelta de tuerca. La base de datos ya no rechazaba conexiones; las aceptaba y
se hundía silenciosamente. Los tiempos de respuesta empeoraron en general. Autovacuum quedó atrás. Los checkpoints se volvieron en picos.
El postmortem fue incómodo porque no había una sola consulta “culpable”. El problema era la concurrencia y la presión de memoria.
Más backends significaron más overhead de memoria, más cambios de contexto y más consumo simultáneo de work_mem durante ráfagas.
El sistema se comportó bien hasta que cruzó un umbral. Entonces se comportó como una cajera cansada en un supermercado al cierre.
La solución final fue revertir max_connections, desplegar PgBouncer en modo transaction pooling y limitar los pools del lado de la app.
También implementaron jitter en los reintentos para que los timeouts no se sincronicen. La lección: subir límites de conexión puede convertir una falla visible en un colapso de rendimiento invisible.
Micro-historia #3: La práctica aburrida pero correcta que salvó el día (timeouts y rieles de seguridad)
Una compañía cercana a finanzas corría Postgres con un proceso de cambios estricto. Los ingenieros refunfuñaban, como suele pasar.
Pero su DBA había insistido en un puñado de “defaults aburridos”: statement_timeout para ciertos roles, idle_in_transaction_session_timeout,
y timeouts de lock conservadores para migraciones.
Una tarde, una migración salió que habría sido inofensiva en staging. En producción chocó con un job por lotes y tomó locks más pesados de lo esperado.
Sin rieles de seguridad, la migración se habría quedado ahí indefinidamente reteniendo a todos de rehén.
En vez de eso, la migración golpeó el timeout de lock y falló rápidamente. La app siguió funcionando. Unas pocas peticiones reintentaron y tuvieron éxito.
El ticket del incidente fue un no-evento: “migración fallida, volver a ejecutar luego.” Sin tormenta, sin pánico, sin llamadas a las 3 a.m.
Eso es lo que hacen los controles aburridos: son invisibles hasta que el día que evitan que tu pager aprenda demasiado de ti.
Errores comunes: síntoma → causa raíz → solución
1) Síntoma: “too many clients already” durante despliegues
Causa raíz: instancias de la app arrancan simultáneamente y cada una abre un pool de tamaño completo inmediatamente; sin jitter; sin topes de pool.
Solución: limita pools por instancia; añade jitter en el arranque; añade PgBouncer para colapsar la dispersión; asegura que los reintentos usen backoff exponencial con jitter.
2) Síntoma: Postgres acepta conexiones pero las consultas agotan tiempo
Causa raíz: contención por locks o transacciones largas, a menudo desencadenadas por DDL durante tráfico pico.
Solución: identifica bloqueadores; adopta timeouts de lock; usa patrones de migración más seguros; mata o posterga la sesión bloqueante durante incidentes.
3) Síntoma: Las conexiones fallan intermitentemente bajo carga
Causa raíz: límites de descriptores de archivo (systemd LimitNOFILE), o límites de backlog del kernel.
Solución: aumenta límites de servicio via drop-in de systemd; valida con /proc/PID/limits; revisa sysctls relacionados con backlog.
4) Síntoma: Muchas sesiones “idle in transaction”
Causa raíz: la aplicación inicia una transacción y luego espera entrada de usuario, trabajo background o llamadas de red.
Solución: establece idle_in_transaction_session_timeout; arregla límites de transacción en la app; considera usar transacciones más cortas y locks explícitos sólo cuando sean necesarios.
5) Síntoma: Tras añadir PgBouncer, algunas funciones dejan de funcionar (tablas temporales, prepared statements)
Causa raíz: el pooling por transacción invalida estado de sesión.
Solución: mueve esas cargas de trabajo a pooling de sesión, o refactoriza la app para evitar estado de sesión; usa prepared statements del lado servidor con cuidado o desactívalos en clientes.
6) Síntoma: PgBouncer “arregla” tormentas pero la latencia se vuelve irregular
Causa raíz: la cola se mueve a PgBouncer; el pool del servidor es demasiado pequeño o las consultas son lentas; la concurrencia excede la capacidad de la BD.
Solución: afina tamaños de pool con cautela; reduce concurrencia de la app; arregla consultas lentas y IO; trata la cola de PgBouncer como una señal, no como un problema para ocultar.
7) Síntoma: Picos de CPU durante tormentas incluso cuando la carga de consultas parece baja
Causa raíz: handshakes TLS y coste de autenticación dominan bajo churn; también la sobrecarga de creación de procesos.
Solución: reduce el churn con un pooler; asegura keepalives; evita conectar-por-petición; considera la estrategia de terminación TLS si la política lo permite.
Listas de verificación / plan paso a paso
Checklist A: Primera hora de contención (detener la hemorragia)
- Confirma si es rechazo de conexión o timeouts de consulta. Usa
pg_stat_activity, errores de la app y chequeos de backlog conss. - Encuentra la fuente cliente dominante. Si un grupo de apps se porta mal, aislarlo (scale down, rollback o throttle).
- Busca locks y “idle in transaction”. Mata al bloqueador solo si entiendes el impacto.
- Revisa límites OS. Descriptores de archivo y límites de systemd son victorias rápidas.
- Reduce la tasa de reconexión. Arregla bucles de retry y añade backoff/jitter; temporalmente aumenta timeouts cliente para reducir thrash.
Checklist B: Arreglo en dos días (hacer que las tormentas sean menos probables)
- Implementa PgBouncer para cargas estilo web y configura pooling por transacción salvo que tengas razones fuertes en contra.
- Establece rieles de seguridad:
idle_in_transaction_session_timeout,statement_timeoutpor roles y timeouts de lock para migraciones. - Limita pools de la app por instancia y documenta la matemática: instancias × tamaño de pool no debe exceder lo que Postgres puede servir.
- Instrumenta el churn: mide la tasa de conexiones, no sólo el conteo de conexiones.
- Realiza una prueba controlada de tormenta en staging: reinicio rolling del fleet de apps y observa backlog, CPU de auth y colas de PgBouncer.
Checklist C: Higiene a largo plazo (dejar de reaprender la misma lección)
- Estandariza ajustes cliente (timeouts, keepalives, retry backoff) como una librería, no como conocimiento tribal.
- Haz las migraciones aburridas: fuerza patrones seguros y separa cambios de esquema de backfills de datos.
- Planifica capacidad sobre “trabajo útil”, no max connections: mide throughput, SLOs de latencia y margen de IO; trata las conexiones como plano de control.
- Ejecuta simulacros de incidentes enfocados en contención de locks y churn de conexiones, porque esos son los que sorprenden a la gente.
Preguntas frecuentes
1) ¿Debo desplegar siempre PgBouncer en Debian 13 para Postgres?
Para la mayoría de cargas OLTP/web: sí, es un valor práctico por defecto. No porque Postgres sea débil, sino porque los clientes son desordenados.
Si tienes cargas dependientes de sesión, aún puedes usar PgBouncer con pooling de sesión o aislar esos clientes.
2) ¿Es subir max_connections alguna vez la decisión correcta?
A veces—cuando estás seguro de tener holgura de memoria, tu carga realmente necesita más concurrencia y el churn de conexiones no es el problema.
Rara vez es la mejor respuesta inicial a una tormenta. Mide la sobrecarga por backend y vigila el swap como un halcón.
3) ¿Por qué mi base de datos se volvió más lenta después de “arreglar” errores de conexión?
Probablemente convertiste una falla por rechazo en una falla por cola. Más backends significa más overhead del scheduler y más riesgo de memoria.
Si no redujiste la demanda real (concurrencia de clientes o consultas lentas), el sistema aún no puede mantenerse—simplemente falla de otra forma.
4) ¿Qué modo de pool debería usar en PgBouncer?
Pooling por transacción para tráfico típico de API. Pooling de sesión solo cuando requieras estado de sesión (tablas temporales, GUCs de sesión,
advisory locks mantenidos entre transacciones, algunos patrones de prepared statements).
5) ¿Cómo sé si mi “tormenta de conexiones” es realmente por locks?
Mira los wait events en pg_stat_activity y el grafo de locks. Si muchas sesiones esperan por eventos Lock y puedes identificar un bloqueador,
estás en un incidente de locks que puede disparar reintentos de conexión. Arregla la causa del lock; no solo añadas pooling.
6) ¿Puede PgBouncer ocultar consultas lentas?
Puede ocultar las señales tempranas al amortiguar a los clientes. Eso es útil para contención. Pero la cola crecerá y la latencia se disparará.
Usa métricas de PgBouncer (clientes esperando, maxwait) como canario que te dice que la base de datos está saturada o bloqueada.
7) ¿Cuál es el ajuste más infravalorado para el radio de blast de una tormenta?
idle_in_transaction_session_timeout. Evita que unas pocas peticiones rotas mantengan locks para siempre y conviertan tu día en arqueología.
8) ¿Necesito tunear parámetros del kernel para tormentas de conexiones en Postgres?
A veces. Si ves saturación del backlog o problemas con la cola SYN, ajustar somaxconn y tcp_max_syn_backlog ayuda.
Pero si tu app conecta como un solo de batería, el tuning del kernel no es un plan de negocio. Arregla el churn con pooling y disciplina de retries.
9) ¿Tiene sentido poner PgBouncer en el host de la base de datos?
Puede tener sentido, y es común. Pero situarlo más cerca de los clientes (por nodo, por clúster o como sidecar) a menudo reduce la carga de handshakes en red
y hace que las tormentas sean menos capaces de cruzar dominios de blast. Elige según simplicidad operativa y dominios de fallo.
10) ¿Cuál es la mejora “rápida y suficiente” si no puedo desplegar un pooler ya?
Limita pools del lado de la app, añade backoff exponencial con jitter y añade timeouts (idle_in_transaction_session_timeout, timeouts de lock para migraciones).
También confirma que los límites de archivos de systemd no te están saboteando.
Siguientes pasos que puedes hacer esta semana
Si actualmente estás apagando incendios por tormentas, haz contención primero: identifica el cliente dominante, detén la avalancha de reintentos y confirma que no estás
atascado por límites OS o contención de locks. Luego elige una solución estructural y desplégala.
- Añade PgBouncer (pooling por transacción) para tu carga principal de app, y verifica el uso observando cómo las direcciones cliente en Postgres colapsan al pooler.
- Establece rieles de seguridad: habilita
idle_in_transaction_session_timeouty añade timeouts por roles donde corresponda. - Arregla el comportamiento de conexión: limita pools por instancia e implementa backoff con jitter en reintentos. Si no puedes describir tu política de reintentos, no tienes una.
- Realiza un ensayo de tormenta en staging: reinicio rolling del fleet de apps y observa backlog con
ss, colas de PgBouncer y wait events de Postgres. - Deja de usar
max_connectionscomo un salvavidas. Manténlo alineado con memoria, CPU y la concurrencia que Postgres puede servir bien.
El objetivo no es “evitar errores”. El objetivo es mantener la base de datos haciendo trabajo útil cuando el resto del sistema tiene un día ruidoso.
Poolers, tuning y comportamiento sensato del cliente cumplen diferentes partes de ese trabajo. Usa la herramienta correcta y tu pager volverá a aburrirse.