Ajuste de Rendimiento en PostgreSQL: Lo Que Aprendí Optimizando Consultas en una Base de Datos de 10TB

Hace unos meses, recibí un mensaje de Slack un domingo por la tarde. El equipo de producto reportaba que el dashboard de analíticas tardaba más de 40 segundos en cargar. No era un caso aislado — llevaba días así, pero nadie lo había escalado. Somos un equipo de cinco personas en backend, y nuestra base de datos PostgreSQL (versión 15.3 en ese momento) había crecido a casi 10TB entre datos históricos, logs de eventos y tablas de auditoría. Lo que siguió fueron tres semanas de investigación donde aprendí más sobre el internals de Postgres que en los cinco años anteriores combinados.

Este artículo es lo que encontré. No es exhaustivo — hay libros enteros sobre este tema — pero son las cosas específicas que movieron la aguja en nuestro caso.

El Problema Que Staging Nunca Reprodujo

Lo primero que hice fue correr las consultas problemáticas en el ambiente de staging. Y ahí fue donde me perdí dos días completos.

En staging, todo volaba. Las mismas consultas que en producción tardaban 40 segundos, en staging terminaban en menos de 200ms. Obvio, ¿no? Staging tiene menos datos. Pero el problema más profundo era que staging tenía una distribución de datos completamente distinta — no solo menos filas, sino una distribución artificial que hacía que los índices se usaran de manera diferente.

Cuando finalmente conecté pg_stat_statements en producción (debería estar activado desde el primer día, aprendan de mí) y empecé a ver los query plans reales, encontré esto:

-- La consulta que destruía el dashboard
SELECT
    u.id,
    u.email,
    COUNT(e.id) AS total_events,
    MAX(e.created_at) AS last_event
FROM users u
LEFT JOIN events e ON e.user_id = u.id
WHERE e.created_at >= NOW() - INTERVAL '90 days'
  AND u.organization_id = $1
GROUP BY u.id, u.email
ORDER BY last_event DESC
LIMIT 100;

-- Lo que EXPLAIN ANALYZE mostraba en prod (simplificado):
-- -> Hash Join (cost=158432.00..289451.23 rows=2341 width=48)
--      -> Seq Scan on events (cost=0.00..189234.00 rows=8234123 ...)
--           Filter: (created_at >= (now() - '90 days'::interval))
--           Rows Removed by Filter: 7,891,234

Un sequential scan sobre 8 millones de filas con casi 8 millones descartadas por el filtro. El índice en events(created_at) existía. ¿Por qué no lo usaba?

Pasé medio día asumiendo que era un problema de estadísticas desactualizadas — corrí ANALYZE events manualmente, y nada cambió. El planner seguía eligiendo el seq scan.

Cuando el Planner de Postgres Tiene Razón y Tú Estás Equivocado

Acá está la cosa: el planner no estaba roto. Yo tenía un índice en events(created_at), pero la consulta filtraba por created_at y hacía un join con users filtrando por organization_id. El índice era demasiado amplio para ser selectivo — aproximadamente el 35% de todos los eventos caían en el rango de 90 días para organizaciones grandes. Con esa selectividad, el planner calculaba (correctamente) que era más barato leer la tabla entera que rebotar entre el índice y el heap.

El fix real fue un índice compuesto en el orden correcto:

-- Índice viejo (inútil para esta consulta en datasets grandes)
CREATE INDEX idx_events_created_at ON events(created_at);

-- Índice nuevo — primero el campo de cardinalidad alta
CREATE INDEX idx_events_user_created
    ON events(user_id, created_at DESC)
    WHERE created_at >= '2024-01-01'; -- índice parcial

-- Para la dimensión de organización, un índice en users ya existía:
-- CREATE INDEX idx_users_org ON users(organization_id);
-- Ese sí estaba bien.

El índice parcial fue una decisión deliberada. Los datos de eventos de antes de 2024 prácticamente nunca se consultaban en tiempo real — van a reportes batch. No tiene sentido indexar datos que nunca vas a leer de forma interactiva. El índice resultante pasó de ocupar ~80GB a ~12GB, lo cual también tiene impacto en escrituras.

La consulta bajó de 40 segundos a 180ms. Pero — y esto es importante — eso no fue el fin del problema.

El Autovacuum Era el Verdadero Culpable (Tardé Semanas en Aceptarlo)

Dos semanas después del fix de índices, las consultas volvieron a degradarse. No tan dramáticamente, pero suficiente para que producto se quejara de nuevo. Esta vez, 8-10 segundos. Revisé los índices — seguían ahí, seguían siendo usados según EXPLAIN ANALYZE. Entonces, ¿qué?

Fui a pg_stat_user_tables y encontré algo que no esperaba:

SELECT
    relname,
    n_dead_tup,
    n_live_tup,
    last_autovacuum,
    last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'events'
ORDER BY n_dead_tup DESC;

-- Resultado:
-- relname | n_dead_tup | n_live_tup | last_autovacuum        | last_autoanalyze
-- events  | 47,234,891 | 89,123,445 | 2025-11-03 04:12:33+00 | 2025-11-03 04:13:01+00

47 millones de tuplas muertas. El autovacuum había corrido… hace tres semanas. En una tabla que recibía aproximadamente 500,000 inserts y updates por día.

El problema era la configuración default de autovacuum, que está pensada para bases de datos pequeñas-medianas. El threshold default es 20% de la tabla — en una tabla con 89 millones de filas, eso significa que autovacuum no arranca hasta que hay ~18 millones de tuplas muertas. Y cuando finalmente arranca, lo hace con recursos tan conservadores que no alcanza a limpiar todo antes de que la tabla acumule más bloat.

Esto es lo que cambié a nivel de tabla (no toqué la configuración global para no afectar otras tablas):

ALTER TABLE events SET (
    autovacuum_vacuum_scale_factor = 0.01,    -- 1% en vez de 20%
    autovacuum_analyze_scale_factor = 0.005,  -- 0.5% para analyze
    autovacuum_vacuum_cost_delay = 2,          -- ms, default es 20
    autovacuum_vacuum_cost_limit = 400         -- default es 200
);

El cost_delay y cost_limit son los que más impacto tuvieron. El default de 20ms de delay hace que autovacuum sea muy gentil con el I/O — tiene sentido para que no interfiera con queries, pero en una tabla tan activa, esa gentileza se convierte en un problema de acumulación. Con 2ms de delay y el doble de cost limit, autovacuum terminaba el trabajo mucho más rápido.

Una cosa que no sabía antes de esto: el table bloat también afecta el query planner porque infla las estadísticas de n_live_tup, lo que hace que el planner sobreestime el tamaño real de los resultados intermedios. Los problemas de índices y autovacuum estaban interactuando de formas no obvias — arreglar solo uno daba mejoras parciales, pero no resolvía nada de raíz.

Configuración de Memoria: Los Números Que Nadie Te Dice

Right, so — este es el apartado donde más me arrepiento de no haber hecho el homework desde el principio.

Nuestra instancia era un servidor dedicado con 256GB de RAM. Y shared_buffers estaba en el default de PostgreSQL, que es 128MB. Ciento veintiocho megabytes. En un servidor con 256GB de RAM. Alguien lo había instalado hace años con la configuración default y nadie lo había tocado. Cuando lo vi, me quedé mirando la pantalla un momento antes de poder seguir.

Para ser justo, no es que “poner shared_buffers alto = problema resuelto”. La relación es más sutil. shared_buffers es la cache compartida de Postgres — pero el OS también tiene su propio page cache, y Postgres puede leer de ahí también. La recomendación estándar de “25% de la RAM” existe por una razón, pero más no siempre es mejor porque reduce la memoria disponible para work_mem.

Esto es lo que terminamos configurando, con el razonamiento detrás:

# postgresql.conf — nuestros valores finales para 256GB RAM

shared_buffers = 64GB              # 25% de RAM
effective_cache_size = 192GB       # estimación del page cache del OS
work_mem = 256MB                   # por operación de sort/hash — CUIDADO con esto
maintenance_work_mem = 4GB         # para VACUUM, CREATE INDEX
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
random_page_cost = 1.1             # estábamos en SSD NVMe, default es 4.0

El random_page_cost fue el cambio más sorprendente en términos de impacto inmediato. El default de 4.0 le dice al planner que los random reads son 4x más caros que los sequential reads — lo cual era verdad en la era de los HDDs. En NVMe, la diferencia es mucho menor. Con 4.0, el planner evitaba índices y prefería seq scans porque “parecían” más baratos. Bajar esto a 1.1 inmediatamente cambió varios planes de query.

Con work_mem hay que ser cuidadoso. 256MB por operación suena bien hasta que recuerdas que work_mem se aplica por operación de sort/hash, y una consulta compleja puede tener múltiples. Con 100 conexiones concurrentes y queries con múltiples sorts, teóricamente podrías consumir cientos de GB solo de work_mem. Nosotros monitoreamos pg_stat_activity y en la práctica el uso real era mucho menor, pero el riesgo existe.

No estoy 100% seguro de que estos valores sean los óptimos para siempre — cuando migremos a PostgreSQL 16 (estamos en 15.6 ahora) voy a revisar si hay cambios en el planner que justifiquen ajustes. Tu millage puede variar dependiendo de tu patrón de queries.

Lo Que Haría Diferente Si Empezara Desde Cero

Lo más impactante y lo más barato: activar pg_stat_statements desde el primer día, con un dashboard básico que muestre el top 10 de queries por tiempo total acumulado. No por tiempo de ejecución individual — por tiempo total, porque una query que tarda 100ms pero corre un millón de veces al día es más dañina que una que tarda 5 segundos pero corre dos veces por semana. Esta distinción me habría ahorrado días de investigación en la dirección equivocada.

Igual de importante, aunque casi siempre ignorada: revisar pg_stat_user_tables cada semana. Específicamente n_dead_tup y last_autovacuum. Si n_dead_tup está creciendo consistentemente o el último autovacuum fue hace más de 24 horas en una tabla activa, algo está mal. Armé un script simple que corre cada hora y nos manda una alerta si alguna tabla supera el 10% de dead tuples relativo a live tuples. Debería haber existido desde el día uno.

Sobre staging: no confíes en él para pruebas de rendimiento a menos que tenga datos representativos en distribución, no solo en volumen. Esto es difícil de mantener — lo sé — pero el costo de no hacerlo es exactamente lo que viví. Dos días perdidos persiguiendo un problema que no existía en el ambiente equivocado.

Y si tuviera que señalar un solo cambio arquitectural que habría evitado la mayor parte del dolor: para tablas de eventos o logs que crecen indefinidamente, pg_partman con particionado por rango de fechas es casi siempre la respuesta correcta desde el principio. Nosotros lo estamos implementando ahora, y el proceso de migrar una tabla monolítica de 10TB a particiones es… no divertido. Si hubiera empezado así, el mantenimiento sería trivial y el bloat de autovacuum sería un problema por partición, no por toda la tabla.

Los problemas de rendimiento de base de datos raramente tienen una causa única — eso fue quizás la lección más dura de aceptar. Lo que yo tenía era un índice subóptimo más un autovacuum desconfigurado más parámetros de memoria default en hardware moderno, cada uno contribuyendo al problema a su manera. Ver el sistema completo, no solo la query lenta, es lo que finalmente lo resolvió.

Mi recomendación concreta si estás en una situación similar: antes de crear cualquier índice o cambiar cualquier configuración, dedica un día entero solo a observar. Activa pg_stat_statements, deja correr por 24 horas, y construye un cuadro completo de qué queries consumen más recursos y cuál es el estado real de tus tablas. El tiempo de diagnóstico honesto es la mejor inversión que puedes hacer.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top