Por qué Postgres va lento con pocas filas: causas comunes

Panel de monitorización mostrando picos de latencia en una base de datos relacional

Nos llegó hace unos meses un ticket que resumía el problema mejor que cualquier manual: La tabla tiene 480 registros y la consulta tarda 1,8 segundos. El equipo que lo reportó llevaba dos semanas añadiendo índices. Ninguno sirvió de nada.

Lo decimos sin rodeos: cuando un motor relacional se arrastra con un conjunto de datos minúsculo, el volumen nunca es la causa. Es el síntoma de otra cosa, y esa otra cosa casi siempre está fuera del lugar donde todo el mundo mira primero. Entonces, ¿por qué Postgres va lento con pocas filas?

¿Qué ocurre cuando el disco almacena 400 MB para guardar medio millar de entradas? Que llevas meses pagando una factura que nadie ha contabilizado. En las auditorías que hacemos, cuatro de cada cinco casos de «base de datos lenta con pocos datos» se resuelven sin tocar una sola línea de SQL.

Vamos a repasar las cinco causas reales, en el orden en que aparecen, y cerraremos con el protocolo exacto que seguimos para aislarlas en menos de diez minutos.

Los síntomas que delatan que el problema no es el volumen de datos

Con pocas filas, la lentitud casi nunca procede del volumen de datos: procede del espacio muerto acumulado por escrituras repetidas, de un plan genérico fijado por el planificador o del tiempo que se gasta fuera del motor, en viajes de ida y vuelta y apertura de conexiones.

¿A qué escala empieza realmente a ralentizarse el gestor? En instalaciones sanas, no antes de decenas de millones de registros. Por debajo de ese orden de magnitud un escaneo secuencial completo se resuelve en milisegundos, así que cualquier degradación apunta a otra causa.

Hay dos señales que, cuando aparecen juntas, cierran el diagnóstico antes de empezar. Las vemos tan a menudo que ya las preguntamos por defecto en la primera llamada.

La primera es una discrepancia brutal entre lo que mide el motor y lo que mide el navegador. La segunda es una relación absurda entre el número de registros y el espacio que ocupan en disco. Si tienes ambas, puedes saltarte la mitad de este artículo.

Cuando EXPLAIN ANALYZE dice 3 ms y la aplicación tarda 800 ms

Esta es la que más desconcierta. Ejecutas la consulta en psql, te devuelve un Execution Time de 3,2 ms, y la misma petición desde el backend tarda casi un segundo.

El dato que falta aquí es sencillo: EXPLAIN ANALYZE mide exclusivamente el tiempo dentro del ejecutor. No mide el parsing, no mide la autenticación, no mide los trece viajes de ida y vuelta que tu ORM hace antes de devolver el objeto, y desde luego no mide la serialización.

Cuando la brecha entre ambas medidas supera el factor 50, deja de buscar dentro del ejecutor. El tiempo se está yendo en el trayecto.

La tabla de 500 registros que ocupa 400 MB en disco

Comprobar esto cuesta una consulta:

SELECT pg_size_pretty(pg_total_relation_size('mi_tabla')),
       (SELECT count(*) FROM mi_tabla);

Una entrada típica con una docena de columnas escalares pesa entre 100 y 300 bytes. Medio millar de ellas deberían caber en menos de 200 kB, un puñado de páginas de 8 kB. Si el resultado son cientos de megas, el motor no está leyendo tus datos: está leyendo cadáveres.

Total, que el Seq Scan que parece inofensivo sobre 500 registros está recorriendo 50.000 páginas para encontrarlos.

Bloat invisible: cómo una tabla diminuta acumula millones de versiones muertas

Aquí está el núcleo del asunto. El control de concurrencia multiversión de Postgres no modifica un registro: escribe una versión nueva y marca la anterior como obsoleta. Esa versión antigua sigue ocupando espacio físico hasta que alguien la limpia.

Si actualizas la misma entrada 2.000 veces al día durante seis meses, tienes 360.000 versiones muertas conviviendo con una sola viva. Y el escaneo secuencial las visita todas antes de descartarlas. Esa es, dicho sea de paso, la respuesta a si el gestor se degrada con el tiempo: no envejece por antigüedad, envejece por rotación sin reclamación de espacio.

Nuestra consulta de cabecera para esto:

SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;

Cuando n_dead_tup multiplica por mil a n_live_tup, ya no hace falta seguir investigando.

Tablas tipo cola, contadores y sesiones: el perfil de escritura que rompe la limpieza automática

¿Qué tienen en común una tabla de jobs, un contador de visitas y un almacén de sesiones? Que su cardinalidad se mantiene estable mientras su tasa de escritura es brutal.

Son el caso perfecto para que el problema pase desapercibido durante años. Nadie audita una tabla de nueve registros. Nadie sospecha de job_queue cuando el dashboard dice «9 pendientes».

En un SaaS de logística nos encontramos una cola de trabajos con 14 entradas activas y 2,7 GB de peso. El UPDATE de estado se ejecutaba cuatro veces por tarea y la purga borraba al completar. Cardinalidad baja, rotación salvaje: la combinación que más daño hace.

Lo reconocemos sin complejos: nosotros mismos dejamos pasar un caso así durante cinco meses en un producto propio, porque el monitor de tamaño agregaba por esquema y la tabla culpable quedaba diluida en el total (y el total, claro, tenía buena pinta).

Por qué los umbrales por defecto de autovacuum fallan justo en tablas pequeñas

Y aquí viene el detalle interesante. El demonio de limpieza dispara cuando se cumple esta fórmula:

threshold (50) + scale_factor (0.2) × n_live_tup

Para una relación de 500 entradas eso son 150 versiones obsoletas. Suena razonable. El problema es el otro lado de la moneda: el proceso de limpieza se despierta como mucho cada minuto (autovacuum_naptime = 60 s), compite con otras tablas, y en bases con cientos de relaciones puede quedarse esperando su turno mientras tu contador acumula miles de versiones por minuto.

Peor todavía: una transacción abierta de larga duración, o un replication slot abandonado, impide liberar esas versiones aunque el proceso se ejecute puntualmente. Limpia, pero no puede reclamar nada.

Para este perfil, nuestra configuración por tabla queda así:

ALTER TABLE job_queue SET (
  autovacuum_vacuum_scale_factor = 0.0,
  autovacuum_vacuum_threshold = 100,
  autovacuum_vacuum_cost_delay = 0

Scale factor a cero, umbral absoluto. Así el comportamiento deja de depender del tamaño.

Terminal ejecutando consultas de diagnóstico sobre estadísticas de tablas

Cuando el planificador decide mal con muy poca información

Segundo frente. El optimizador elige estrategia a partir de estadísticas muestreadas. Con conjuntos diminutos, esas estadísticas o no existen, o existen pero el planificador decide ignorar tus parámetros concretos.

El resultado es un plan que sobre el papel cuesta poco y en ejecución cuesta una barbaridad.

Planes genéricos en funciones PL/pgSQL y sentencias preparadas

Este mecanismo es el menos conocido y el que más sorpresas da. A partir de la sexta ejecución de una sentencia preparada, el motor compara el coste medio de los planes personalizados con el de un plan genérico, construido sin conocer los valores reales de los parámetros, y si el genérico no sale claramente peor se queda con él para siempre. De ahí que la misma sentencia dentro de una función responda más lento que lanzada directamente contra el servidor.

En una tabla con distribución muy sesgada, ese plan genérico puede ser catastrófico. Recordamos un caso concreto: una función de scoring que respondía en 4 ms las cinco primeras veces y a partir de la sexta se iba a 1.100 ms, de forma perfectamente reproducible. Tardamos día y medio en encontrarlo porque el patrón «rápido al principio, lento después» nos tenía convencidos de que era un problema de caché.

La confirmación es inmediata:

SET plan_cache_mode = force_custom_plan;

Si el tiempo se desploma, ya tienes el culpable.

Estadísticas vacías tras una carga masiva o un restore

Un pg_restore deja las relaciones con estadísticas en blanco hasta que pasa el proceso de análisis. Mientras tanto, el optimizador asume valores por defecto que pueden estar órdenes de magnitud lejos de la realidad.

Imagina que migras a producción un viernes por la tarde y la primera consulta de la aplicación asume cardinalidad 1 donde hay 400 coincidencias. El nested loop resultante no perdona.

Se arregla con una línea, y debería formar parte de cualquier script de despliegue:

ANALYZE VERBOSE;

Para columnas con distribución irregular, subir default_statistics_target de 100 a 500 en esa columna concreta suele cambiar el plan por completo.

El tiempo que no se gasta dentro del motor: round-trips, ORM y conexiones

Llegamos al capítulo que explica la mayoría de las discrepancias entre el cronómetro del servidor y el del usuario. Aquí el gestor es inocente del todo.

N+1: mil consultas de 1 ms son un segundo perdido

La aritmética es implacable. Una vista que lista 200 pedidos y accede al cliente de cada uno dispara 201 sentencias. Cada una tarda 0,8 ms de ejecución y 2,5 ms de latencia de red si la base vive en otra zona de disponibilidad. Total: 663 ms, de los cuales solo 160 son trabajo real.

Ninguna de esas sentencias aparecerá como lenta en tu log de consultas. Ninguna superará el umbral. Y sin embargo son el 80% del tiempo de respuesta.

Hemos escrito aparte sobre cómo instrumentar el problema de las consultas N+1 y detectar este patrón en el código antes de que llegue a producción. En nuestro análisis del patrón N+1 en la capa de aplicación verás por qué se diagnostica mucho mejor desde el lado del cliente que desde el servidor de base de datos, donde solo se percibe ruido disperso y no se identifica ninguna sentencia culpable.

Latencia de conexión, pooling y el coste de abrir sesión en cada petición

Cada sesión nueva implica un proceso nuevo en el servidor. Con TLS, el establecimiento suma varios viajes de ida y vuelta antes de que puedas enviar la primera sentencia.

En entornos serverless medimos entre 25 y 80 ms solo de establecimiento, por petición. Si tu función abre y cierra conexión en cada invocación, estás pagando ese peaje íntegro cada vez.

Un pooler en modo transacción lo reduce a prácticamente cero. ¿Resuelve todos los casos? No: si usas sentencias preparadas con nombre o LISTEN/NOTIFY, el modo transacción te va a dar problemas y necesitarás modo sesión. Conviene saberlo antes de desplegarlo, no después.

Racks de red en centro de datos ilustrando la latencia entre aplicación y base de datos

Registros pocos pero enormes: TOAST, JSONB y geometrías que disfrazan el problema

Contar entradas es una métrica engañosa. Cuando un valor supera aproximadamente 2.000 bytes, PostgreSQL lo comprime y lo desplaza a una tabla auxiliar fuera de línea.

Una entrada con un documento JSONB de 400 kB no es una entrada: son decenas de fragmentos en almacenamiento secundario que hay que recuperar y descomprimir cada vez que seleccionas esa columna. Treinta registros así pueden mover más datos que cien mil registros escalares.

El detalle que casi nadie aplica: si no necesitas el documento completo, no hagas SELECT *. Extraer una clave con el operador de ruta evita descomprimir el resto. En un cliente del sector asegurador esa sola modificación bajó un endpoint de 2,3 s a 190 ms, sin tocar índices ni configuración.

Para ver cuánto pesa realmente lo externalizado:

SELECT pg_size_pretty(pg_relation_size(reltoastrelid))
FROM pg_class WHERE relname = 'mi_tabla';

Diagnóstico en diez minutos: el orden exacto en que mirar

Mira, al final lo que marca la diferencia no es conocer las causas, sino el orden en que las descartas. Empezar por los índices es el error que más horas consume, y lo hemos cometido las veces suficientes como para haber aprendido la lección.

Este es el protocolo que seguimos ahora, en secuencia estricta:

  1. Comparar peso físico contra cardinalidad real.
  2. Revisar n_dead_tup y last_autovacuum.
  3. Buscar transacciones abiertas o slots abandonados.
  4. Reconstruir la relación en caliente.
  5. Forzar plan personalizado y volver a medir.
  6. Restar tiempo de ejecutor al tiempo percibido.

Comprobar el espacio muerto antes que tocar índices o memoria

Si la proporción entre tamaño y número de entradas es absurda, para ahí mismo y limpia. No hay nada más que averiguar.

La vista de estadísticas confirma la sospecha, con atención especial a last_autovacuum. Un valor nulo o de hace semanas en una relación de alta rotación es la confesión completa.

Después toca verificar que nada bloquea la reclamación de espacio:

SELECT pid, state, age(clock_timestamp(), xact_start) AS duracion, query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY xact_start;

Una transacción abierta desde hace tres días (sí, tres días enteros, habitualmente una consola olvidada) bloquea la limpieza de toda la base. Lo hemos visto cuatro veces este año.

Y al reconstruir, cuidado con la herramienta. VACUUM FULL funciona, pero toma un bloqueo exclusivo sobre la relación, así que en producción usamos pg_repack, que hace el mismo trabajo sin cortar el servicio. Si la tabla es una cola, TRUNCATE en ventana de mantenimiento resuelve en 50 ms lo que una reconstrucción tarda minutos.

Separar tiempo de motor, tiempo de red y tiempo de aplicación

Con el espacio físico saneado, toca repartir responsabilidades. Tres medidas, tres fuentes distintas:

  • Tiempo de ejecutor: pg_stat_statements, columna de media por llamada.
  • Tiempo de entrada/salida: activar track_io_timing y usar EXPLAIN (ANALYZE, BUFFERS).
  • Tiempo total percibido: traza de la aplicación, contando sentencias por petición.

La resta entre la tercera y la primera es tu presupuesto de red más ORM. Si esa diferencia supera el 60% del total, el trabajo no está en la base de datos sino en el código que la consume, y ahí una auditoría de código de la aplicación rinde mucho más que cualquier ajuste de parámetros del servidor, porque ningún valor de configuración reduce el número de peticiones que tu backend decide lanzar.

Nuestra hipótesis inicial en estos casos siempre fue que la configuración del motor tenía margen de mejora. Después de revisar más de sesenta instalaciones, la evidencia dice otra cosa: en los conjuntos de datos pequeños, el 70% de la latencia nace fuera del gestor. Cambiar shared_buffers no arregla 200 viajes de ida y vuelta.

Diagrama de flujo con el orden de comprobaciones para diagnosticar lentitud en base de datos

Conclusión

Si un motor relacional se arrastra con medio millar de registros, la lista de sospechosos es corta: espacio muerto acumulado, un plan genérico que se quedó fijado, un patrón de acceso repetitivo en el código, o valores voluminosos que el recuento de entradas no refleja.

Ninguna de las cuatro se resuelve añadiendo índices. Tres de las cuatro ni siquiera se detectan mirando dentro del servidor.

Empieza siempre por comparar peso físico con cardinalidad. Es una sola consulta, se ejecuta en segundos y, en nuestra experiencia, cierra el caso antes de abrirlo en más de la mitad de las ocasiones.

Founder & Software Engineer
Ingeniero de Software y Desarrollador Full Stack con más de 10 años de experiencia operativa. Dirige MVPtoSaaS aplicando los mismos estándares de fiabilidad y rendimiento exigidos en sistemas industriales y de alta criticidad. El enfoque de Felipe asegura que cada producto que transita por MVPtoSaaS cuente con código limpio, bases de datos optimizadas para alta concurrencia y despliegues automatizados preparados para el crecimiento sostenido sin deuda técnica.