
Nadie tocó el código. El deploy fue limpio, los tests en verde, todo saludando. Y aun así, una consulta que respondía en 0.7 segundos amaneció tardando 33. La culpa no era del código nuevo: era de la tabla más sana del sistema.
El misterio: nada cambió y todo se puso lento
Lo vivimos en un dashboard de producción real durante el release de una plataforma nueva. La actualización pasó todos los filtros: suite completa de 786 checks en verde, pruebas de humo, revisión de rutas críticas. Horas después del cambio, una sola ruta —el listado de productos— tardaba 33 segundos en cargar. El resto de la aplicación respondía igual de rápido que siempre.
Lo primero que cualquiera supone: “el código nuevo es más lento”. Hicimos la prueba que desmonta esa teoría en diez minutos: ejecutamos la versión anterior y la nueva en la misma máquina, contra la misma base de datos. La ruta vieja respondía en 0.7 segundos. La nueva, en 33. Misma máquina, misma DB, misma consulta. El código no era el problema — pero algo había cambiado debajo.
La pista llegó de la columna más aburrida del catálogo del sistema: la fecha del último ANALYZE de la tabla más grande del sistema. Una tabla de snapshots de inventario con 1.1 millones de filas, que muta unos 70,000 registros todos los días, llevaba días sin que PostgreSQL volviera a mirar sus estadísticas. Y las estadísticas son justo lo que el planner usa para decidir cómo ejecutar cada consulta.
Cómo decide tu base de datos (y por qué miente)
PostgreSQL no ejecuta tus consultas tal como las escribes. Antes de tocar un solo dato, el planner estima cuántas filas devolverá cada paso y elige el camino más barato: ¿índice o recorrido completo? ¿merge o hash join? ¿ordenar en memoria o en disco? Esas estimaciones salen de una muestra estadística que PostgreSQL guarda en el catálogo del sistema — y que solo se refresca cuando alguien corre ANALYZE.
La documentación oficial lo dice sin rodeos:
ANALYZE collects statistics about the contents of tables in the database, and stores the results in the pg_statistic system catalog. Subsequently, the query planner uses these statistics to help determine the most efficient execution plans for queries.
— Documentación oficial de PostgreSQL,
ANALYZE
Traducido al problema nuestro: si la muestra estadística dice “esta tabla tiene la forma que tenía hace diez días”, el planner planifica para una tabla que ya no existe. En nuestro caso, estimó tan mal la selectividad de un filtro que descartó el índice, eligió un recorrido secuencial completo de 1.1 millones de filas y un ordenamiento que no cabía en memoria: el sort terminaba derramándose a disco. De ahí los 33 segundos.
La matemática del autovacuum que traiciona
La parte contra-intuitiva del asunto: el default de PostgreSQL está pensado para la tabla promedio, y tus tablas más importantes no son la tabla promedio. El autovacuum no corre ANALYZE cada día porque sí: dispara cuando las mutaciones acumuladas superan un umbral proporcional al tamaño de la tabla. La documentación oficial describe el mecanismo:
The table is [analyzed] if the number of tuples modified exceeds the “analyze threshold”, defined as:
autovacuum_analyze_base_threshold + autovacuum_analyze_scale_factor × number of live tuples.— Documentación oficial de PostgreSQL, Routine Vacuuming
Hagamos la cuenta con nuestra tabla. Con los defaults (base threshold 50, scale factor 0.1) sobre 1.1 millones de filas vivas, el umbral queda en 50 + 0.1 × 1,100,000 = 110,050 tuplas. El registro del sistema contó la escena del crimen: en los 10 días desde el último autoanalyze se acumularon 70,523 tuplas modificadas — sin llegar al umbral de 110K. Ahí está la trampa completa: el umbral es proporcional al tamaño de la tabla, no al ritmo con que su forma cambia. Una tabla puede acumular semana y media de deriva — con la distribución de sus datos volviéndose irreconocible para las estadísticas viejas — mientras el contador de mutaciones todavía “no justifica” un analyze. El default asume que una tabla que muta despacio no cambia de forma; nuestra tabla demostró lo contrario todos los días.
Durante esos 10 días, la forma real de la tabla cambió tanto que las estimaciones del planner quedaron en otro universo. Y esto no solo nos pasó a nosotros: hay war stories públicas de equipos que vivieron meses con el mismo síntoma — consultas lentas “sin explicación”, autovacuum saltándose justo las tablas más calientes, y un planner tomando decisiones con estadísticas viejas mientras EXPLAIN ANALYZE en staging (con stats frescas) mostraba todo perfecto.
Diagnóstico en 3 comandos
La buena noticia: todo el diagnóstico cabe en tres consultas. La primera te dice cuán stale está cada tabla:
| Qué mirar | Comando | Señal de alarma |
|---|---|---|
| Último analyze y mutaciones pendientes | SELECT relname, last_analyze, n_mod_since_analyze, n_live_tup FROM pg_stat_user_tables ORDER BY n_mod_since_analyze DESC; | n_mod_since_analyze alto y last_analyze con días/meses de antigüedad |
| El plan real que está ejecutando | EXPLAIN (ANALYZE, BUFFERS) SELECT …; | Seq Scan sobre una tabla grande + external merge (sort derramado a disco) |
| Los umbrales vigentes | SELECT relname, reloptions FROM pg_class WHERE relname = 'tu_tabla'; | Sin reloptions ⇒ la tabla usa defaults globales |
En nuestro caso, la primera consulta contó la historia completa: last_analyze fechado 10 días atrás, 70,523 modificaciones desde entonces, y la consulta crítica mostrando Seq Scan + external merge sort. Plan roto, causa encontrada.
El fix en dos capas: ANALYZE y tuning por tabla
La reparación tiene una capa inmediata y una duradera. La inmediata es literalmente un comando:
ANALYZE tabla_de_snapshots;
Resultado medido: la ruta pasó de 33 segundos a 0.29 segundos — sin tocar una línea de código. Pero el ANALYZE manual es medicina de urgencia: el problema volvería cuando las estadísticas volvieran a envejecer. La capa duradera es bajar los umbrales por tabla para que el autovacuum refresque stats mucho más seguido en las tablas de alta mutación:
ALTER TABLE tabla_de_snapshots SET (autovacuum_analyze_scale_factor = 0.02, autovacuum_vacuum_scale_factor = 0.05);
Con scale factor 0.02, el umbral de analyze de nuestra tabla baja de ~110,000 a ~22,000 tuplas: con 70K mutaciones diarias, el refresco pasa de “cada semana y media si todo sale mal” a “varias veces al día”. Aplicado en las dos bases de datos del entorno, la ruta se mantuvo estable entre 0.06 y 0.29 segundos desde entonces.
Un matiz honesto: ANALYZE sobre una tabla gigante no es gratis — toma muestras leyendo páginas reales, y en el peor momento puede sumar ruido a una ventana cargada. La práctica sana es refrescar en horas valle o dejar que el autovacuum afinado lo haga solo, que es justo lo que el tuning por tabla consigue.
La regla operativa que nos quedamos
Las tablas de alta mutación merecen tuning por tabla; los defaults son para la tabla promedio que casi nadie tiene. Si una tabla cambia decenas de miles de filas al día, revisa su last_analyze hoy mismo — es una consulta de un segundo que puede explicar un misterio de semanas. Y cuando una consulta “se puso lenta de repente” sin cambios de código, antes de culpar al último deploy, pregunta qué cambió en los datos, no en el código: el planner planifica contra la forma de tus tablas, y esa forma cambia todos los días aunque tú no toques nada.
Esta es una de las dos lecciones que nos dejó el mismo release; la otra —cómo diseñar un despliegue para poder deshacerlo sin dramas— la contamos en Despliegue a producción sin miedo: build once, canary y rollback real. Si te gusta el método de medir antes de optimizar, en cómo optimizamos MiniV de 310 a 92 segundos aplicamos el mismo músculo a un problema de inferencia local, y en qué hace que un modelo local sirva para programar hay más war stories de bugs reales que solo la evidencia descifra. Para el principio de fondo — pruebas reales sin humo — nuestra base está en IA local sin humo.
Preguntas frecuentes
¿Por qué una consulta de PostgreSQL se vuelve lenta “de repente” sin cambios de código?
La causa más común es que las estadísticas del planner quedaron viejas: los datos mutaron pero el ANALYZE no corrió, y el planner estima con la forma antigua de la tabla. El resultado típico es un Seq Scan donde antes había índice, o un sort que se derrama a disco. Revísalo con pg_stat_user_tables antes de culpar al último deploy.
¿Qué hace exactamente el comando ANALYZE?
Toma una muestra estadística del contenido de las tablas y la guarda en el catálogo del sistema. El planner usa esa muestra para estimar cuántas filas devuelve cada operación y elegir el plan de ejecución más barato. Sin stats frescas, las estimaciones —y por tanto los planes— se degradan.
¿Qué valores de autovacuum_analyze_scale_factor debo usar?
Depende del patrón de escritura. Para tablas grandes con alta mutación diaria, bajar el scale factor (0.02–0.05) hace que el refresco corra varias veces al día en vez de cada semanas. Aplícalo por tabla con ALTER TABLE … SET, no globalmente: en tablas pequeñas o casi estáticas el default funciona bien y no quieres analyze corriendo de más.
¿ANALYZE bloquea mi tabla?
Usa locks livianos y no reescribe la tabla, pero sí genera I/O de lectura para muestrear. En tablas muy grandes puede notarse en horas pico; lo sano es dejar que el autovacuum afinado lo ejecute distribuido, o correrlo manual en ventanas valle.
¿Cómo sé si mi tabla sufre esto hoy?
Una consulta: SELECT relname, last_analyze, n_mod_since_analyze, n_live_tup FROM pg_stat_user_tables ORDER BY n_mod_since_analyze DESC LIMIT 10;. Si ves tu tabla más crítica con n_mod_since_analyze en decenas de miles y un last_analyze antiguo, tienes el mismo fantasma que nosotros.
El conocimiento verdadero trasciende a lo público 🌀
¿Quieres seguir la línea de investigación? Continúa con artículos relacionados y guarda esta lectura para volver después.
Ver relacionados


