Lanzas una pequeña aplicación en un pequeño VPS. Al principio se comporta. Luego, un día, sale un correo de marketing, el tráfico se triplica y tu base de datos se convierte en el portero de una discoteca: “No esta noche.” Mientras tanto tu CPU ni siquiera suda. Tu RAM sí. Los registros de errores empiezan a hablar en lenguas: too many connections, timeouts, picos raros de latencia, reinicios ocasionales de conexiones.
Este es el momento en que la gente descubre el pooling de conexiones. Generalmente bajo presión. Generalmente después de haber culpado a la red, al ORM, a DNS, al proveedor cloud y —si están muy dedicados— a la luna.
La tesis directa: ¿quién necesita pooling antes?
Si ejecutas un VPS único con RAM limitada, PostgreSQL normalmente necesita pooling antes que MySQL por una razón simple: una conexión cliente de Postgres normalmente se corresponde con un proceso backend dedicado, y ese proceso consume memoria no trivial incluso cuando está “idle”. Suficientes conexiones inactivas y tu VPS pasa la vida intercambiando, matando latencia y, eventualmente, tu dignidad.
El modelo por defecto de MySQL (especialmente con InnoDB) suele ser más tolerante con un número moderado de clientes porque no es un proceso pesado por conexión en el mismo sentido, y tiene perillas como el thread caching que pueden suavizar el churn de conectar/desconectar. Pero “más tolerante” no es “inmune”. En un VPS con poca memoria y tráfico explosivo, ambos pueden colapsar por una tormenta de conexiones; Postgres simplemente choca primero, y la pared suele ser la RAM.
Hay una segunda verdad directa: la mayoría de las apps no necesitan pooling con 20 conexiones; necesitan límites sensatos, timeouts y menos reconexiones. Pero una vez que tienes muchos trabajadores de la app y peticiones de corta duración, el pooling deja de ser “optimización” y pasa a ser “cinturón de seguridad”.
Hechos interesantes y un poco de historia (para que dejes de repetirlo)
- La herencia de “un backend por conexión” de PostgreSQL proviene de modelos Unix tempranos y una fuerte preferencia por el aislamiento. Esa arquitectura sigue ahí hoy, aunque el interior haya evolucionado mucho.
- PgBouncer se hizo popular no porque Postgres sea lento sino porque muchas pilas web creaban sesiones de corta duración, y “simplemente aumentar max_connections” se convirtió en un hobby caro.
- El modelo thread-per-connection de MySQL existe desde hace décadas, pero el comportamiento práctico depende mucho del thread caching y la configuración. MySQL/MariaDB modernos pueden manejar mayor churn mejor de lo que su peor reputación sugiere—si los configuras.
- El valor por defecto de
max_connectionsen PostgreSQL es conservador porque cada conexión puede consumir memoria en múltiples contextos (work_mem, buffers, estructuras por backend). El sistema te está empujando hacia el pooling. - El coste del handshake en MySQL solía ser más importante cuando TLS y la autenticación eran más lentos y las apps se reconectaban constantemente. Sigue importando, pero el hardware y las librerías mejoraron.
- “Pooling en la app” se hizo común cuando los frameworks empezaron a ejecutar muchos procesos worker (piensa en servidores pre-fork) y cada worker mantenía su propio pool, multiplicando conexiones de forma invisible.
- El transaction pooling es un instrumento relativamente bruto que sacrifica características a nivel de sesión (como prepared statements y variables de sesión) por supervivencia pura bajo carga. Es un intercambio, no magia.
- Postgres añadió observabilidad más rica con el tiempo (como
pg_stat_activity, eventos de espera y más), lo que irónicamente hace más fácil ver las tormentas de conexiones—y por ende, más fácil entrar en pánico correctamente.
Qué resuelve realmente el pooling de conexiones (y qué no)
Una conexión de base de datos no es una “cadena”. Es una sesión negociada con autenticación, asignaciones de memoria, buffers de socket y—dependiendo de la BD—recursos dedicados en el servidor. Crear y destruir esas sesiones a altas tasas es costoso e impredecible.
El pooling de conexiones resuelve dos problemas:
- Churn: Amortiza el coste de establecer/destruir conexiones a lo largo de muchas peticiones.
- Fan-out: Limita el número de sesiones del lado servidor incluso cuando tu app tiene muchos workers o hilos.
El pooling no resuelve:
- Consultas lentas (puede ocultarlas hasta que alcanzas una cola)
- Índices deficientes
- Contención de locks
- Saturación de IO en disco
- Ejecutar análisis en la misma máquina que tu carga OLTP porque “es solo un informe”
Chiste #1: Un pool de conexiones es como una impresora de oficina compartida—todo el mundo la ama hasta que alguien envía un PDF de 300 páginas y bloquea la cola.
MySQL en un VPS: comportamiento de las conexiones que pica primero
MySQL (y MariaDB) comúnmente se ejecuta como un único proceso servidor que gestiona muchos hilos. Cada conexión cliente suele mapearse a un hilo servidor. Eso puede ser muchos hilos, lo que tiene su propio coste, pero por lo general no explota tu RSS como pueden hacerlo los procesos backend de Postgres en cajas con poca RAM—al menos no con los mismos recuentos de conexión.
Lo que falla primero en MySQL en el mundo VPS tiende a parecerse a:
- Churn por creación de hilos cuando las apps se conectan/desconectan constantemente sin thread caching afinado.
- Agotamiento de max_connections con sesiones “sleeping” de clientes con fugas o pools enormes en la app.
- Colas y timeouts si tu carga está limitada por CPU o IO y las conexiones se amontonan esperando.
MySQL puede tolerar “muchas conexiones” mejor que Postgres en algunos casos, pero también tienta a la gente a un patrón estúpido: subir max_connections y llamarlo planificación de capacidad. En un VPS, así es como cambias “demasiadas conexiones” por “el kernel OOM killer mató mi base de datos”.
MySQL: qué te compra pooling temprano
Si tu app usa muchas peticiones cortas (PHP-FPM, workers tipo serverless, tormentas de cron), el pooling te compra principalmente reducción del overhead del handshake y menos hilos concurrentes. Pero muchas librerías cliente y frameworks para MySQL ya hacen pooling básico, y MySQL suele desplegarse detrás de una sola capa de aplicación donde puedes fijar tamaños de pool sensatos por proceso.
En la práctica: en MySQL, puedes sobrevivir más tiempo sin un pooler externo si tu aplicación hace pooling correctamente y ajustas los parámetros del servidor (thread cache, timeouts, backlogs). Pero “sobrevivir más tiempo” no significa “bien”; significa que tienes tiempo para arreglarlo antes de que el pager empiece a tocar jazz.
PostgreSQL en un VPS: por qué “un backend por conexión” lo cambia todo
La arquitectura de PostgreSQL es famosamente directa: el postmaster acepta una conexión y hace fork (o reutiliza) un proceso backend para manejarla. Una conexión, un backend. Eso es aislamiento limpio, límites de fallo claros y un modelo de costes muy visible.
En un VPS, el modo de fallo es brutalmente consistente:
- Tu app levanta más workers (o más procesos).
- Cada worker mantiene su propio pool (a menudo 5–20 conexiones por defecto).
- El recuento de conexiones se dispara.
- El uso de memoria sube con él.
- La máquina empieza a intercambiar o hacer OOM.
- La latencia se convierte en función de “cuánto tiempo pasamos entrando en pánico”.
Postgres puede manejar cargas pesadas en hardware modesto, pero no le gusta ser tratado como un multiplexor de sockets para una capa de app indisciplinada. Cuando la gente dice “Postgres necesita pooling”, en realidad quieren decir “tu app necesita supervisión adulta”.
Postgres: por qué el pooling no es opcional en cierto punto
Incluso los backends “idle” cuestan memoria. Y los backends ocupados cuestan más, especialmente con ordenamientos, hashes y ajustes por sesión. En instancias VPS pequeñas, unos pocos cientos de conexiones pueden ser catastróficas aun cuando el QPS sea modesto, porque el cuello de botella es la memoria y el cambio de contexto—no el rendimiento bruto de consultas.
Por eso PgBouncer (u otros similares) es tan común: limita las sesiones del lado servidor mientras deja que la app crea que tiene muchas “conexiones”. También te da un lugar central para imponer límites y timeouts. No es glamouroso. Es salvavidas.
Entonces, ¿quién necesita pooling antes en un VPS?
PostgreSQL necesita pooling antes en el escenario típico de VPS porque el coste por conexión en el servidor es mayor y está más ligado a procesos del SO. Si ejecutas una app web con muchos workers, puedes chocar contra el límite con tráfico sorprendentemente bajo: docenas de workers × un pool de 10 cada uno ya son cientos de conexiones.
MySQL necesita pooling antes si el comportamiento del cliente es patológico: muchas conexiones/ desconexiones por petición, sin keepalive, cache de hilos bajo, o si tu capa de app tiene fan-out intenso (muchos trabajos o servicios independientes golpeando la misma BD). MySQL puede caer por churn de conexiones y tormentas de hilos, y también puede sufrir contención de recursos con recuentos enormes de conexiones incluso si la memoria no sube tanto.
Aquí la regla práctica para un VPS:
- Si estás en Postgres y no puedes afirmar con confianza “limitamos las conexiones totales por debajo de 100 y sabemos exactamente por qué”, deberías asumir que necesitas un pooler externo o pooling agresivo en la app ahora.
- Si estás en MySQL y ves churn alto de conexiones, frecuentes
Aborted_connectso picos en hilos en ejecución, necesitas pooling o al menos reutilización disciplinada de conexiones en la app ahora.
Chiste #2: Si tu plan es “simplemente aumentaremos max_connections”, felicitaciones—has reinventado la denegación como servicio.
Opciones de diseño del pooler: pool en la app vs proxy vs pool en el servidor
1) Pooling en la app (integrado en frameworks)
Este es el comportamiento por defecto en muchas pilas: cada proceso mantiene un pool de conexiones abiertas. Es fácil, rápido y preserva la semántica de sesión (prepared statements, tablas temporales, variables de sesión). También multiplica las conexiones por el número de workers de la app. En un VPS, esa multiplicación es cómo te mueres calladamente a las 3 a.m.
Usa pooling en la app cuando:
- Tienes un número pequeño de procesos de la app
- Puedes hacer cumplir límites estrictos por proceso
- Necesitas características a nivel de sesión
2) Pooler/proxy externo (PgBouncer, ProxySQL)
Este es el “adulto en la habitación”. Pones un servicio ligero delante de la base de datos y tu aplicación se conecta a él. El pooler mantiene un número controlado de conexiones al servidor mientras atiende muchas conexiones cliente.
Compensaciones:
- Genial: protege la BD de tormentas de conexiones; centraliza límites; reduce churn.
- No tan genial: la semántica de sesión puede romperse en modos de pooling por transacción/por sentencia.
- Operativo: es otra pieza en movimiento en un VPS pequeño; mantenlo aburrido y monitorizado.
3) “Simplemente aumentar max_connections” (no lo hagas)
Aumentar límites puede ser correcto cuando has medido la memoria por conexión y tienes margen de RAM. Pero en un VPS suele ser un parche en una arteria cortada. Si subes el tope sin cambiar el comportamiento del cliente, no estás aumentando capacidad; estás ampliando el radio del desastre.
Tareas prácticas: comandos, salidas y la decisión que tomas
Estos son los chequeos que ejecuto cuando alguien dice “la base de datos está lenta” pero lo que realmente quieren decir es “las conexiones están fundiendo la caja”. Ejecútalos en el VPS. No adivines. El hardware del VPS es finito y muy honesto al respecto.
Task 1: Check memory pressure and swapping
cr0x@server:~$ free -h
total used free shared buff/cache available
Mem: 3.8Gi 2.9Gi 180Mi 52Mi 720Mi 420Mi
Swap: 1.0Gi 820Mi 204Mi
Qué significa: El swap está muy usado y la memoria disponible es baja. Tu latencia va a parecer un generador de números aleatorios.
Decisión: Trata el recuento de conexiones como sospechoso de inmediato; reduce sesiones concurrentes y considera un pooler externo antes de “tunar consultas”.
Task 2: Identify whether the DB is being OOM-killed
cr0x@server:~$ journalctl -k --since "2 hours ago" | tail -n 20
Dec 29 10:41:12 vps kernel: Out of memory: Killed process 2143 (postgres) total-vm:5216444kB, anon-rss:3102420kB, file-rss:0kB, shmem-rss:0kB
Dec 29 10:41:12 vps kernel: oom_reaper: reaped process 2143 (postgres), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB
Qué significa: El kernel mató a Postgres. Esto no es un “bug de Postgres”. Es matemática de recursos.
Decisión: Mitigación inmediata: limitar conexiones, matar inactivas, habilitar pooling y dejar de añadir workers hasta que la memoria se estabilice.
Task 3: Check TCP connection states to the database port
cr0x@server:~$ ss -tanp | awk '$4 ~ /:5432$/ || $4 ~ /:3306$/ {print $1, $2, $3, $4, $5}' | head
ESTAB 0 0 10.0.0.10:5432 10.0.0.21:51122
ESTAB 0 0 10.0.0.10:5432 10.0.0.21:51130
SYN-RECV 0 0 10.0.0.10:5432 10.0.0.22:60718
TIME-WAIT 0 0 10.0.0.10:5432 10.0.0.23:49810
Qué significa: SYN-RECV sugiere presión en el backlog de accept o que la BD está lenta aceptando conexiones; muchos TIME-WAIT implica churn.
Decisión: Si domina TIME-WAIT, arregla la reutilización/ pool del cliente. Si domina SYN-RECV, revisa el backlog de escucha y la saturación de CPU de la BD.
Task 4: Count live connections by process (PostgreSQL)
cr0x@server:~$ sudo -u postgres psql -c "select state, count(*) from pg_stat_activity group by 1 order by 2 desc;"
state | count
---------+-------
idle | 142
active | 9
| 1
(3 rows)
Qué significa: 142 sesiones idle están asentadas en procesos backend. En un VPS, eso suele ser memoria y cambios de contexto desperdiciados.
Decisión: Añade PgBouncer o reduce el tamaño del pool en la app; además configura idle_in_transaction_session_timeout y revisa el comportamiento de keepalive.
Task 5: Inspect Postgres max_connections and reserved slots
cr0x@server:~$ sudo -u postgres psql -c "show max_connections; show superuser_reserved_connections;"
max_connections
-----------------
200
(1 row)
superuser_reserved_connections
-------------------------------
3
(1 row)
Qué significa: Solo 197 conexiones están disponibles para usuarios normales. Ese límite está ahí por una razón.
Decisión: No subas esto hasta medir la memoria por backend y confirmar que el swap no está involucrado.
Task 6: Measure approximate Postgres backend memory usage
cr0x@server:~$ ps -o pid,rss,cmd -C postgres --sort=-rss | head
PID RSS CMD
2149 178432 postgres: appdb appuser 10.0.0.21(51122) idle
2191 165120 postgres: appdb appuser 10.0.0.21(51130) idle
2203 98204 postgres: appdb appuser 10.0.0.22(60718) active
2101 41288 postgres: checkpointer
2099 18864 postgres: writer
Qué significa: Cada backend es ~100–180MB RSS aquí. En un VPS de 4GB, 50 sesiones así pueden arruinarte la semana.
Decisión: Necesitas pooling y/o bajar ajustes de memoria; además investiga por qué el RSS por backend es tan alto (extensiones, prepared statements, comportamiento de work_mem).
Task 7: Check MySQL connection counts and running threads
cr0x@server:~$ mysql -e "SHOW GLOBAL STATUS LIKE 'Threads_%';"
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_cached | 32 |
| Threads_connected | 180 |
| Threads_created | 9124 |
| Threads_running | 14 |
+-------------------+-------+
Qué significa: Muchas conexiones, hilos en ejecución moderados, y un Threads_created alto indica churn (dependiendo del tiempo de actividad).
Decisión: Incrementa thread_cache_size, reduce las reconexiones de la aplicación y limita los pools. Si el churn es intenso, considera ProxySQL o arreglos de pooling en la app primero.
Task 8: Check MySQL max_connections and aborted connects
cr0x@server:~$ mysql -e "SHOW VARIABLES LIKE 'max_connections'; SHOW GLOBAL STATUS LIKE 'Aborted_connects';"
+-----------------+-------+
| Variable_name | Value |
+-----------------+-------+
| max_connections | 200 |
+-----------------+-------+
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| Aborted_connects | 381 |
+------------------+-------+
Qué significa: Aborted connects pueden ser problemas de autenticación, resets de red o tormentas de conexiones que chocan con límites/timeouts.
Decisión: Si sube durante picos de tráfico, tu app se está reconectando demasiado o está siendo limitadA. Arregla pooling; luego ajusta timeouts y backlog.
Task 9: Find who is opening the connections (server-side view)
cr0x@server:~$ sudo lsof -nP -iTCP:5432 -sTCP:ESTABLISHED | awk '{print $1,$2,$9}' | head
postgres 2149 TCP 10.0.0.10:5432->10.0.0.21:51122
postgres 2191 TCP 10.0.0.10:5432->10.0.0.21:51130
postgres 2203 TCP 10.0.0.10:5432->10.0.0.22:60718
Qué significa: Puedes mapear las fuentes de conexión por IP/puerto; combínalo con logs de la app o discovery de servicios para identificar al vecino ruidoso.
Decisión: Si un host domina, limítalo allí primero. No castigues a toda la flota porque un job runner se porta mal.
Task 10: Check Postgres for idle-in-transaction sessions
cr0x@server:~$ sudo -u postgres psql -c "select pid, usename, state, now()-xact_start as xact_age, left(query,80) as query from pg_stat_activity where state like 'idle in transaction%' order by xact_start asc limit 5;"
pid | usename | state | xact_age | query
------+----------+----------------------+------------+------------------------------------------------------------------------------
3012 | appuser | idle in transaction | 00:12:41 | UPDATE orders SET status='paid' WHERE id=$1
(1 row)
Qué significa: Una conexión manteniendo una transacción abierta puede bloquear vacuum, causar bloat en tablas y generar contención de locks que se parece a “las conexiones son lentas”.
Decisión: Arregla la gestión de transacciones en la app; configura idle_in_transaction_session_timeout y considera el transaction pooling con cuidado (puede enmascarar bugs).
Task 11: Validate backlog and listen settings on Linux
cr0x@server:~$ sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 4096
Qué significa: Los límites de backlog son razonables. Si aún ves muchos SYN-RECV, la app/BD puede ser demasiado lenta aceptando o está falta de CPU.
Decisión: Si los valores son pequeños (p. ej., 128), súbelos; pero no uses tuning del kernel para evitar arreglar tormentas de conexiones.
Task 12: Check Postgres wait events and top queries (high-level)
cr0x@server:~$ sudo -u postgres psql -c "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 | 6
IO | DataFileRead | 3
(2 rows)
Qué significa: Tu “problema de conexiones” podría ser en realidad contención de locks o stalls de IO. El pooling no arregla eso; solo encola el dolor.
Decisión: Si los locks dominan, busca transacciones largas y filas calientes. Si IO domina, revisa la latencia de disco y las tasas de hit de cache antes de tocar el pooling.
Task 13: Check MySQL process list for sleeping floods
cr0x@server:~$ mysql -e "SHOW PROCESSLIST;" | head
Id User Host db Command Time State Info
412 appuser 10.0.0.21:51912 appdb Sleep 287 NULL
413 appuser 10.0.0.21:51920 appdb Sleep 290 NULL
444 appuser 10.0.0.22:38110 appdb Query 2 Sending data SELECT ...
Qué significa: Muchos Sleep significa clientes manteniendo conexiones abiertas. Eso es normal con pooling, pero debe estar acotado.
Decisión: Si el número de Sleep es enorme y alcanzas max_connections, reduce tamaños de pool, añade disciplina de pooling o usa un pooler proxy para limitar sesiones servidor.
Task 14: Verify application fan-out from the OS side
cr0x@server:~$ ps -eo pid,cmd | egrep 'gunicorn|puma|php-fpm|sidekiq|celery' | head
1021 /usr/bin/puma 5.6.7 (tcp://0.0.0.0:3000) [app]
1033 sidekiq 6.5.9 app [0 of 10 busy]
1102 php-fpm: pool www
1103 php-fpm: pool www
Qué significa: Configuraciones con muchos workers multiplican pools. Si cada uno de esos procesos tiene un pool de 10, acabas de crear una fábrica de conexiones.
Decisión: Suma tus peores conexiones: workers × pool_size. Si supera el límite seguro de tu BD, necesitas un pooler o reducir la huella.
Guía rápida de diagnóstico
Cuando estás en un VPS, el objetivo no es el análisis perfecto. El objetivo es detener la hemorragia e identificar correctamente el cuello de botella dominante antes de “optimizar” lo incorrecto.
Primero: confirma si es presión de conexiones o presión de consultas/IO
- Memoria + swap:
free -h. Si el swap está cargado, el recuento de conexiones es culpable hasta que se demuestre lo contrario. - Recuento de conexiones: Postgres
pg_stat_activity, MySQLThreads_connected. - Logs del kernel: OOM kills o errores TCP.
Segundo: determina si las conexiones están idle, bloqueadas o realmente ocupadas
- Postgres: cuenta
idlevsactivevsidle in transaction. - MySQL:
SHOW PROCESSLIST, busca muchos Sleep vs muchos “Sending data” o “Locked”.
Tercero: si están ocupadas, identifica la espera dominante
- Locks: transacciones largas, filas calientes, índices faltantes.
- IO: disco lento, cache insuficiente, demasiadas lecturas aleatorias.
- CPU: demasiadas consultas concurrentes; el pooling puede ayudar limitando la concurrencia, pero es un limitador, no una solución.
Cuarto: aplica la mitigación de menor riesgo
- Limita la concurrencia de la app (workers/hilos).
- Activa un pooler externo (PgBouncer/ProxySQL) si la BD se ahoga en sesiones.
- Reduce el churn: keepalive, timeouts sensatos, reutiliza conexiones.
Tres micro-historias del mundo corporativo (anonimizadas, pero dolorosamente reales)
Incidente: la suposición equivocada que causó una avalancha de conexiones
Un equipo SaaS mediano ejecutaba Postgres en un VPS potente—pero no tanto. La capa de aplicación era una mezcla de workers web y background. Durante un lanzamiento de producto, los tiempos de respuesta pasaron de “bien” a “congelado”. Sus dashboards mostraban CPU al 40%. El ingeniero on-call, con razón, culpó al balanceador y empezó a buscar caídas de red.
El problema real era una suposición silenciosa: “Nuestro ORM hace pooling de conexiones”. Cierto. También incompleto. Cada proceso worker tenía su propio pool, y el despliegue aumentó el número de workers para manejar tráfico. Las conexiones totales se multiplicaron, Postgres hizo fork de un backend por cada una, la memoria subió, empezó el swapping y entonces el kernel empezó a matar procesos. El balanceador no hizo nada malo; solo miraba el incendio.
La solución no fue exótica. Limitaron el tamaño del pool por proceso, redujeron temporalmente los workers e insertaron PgBouncer en modo session pooling. De repente la BD dejó de multiplicarse hasta la autodestrucción. También pusieron una alerta dura en conexiones totales y uso de swap. La lección no fue “PgBouncer es la solución”. La lección fue: cuenta tus conexiones como cuentas CPUs—a nivel global.
Optimización que salió mal: transaction pooling agresivo sin higiene en la app
Un equipo de e-commerce quiso “hacer escalar Postgres” en un VPS sin subir de plan. Desplegaron PgBouncer en transaction pooling porque parecía la opción más eficiente. El recuento de conexiones se estabilizó. Todos celebraron. Luego llegaron bugs raros: “prepared statement does not exist” intermitente, “current transaction is aborted” ocasional y algunos pagos quedaron en limbo.
Tuvieron dos problemas. Primero, la app dependía del estado de sesión: prepared statements y ajustes por sesión. En transaction pooling, un cliente puede no obtener la misma conexión servidor en la siguiente transacción, así que el estado de sesión se vuelve poco fiable a menos que adaptes la app. Segundo, tenían manejo de transacciones descuidado: algunas rutas de código dejaban transacciones abiertas más tiempo del esperado, y el pooler amplificó los síntomas al repartir clientes entre menos backends servidor.
Finalmente cambiaron a session pooling para la app principal, mantuvieron transaction pooling para un job runner sin estado, y arreglaron los límites de las transacciones en el código. El rendimiento fue un poco menor que el “teórico máximo”, pero la corrección volvió. El VPS permaneció estable. Fue un recordatorio caro de que “modo de pooling” es un contrato de la aplicación, no un ajuste del servidor.
Práctica aburrida pero correcta que salvó el día: presupuestos estrictos y timeouts
Una empresa financiera ejecutaba MySQL en un VPS pequeño para una herramienta interna. La herramienta no era “crítica” hasta que, inevitablemente, lo fue. Tenían un hábito que les hacía parecer paranoicos: una hoja de cálculo de presupuesto de conexiones. No era sofisticado. Solo una tabla: número de procesos de la app, tamaño de pool por proceso, conexiones pico esperadas y el margen respecto a max_connections.
Cuando introdujeron un nuevo job por lotes, se requería declarar su uso de conexiones e implementar backoff exponencial en intentos de conexión. También tenían timeouts sensatos: timeout de conexión, timeout de consulta en el lado cliente y timeouts de inactividad en el servidor para evitar sesiones zombis.
En un cierre de trimestre, el job por lotes se ejecutó tarde, la UI web tuvo más uso y una caída de red provocó tormentas de reconexión. El sistema no cayó. Se degradó: hubo colas, algunas peticiones expiraron rápido y la base de datos se mantuvo arriba. La práctica aburrida—presupuestos explícitos y timeouts forzados—evitó una falla en cascada. Nadie recibió una medalla. Así sabes que funcionó.
Errores comunes: síntomas → causa raíz → solución
1) “Demasiadas conexiones” aparece tras escalar workers
Síntomas: errores repentinos tras deploy; CPU de la BD se ve bien; la memoria sube; muchas sesiones idle.
Causa raíz: pools por proceso multiplicados por el aumento de workers. El total superó la capacidad de la BD.
Solución: limitar tamaño de pool por worker; reducir número de workers; añadir PgBouncer/ProxySQL; fijar presupuestos globales de conexiones y alertas.
2) Picos de latencia con muchos sockets TIME-WAIT
Síntomas: muchas conexiones de corta duración; tráfico con handshake intensivo; timeouts intermitentes.
Causa raíz: conectar/desconectar por petición; no hay keepalive; churn de hilos/procesos del servidor.
Solución: activar pooling en la app; mantener conexiones calientes; ajustar thread cache en MySQL; considerar un pooler; establecer timeouts y reintentos con backoff en el cliente.
3) Postgres hace OOM aunque las consultas “no sean tan pesadas”
Síntomas: kills del kernel por OOM; swap sube; muchas sesiones idle; VPS inservible.
Causa raíz: demasiados backends; huella de memoria por backend; a veces work_mem grande combinado con ordenamientos/hashes concurrentes.
Solución: reducir max_connections con un pooler; bajar work_mem o limitar concurrencia; dejar de tratar max_connections como throughput.
4) Después de agregar PgBouncer, la app falla de formas extrañas
Síntomas: errores de prepared statement; ajustes de sesión no aplicados; tablas temporales ausentes.
Causa raíz: usar transaction/statement pooling mientras la app depende de semántica de sesión.
Solución: cambiar a session pooling; o refactorizar la app para evitar estado de sesión; asegurar que los drivers son compatibles con el modo de pooling.
5) “La base de datos está lenta” pero las conexiones activas son pocas
Síntomas: bajo recuento de conexiones; tiempos de respuesta altos; altas esperas de IO o locks.
Causa raíz: no es un problema de conexiones—contención de locks, saturación de IO, índices faltantes, transacciones largas.
Solución: investiga wait events, logs de consultas lentas, locks; optimiza esquema/consultas; añade caching; mueve trabajos pesados fuera del VPS.
6) La cola del pool retiene peticiones incluso con QPS bajo
Síntomas: tiempo de espera en el pool alto; la BD muestra pocas consultas activas; hilos de la app bloqueados esperando conexión.
Causa raíz: tamaño del pool demasiado pequeño para la concurrencia; o conexiones con fugas (no devueltas); o transacciones largas que atrapan conexiones.
Solución: detectar fugas; establecer timeouts de checkout del pool; ajustar el tamaño del pool; reducir el alcance de las transacciones; añadir circuit breakers.
Listas de verificación / plan paso a paso
Paso a paso: decidir si necesitas un pooler externo en un VPS
- Cuenta los workers de la app en los hosts VPS/app y multiplícalos por el tamaño configurado del pool. Si no lo sabes, ya tienes un problema.
- Mide las conexiones reales a la BD (
pg_stat_activityo variables de estado de MySQL). - Revisa el margen de memoria y el swap. Si el swap es no trivial bajo carga, trata las conexiones como sospecha principal.
- Clasifica las conexiones: idle vs active vs idle-in-transaction.
- Si es Postgres y hay muchas idle: añade PgBouncer, limita agresivamente conexiones servidor y aplica topes en la app.
- Si es MySQL y hay churn de hilos/conexiones: arregla la reutilización en la app, ajusta thread cache y limita pools; considera ProxySQL si tienes muchos clientes.
- Establece timeouts que fallen rápido: timeout de conexión cliente, timeout de consulta y timeouts de inactividad del servidor.
- Configura alertas en: conexiones totales, uso de swap, eventos OOM, y tiempo de espera en el pool.
Paso a paso: “hacerlo estable esta noche” mitigación
- Reduce temporalmente la concurrencia de la app (workers/hilos).
- Reduce tamaños de pool en la configuración de la app. Reinicia procesos de la app para aplicar.
- Si Postgres está haciendo swap: despliega PgBouncer con un
default_pool_sizeconservador y limita conexiones servidor. - Mata actores malos obvios: sesiones idle-in-transaction; jobs en fuga.
- Valida que los errores de conexión bajen y la latencia se normalice.
- Sólo entonces sigue con tuning de consultas e índices.
Paso a paso: dimensionar conexiones sensatamente en un VPS
- Empieza por la caja: cuánta RAM puede usar el proceso de BD sin hacer swap.
- Estima el coste por conexión: para Postgres, mide el RSS de backends bajo carga representativa.
- Fija un tope duro: las conexiones servidor de Postgres suelen ser mucho menores que las conexiones cliente; para eso están los poolers.
- Prefiere encolamiento a caída: una cola de pool es molesta; un OOM es un reinicio del servicio con pasos extra.
FAQ
1) ¿Es siempre necesario el pooling en un VPS?
No. Si tienes un único proceso de app con un pool pequeño y estable y bajo churn, puede que no necesites un pooler externo. Pero aun así necesitas límites y timeouts.
2) ¿Por qué PostgreSQL “necesita” PgBouncer con tanta frecuencia?
Porque cada conexión se mapea a un proceso backend con sobrecarga de memoria significativa. El pooling te permite servir muchas sesiones cliente con menos backends servidor, que es precisamente lo que necesita un VPS limitado en RAM.
3) ¿Por qué no simplemente aumentar max_connections en PostgreSQL?
Puedes hacerlo, pero frecuentemente es la movida equivocada en un VPS. Más conexiones incrementan la presión de memoria y el cambio de contexto. Si ya estás cerca del swap, subir el tope puede convertir “errores” en “caída”.
4) ¿MySQL tiene un equivalente a PgBouncer?
Ecosistema diferente, idea similar. ProxySQL se usa comúnmente como capa proxy/pooler, y muchas aplicaciones dependen del pooling en el cliente. Si necesitas un proxy depende de cuántos clientes independientes tengas y cuán disciplinados sean los pools de tu app.
5) ¿Cuál es el mejor modo de pooling en PgBouncer?
El session pooling es el más seguro para compatibilidad. El transaction pooling es potente pero rompe supuestos a nivel de sesión; úsalo solo si tu app es stateless a nivel de sesión y has probado los casos límites.
6) ¿El pooling puede ocultar consultas lentas?
Sí. Puede hacer que el sistema parezca “estable” mientras las peticiones esperan detrás de un número pequeño de conexiones servidor. Eso es mejor que caer, pero aún tienes que arreglar las consultas lentas o la contención de locks.
7) Mi base de datos muestra muchas conexiones idle. ¿Es eso malo?
No inherentemente. Es malo cuando las conexiones idle consumen recursos que no puedes permitirte (Postgres en un VPS pequeño) o cuando te empujan al agotamiento de max_connections. Idle está bien; idle sin límite no.
8) ¿Sobre qué debo alertar para detectar esto temprano?
Como mínimo: conexiones totales, conexiones activas, uso de swap, eventos OOM killer, tiempo de espera en el pool (métricas de la app) y tasa de errores por fallos/timeouts de conexión.
9) ¿Debo pooler en la app y además usar un pooler externo?
A veces. Puede funcionar bien si los pools en la app son pequeños y el pooler externo hace cumplir un tope global. El peligro es doble encolamiento y latencia confusa. Mantén el diseño simple y observable.
10) Una frase para el camino—¿cuál es la mentalidad de fiabilidad aquí?
Idea parafraseada, atribuida a John Allspaw: la fiabilidad viene de diseñar sistemas que fallen de forma controlada, no de esperar que nunca fallen.
Conclusión: pasos prácticos siguientes
En un VPS, el pooling de conexiones es menos sobre exprimir rendimiento y más sobre prevenir una autodenegación de servicio. PostgreSQL suele requerir pooling antes porque las conexiones del lado servidor son más pesadas. MySQL a menudo te da más pista, pero aún castiga el churn y el fan-out sin límites.
Haz lo siguiente:
- Escribe tu presupuesto de conexiones: workers de la app × tamaño del pool, más jobs en background.
- Mide la realidad: conexiones actuales, idle vs active y uso de memoria bajo carga.
- Capar y encolar: prefiere una cola controlada del pool a una espiral incontrolada de memoria.
- Añade un pooler cuando la matemática lo indique: PgBouncer para Postgres, o una estrategia de proxy para MySQL si el recuento de clientes es caótico.
- Instrúyelo: alerta sobre conexiones, swap y tiempo de espera en el pool. La primera vez que lo detectes temprano pagará el esfuerzo.