Cuatro veces en tres semanas
La alerta llegaba siempre igual: CPU del PostgreSQL de producción por encima del 80%. Cuando alguien abría el gráfico, la base ya estaba en 97, 98, 99% del límite. Todos los clientes sentían la plataforma lenta al mismo tiempo. Y entonces, una hora después, sin que nadie hubiera tocado nada, la CPU bajaba y la alerta se resolvía sola.
La primera vez, eso parece suerte. La cuarta vez en tres semanas, es un patrón. Decidí tratar las cuatro ocurrencias como un único problema y usar cada repetición para ver un poco más.
Al principio, la base no veía nada
En la primera ocurrencia solo tenía las estadísticas del propio PostgreSQL. Mostraban cosas que valía la pena corregir (tablas sin índice en la columna de filtro, conexiones ociosas sin pooler, una versión sin soporte), pero lo que más me preocupó no tenía relación directa con la CPU: la base no tenía réplica ni recuperación a un punto en el tiempo. Si fallaba el disco, la empresa podía perder hasta seis horas de datos. Lo registré como prioridad antes de volver al síntoma.
En la segunda ocurrencia, el EXPLAIN del listado paginado mostró que cada llamada ordenaba unos 400 MB en disco. La base ya había generado 32 TB de archivos temporales en menos de un mes. Medí las transacciones minuto a minuto durante el pico y la línea se mantuvo plana. El tráfico no había crecido. Eran las mismas pocas consultas, doscientas veces más pesadas.
Para descubrir quién hacía esas consultas necesitaba el log de queries lentas, que estaba apagado. Lo encendí con reload en caliente, sin reiniciar la base ni tirar ninguna conexión. El umbral de 10 MB para registrar archivos temporales no fue al azar: era el valor que separaba el funcionamiento normal del incidente en mis mediciones.
El causante tiene nombre
Se creó el índice recomendado y el sort en disco desapareció. Aun así, en la tercera ocurrencia la CPU volvió al 98%.
Esta vez, el log que yo había encendido mostró los parámetros de las consultas, y apareció la respuesta: el 99,97% de las ejecuciones lentas venía de un único cliente, con unos 140 mil registros, paginando el listado de principio a fin. Las filas leídas por segundo habían subido 69 veces con el tráfico estable. Parte de eso venía de un detalle pequeño: un count(i.*) obligaba a la base a armar la fila entera solo para contarla. Cambiarlo por count(*) permitía leer solo el índice.
También mostré que el número que importaba no era el porcentaje de CPU, sino el throttling: la base estaba congelada en el 97% de sus ventanas de 100 ms, y eso volvía lentos a todos los clientes, no solo al que causaba el problema.
Y el incidente terminó solo una vez más. Ahora sabía por qué: el cliente había dejado de paginar. La causa seguía en el código, y el disparador dependía de alguien de afuera. Iba a volver a pasar.
Separar lo que es de cada equipo
El equipo de desarrollo aplicó el cambio del count, y funcionó. En la cuarta ocurrencia, lo que quedaba era la paginación por OFFSET: para devolver 15 filas, una llamada leía 119.145.
Ahí la conversación podía convertirse en infraestructura y desarrollo señalándose mutuamente, así que separé con números lo que le tocaba a cada lado. El código definía el techo del costo, y ningún ajuste de infraestructura cambiaría eso. La infraestructura funcionaba como multiplicador: con su disparador por defecto, el autovacuum dejaba el visibility map desactualizado y anulaba parte de la mejora que el equipo ya había entregado.
También corregí la prioridad que se le había pasado al equipo. El conteo respondía por cerca del 92% de la lectura, y el OFFSET, por el 8%. Sin ese número, habrían cambiado solo la paginación y dejado la mayor parte de la carga donde estaba.
Para no depender de otro pico, propuse una alerta que avisa antes: filas leídas por scan en esa tabla, un indicador que sube antes de que la CPU pase del 80% y que no se dispara cuando el tráfico crece de forma legítima.
El mismo patrón en otro producto
Después, la misma firma apareció en una plataforma de agentes de IA. Esta vez empecé por el borde: las solicitudes por segundo en el ingress seguían la CPU de la base minuto a minuto. La pregunta dejó de ser "qué se rompió en la base" y pasó a ser "quién está llamando". Era un único cliente de la API pública releyendo 641 páginas de historial en bucle.
Limitar a ese cliente era una decisión de producto, así que también armé un plan que no dependía de él: caché de respuesta con invalidación por versión (según las llamadas reales del pico, el acierto teórico quedaba entre el 80% y el 94%), caché de la validación del token y un límite de consultas simultáneas a la base.
Qué cambió
La causa dejó de ser un "pico aislado" y se convirtió en una línea de código con dueño. El sort en disco pasó de 215 MiB/s de archivos temporales a cero, y el conteo empezó a leer solo el índice. La base ganó la observabilidad que no tenía (log de queries lentas, recolección de logs y paneles de saturación) sin ningún restart en producción. Y los riesgos más graves que el síntoma, como la falta de réplica y de recuperación a un punto en el tiempo, entraron en la lista de prioridades.
Lo que me llevo
Cuando un incidente desaparece sin que nadie haga nada, la pregunta correcta es qué se detuvo, y no qué lo arregló. Cada repetición es una oportunidad de instrumentar un poco más: fue el log encendido en la segunda ocurrencia lo que le puso nombre al causante en la tercera.
También aprendí que, cuando la causa está repartida entre infraestructura y código, el número es lo que evita que cada lado espere al otro.