- Al operar una job queue en Postgres, se resumen las causas y soluciones de los problemas crónicos de acumulación de dead tuples y el consecuente table bloat y la degradación del rendimiento
- En las tablas de cola, la mayoría de las filas pasan por un ciclo corto de insertar-leer-eliminar, por lo que su tamaño se mantiene estable, pero el volumen acumulado procesado es enorme
- Debido a la arquitectura MVCC de Postgres, las filas eliminadas no se borran de inmediato, sino que quedan como dead tuples y deben limpiarse; de eso se encarga autovacuum
- Si transacciones de larga duración o consultas analíticas superpuestas fijan el MVCC horizon, autovacuum no puede limpiar los dead tuples y el rendimiento de la cola se degrada
- La función Traffic Control de PlanetScale (parte de la extensión Insights) se presenta como una forma práctica de resolver este problema mediante límites de recursos por clase de consulta
Características de la carga de trabajo de una cola
- La característica distintiva de una tabla de cola es que la mayoría de sus filas son transitorias (transient): se insertan, se leen una vez y se eliminan
- El tamaño de la tabla se mantiene casi constante, pero el throughput acumulado es enorme
- La principal ventaja de tener la job queue dentro de Postgres es que permite sincronizar el estado de los jobs y otra lógica de la base de datos dentro de la misma transacción
- Si el job falla, se revierte toda la transacción
- Si se usa un servicio de colas externo, sincronizarlo con el estado transaccional de la aplicación se vuelve más complejo
Ejemplo de tabla de cola y funcionamiento del worker
- Esquema básico presentado en el artículo
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
run_at TIMESTAMPTZ DEFAULT now(),
status TEXT DEFAULT 'pending',
payload JSONB
);
CREATE INDEX idx_jobs_fetch ON jobs (run_at) WHERE status = 'pending';
- El worker abre una transacción y bloquea el pending job más antiguo con
FOR UPDATE SKIP LOCKED para evitar procesamiento duplicado
- Si el trabajo se completa con éxito, hace
DELETE y luego COMMIT; si falla, hace rollback y esa fila vuelve a quedar visible para otro worker
- Esta transacción debe mantenerse lo más corta posible: cuanto más tiempo permanezca abierta, más bloquea a vacuum (el ejemplo del artículo asume workers de menos de un milisegundo)
El problema no es el rendimiento en sí
- Ya está documentado que Postgres puede manejar job queues de gran escala, así que la capacidad en sí no es el problema
- El verdadero problema es la convivencia con otras cargas de trabajo que compiten en la misma base de datos
- La salud de la tabla de cola no depende solo de su propia configuración, sino también del comportamiento de todas las transacciones que corren en la misma instancia de Postgres
- El artículo se enfoca en el tráfico de consultas en competencia sobre el primario (el impacto de réplicas y replication slots se trata por separado)
El problema real: limpiar los dead tuples
- Postgres usa MVCC para mantener múltiples versiones de una misma fila: una fila eliminada no se borra de inmediato, sino que queda marcada como eliminada y pasa a ser invisible para nuevas transacciones
- Esa fila remanente es un dead tuple y se limpia mediante una operación de vacuum
- Los dead tuples no aparecen en los resultados de
SELECT, pero aun así generan costo
- Sequential scan: el ejecutor lee el dead tuple desde la heap page, verifica su visibilidad y luego lo descarta
- Index scan (la forma usada en la cola con
ORDER BY run_at LIMIT 1): el índice B-tree acumula referencias a dead tuples, por lo que termina recorriendo entradas que apuntan a filas que ya no son visibles
- Cada dead index entry provoca I/O adicional; no se ve desde la aplicación, pero el costo aumenta mucho según la cantidad de dead tuples
- El ciclo de limpieza depende de
autovacuum_naptime (1 minuto por defecto), y si se ejecuta o no lo determinan autovacuum_vacuum_threshold y autovacuum_vacuum_scale_factor
Mecanismo interno de los dead tuples
- Hay 3 metadatos de fila clave
ctid: ubicación física del tuple dentro de la heap (page, offset)
xmin: ID de transacción (XID) que insertó esa fila
xmax: ID de transacción que eliminó o bloqueó esa fila; si es 0, no hay marca de eliminación
- Incluso al consultar 3 filas pendientes, puede ocurrir que el ejecutor escanee primero 6 dead tuples eliminados previamente y solo devuelva 3 filas
- Si las entradas leaf del índice apuntan a heap tuples muertos, también se acumula trabajo desperdiciado durante el scan
- Si la base de datos limpia más lento de lo que se generan dead tuples, entra en una trayectoria de fallo
- Un clúster de Postgres bien ajustado puede soportar decenas de miles de operaciones de cola por segundo
Cuando autovacuum queda inutilizado
- Las principales razones por las que autovacuum falla al limpiar dead tuples son
- cierto lock de tabla bloquea la limpieza
- una configuración de autovacuum inadecuada
- y, lo más común, transacciones activas que impiden recuperar los dead tuples
- Postgres no hace vacuum sobre dead tuples que aún podrían ser visibles para transacciones activas
- La transacción activa más antigua fija el cutoff: el MVCC horizon
- Hasta que esa transacción termine, todos los dead tuples posteriores a ese snapshot deben mantenerse
- Una sola transacción de 2 minutos fija el horizon durante 2 minutos
- El mismo patrón de fallo también aparece con consultas de duración media que se superponen
- Por ejemplo: si 3 consultas analíticas de 40 segundos se lanzan escalonadas cada 20 segundos, ninguna vence por timeout, pero siempre hay una activa, así que el horizon no puede avanzar
- Si, bajo la filosofía de “Just use Postgres”, varias cargas conviven en una sola base de datos, el problema no es el procesamiento rápido de jobs en sí, sino que las consultas lentas superpuestas retrasan la limpieza de dead tuples
Herramientas existentes y sus límites
- Opciones de ajuste de autovacuum:
autovacuum_vacuum_cost_delay, autovacuum_vacuum_cost_limit
- Timeouts para limitar consultas de larga duración
statement_timeout (Postgres 7.3): termina una sentencia SQL individual que excede el tiempo especificado
idle_in_transaction_session_timeout (9.6): termina sesiones que permanecen ociosas dentro de una transacción
transaction_timeout (17.0): termina transacciones activas o inactivas cuando superan el tiempo definido
- Estos timeouts solo apuntan al tiempo de ejecución de una consulta individual; no limitan la concurrencia ni el costo de ejecución, así que no sirven para bloquear cargas de trabajo que fijan el MVCC horizon de forma sostenida
- Lo que hace falta es distinguir por clase de tráfico, dejando intactas las cargas prioritarias y regulando solo el uso de recursos de las de baja prioridad
Database Traffic Control™
- Función exclusiva de PlanetScale Postgres, desarrollada por PlanetScale como parte de la extensión Insights
- Se usa para controlar con granularidad el rendimiento y el uso de recursos de consultas individuales
- Con Resource Budget se asignan límites de recursos a consultas objetivo; si los exceden, pueden bloquearse
- La estrategia de solución consiste en limitar la cantidad y frecuencia de consultas lentas superpuestas, para que autovacuum tenga margen de limpiar dead tuples a un ritmo adecuado
- Las consultas bloqueadas no se rechazan de forma permanente, sino que deben reintentarse, por lo que la lógica de retry en la aplicación es indispensable
- Es un enfoque para suavizar la velocidad de ejecución manteniendo la misma cantidad total de trabajo
Configuración de la demo y contexto
- La inspiración de este artículo viene del blog de 2015 de Brandur Leach, “Postgres Job Queues & Failure By MVCC”
- Documentó un modo de fallo crítico en job queues basadas en Postgres
- Incluyó un test bench que demostraba cómo una transacción que no se cerraba fijaba el MVCC horizon e impedía la limpieza
- El test bench original está publicado como
brandur/que-degradation-test
Reproducción del problema (basado en Postgres 18)
- La prueba original estaba basada en Ruby + Que gem v0.x + Postgres 9.4
- El autor la reescribió en TypeScript + Bun para aislar y verificar solo el comportamiento a nivel SQL
- Se mantuvieron el mismo patrón de recursive CTE de Que, el mismo esquema, producer rate, duración del trabajo, número de workers y patrón de long-runner
- Se ejecutó en un clúster PlanetScale PS-5 (desde USD 5 al mes)
- Resultado: degradación visible pero manejable
- La prueba original llevaba a la base de datos a una death spiral en 15 minutos, pero en PS-5 la cola de workers se mantuvo cerca de 0 durante 15 minutos
- Sin embargo, los dead tuples aumentaron linealmente, señal de que el mismo problema reaparecería si se extendía el tiempo
- Gracias a mejoras en la limpieza de índices B-tree (como bottom-up deletion ante version churn y eliminación de dead index tuples durante scans), el problema se mitigó, pero no desapareció
Intento de mejora: SKIP LOCKED + procesamiento por lotes
- Dos mejoras modernas que no existían en 2015
FOR UPDATE SKIP LOCKED: reemplaza todo el recursive CTE por un único SELECT, omitiendo filas bloqueadas por otros workers
- Batch processing (10 jobs por transacción): procesa 10 con una sola adquisición de lock, repartiendo el costo del index scan
- Mismas condiciones: 8 workers, producer de 50 jobs/seg, trabajo de 10 ms, long-runner iniciando a los 45 segundos
- Resultados principales
| Métrica |
original (recursive CTE) |
mejorado (SKIP LOCKED + batch) |
| Baseline lock time |
2–3ms |
1.3–3.0ms |
| End lock time (typical) |
10–34ms |
9–29ms |
| Worst spike |
84.5ms (dead tuple 33k) |
180ms (dead tuple 24k) |
| Queue depth |
0–100 (oscillating) |
0 (la mayor parte del tiempo) |
| Dead tuples at end |
42,400 |
42,450 |
| Throughput |
~89/s |
~50/s |
- La curva de degradación es casi igual: ambos métodos escanean el mismo índice B-tree y se encuentran con los mismos dead tuples
- La diferencia de throughput no viene de la estrategia de lock, sino del diseño de la prueba (el worker con CTE toma jobs más rápido que el producer; el worker por lotes vacía la cola y luego entra en backoff sleep)
- Conclusión: el diseño de cola que hace 10 años mataba la base en 15 minutos ahora resiste más, pero el problema de fondo sigue ahí; si se sube a 500 jobs/seg, reaparece más rápido
Resolverlo con Traffic Control
- Mecanismos de control que ofrece Resource Budget
- Server share & burst limit: proporción de recursos del servidor y velocidad de consumo
- Per-query limit: tiempo de ejecución permitido por consulta, en segundos, según uso del servidor
- Maximum concurrent workers: proporción respecto de los procesos worker disponibles
- La selección de consultas objetivo se hace principalmente con metadatos en etiquetas de SQLCommenter (por ejemplo,
action=analytics)
- En vez del long-runner capturado por
idle_in_transaction_session_timeout, se provocó la degradación con un escenario más realista: consultas analíticas activas y superpuestas (un caso que el timeout de sesión no puede capturar)
- Se limitó Maximum concurrent workers de las consultas
action=analytics a 1 worker (25% de max_worker_processes) → solo una consulta analítica puede ejecutarse al mismo tiempo
- Para inducir una death spiral dentro de una ventana de 15 minutos, el producer se elevó a 800 jobs/seg
- En EC2 se ejecutó dos veces la carga “enhanced” contra la misma base de datos de PlanetScale
- 800 jobs/seg
- 3 consultas analíticas de 120 segundos ejecutándose en paralelo y escalonadas para superponerse de forma continua
- durante 15 minutos
- Comparación de resultados
| Métrica |
Traffic Control desactivado |
Traffic Control activado |
| Queue backlog |
155,000 jobs |
0 jobs |
| Lock time |
300ms+ |
2ms |
| Dead tuples at end |
383,000 |
0–23,000 (cíclico) |
| Analytics queries |
3 concurrentes, superpuestas |
1 a la vez, 2 reintentando |
| VACUUM effectiveness |
Bloqueado (horizon fijo) |
Normal (hay ventanas entre consultas para limpiar) |
| Outcome |
Death spiral |
Completamente estable |
- Traffic Control limita directamente la concurrencia de una carga de trabajo específica, algo que ni el tuning de autovacuum ni los timeouts permiten
- Los reportes analíticos siguieron ejecutándose dentro de lo que permitía la capacidad y completaron 15 ejecuciones en 15 minutos, mientras la cola se mantuvo saludable todo el tiempo
Resumen
- El problema de MVCC dead tuples en colas basadas en Postgres no es un fósil de 2015
- El Postgres moderno ofrece bastante más margen gracias a mejoras en B-tree y
SKIP LOCKED, pero el mecanismo fundamental sigue siendo el mismo
- Si VACUUM no puede limpiar los dead tuples, estos se acumulan
- Si transacciones largas o superpuestas fijan el MVCC horizon, VACUUM no puede limpiarlos
- En entornos donde, bajo la idea de “Just use Postgres”, conviven cola, analítica y lógica de aplicación en una sola base de datos, esto no es un riesgo teórico, sino una condición operativa cotidiana
- La forma peligrosa no es un crash dramático, sino un estado de equilibrio que se degrada silenciosamente: el lock time sube poco a poco, los jobs se ralentizan y no suena ninguna alarma
- Las herramientas de timeout de Postgres no permiten distinguir clases de carga ni limitar concurrencia
- Si vas a ejecutar la cola junto con otras cargas, la medida más efectiva es garantizar que VACUUM pueda seguir el ritmo, y Traffic Control simplifica justamente eso
1 comentarios
Comentarios en Hacker News
Postgres todavía tiene el problema del vacuum horizon. Es decir, las consultas de larga duración impiden el vacuum en tablas que cambian rápidamente. Este problema ya era bien conocido desde 2015. Postgres base no tiene buenas herramientas para resolverlo, pero la versión personalizada de la empresa del autor sí incluye una función para solucionarlo. En conclusión, sigue sin ser una buena idea mezclar trabajos largos estilo OLAP con trabajos rápidos estilo cola en la misma instancia de Postgres. Según los requisitos, puede ser más fácil usar una cola de mensajes como 0MQ o RMQ
El artículo estuvo bien, pero hay algunos puntos que señalar.
SELECT * FROM jobs WHERE status='pending' ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED;sí existe, pero se puede aliviar agregando una columna monótonamente creciente y poniéndole un índice. Así no hace falta considerar los tuples muertos; solo se desperdicia espacio y el rendimiento de lectura se degrada menos. Eso sí, cómo garantizar ese crecimiento monotónico con escrituras concurrentes depende del diseño de la aplicaciónSoy el autor. Si tienen preguntas, no duden en hacerlas
Parece publicidad, pero me habría gustado ver хотя sea un poco de explicación sobre la solución técnica
Postgres realmente puede hacer muchísimas cosas. La gente elige Kafka o SQS, pero en realidad hay muchos trabajos que Graphile Worker puede manejar sin problema
En Postgres el problema se vuelve mucho más grave cuando se actualizan filas. Si solo usas inserciones y eliminaciones, puede aguantar bastante tiempo
UPDATEes básicamente una combinación deINSERTyDELETE