- Basado en los problemas que Hatchet enfrentó durante 2 años en producción, resume principios operativos paso a paso, desde el diseño inicial de esquemas y consultas hasta escrituras masivas y migraciones de tablas.
- Para lecturas rápidas, hay que alinear los índices con
ORDER BY, pero como el planificador de consultas puede elegir un escaneo secuencial según estadísticas y costos, se debe comparar la estimación con la ejecución real usando EXPLAIN ANALYZE.
- El rendimiento y la estabilidad de escritura dependen de transacciones cortas, bloquear solo las filas necesarias,
CREATE INDEX CONCURRENTLY y connection pooling; en las mediciones de Hatchet, el procesamiento por lotes aumentó el throughput unas 10 veces.
- En entornos de escritura de alta frecuencia, la configuración predeterminada de autovacuum puede no recuperar a tiempo dead tuples ni transaction IDs, y si se llega a transaction ID wraparound puede producirse un downtime importante.
- A medida que crece la escala, conviene usar colas de trabajo basadas en
FOR UPDATE SKIP LOCKED, particionamiento, triggers y backfill por lotes, pero es necesario poder controlar SQL directamente fuera de la abstracción del ORM.
Público objetivo y límites del ORM
- Es una guía pensada para desarrolladores que conocen los conceptos básicos de SQL, filas, tablas e índices y necesitan responder a problemas de Postgres en producción.
- El manual de Postgres es amplio, pero no es fácil consultarlo rápido durante incidentes, así que se condensa alrededor de la experiencia operativa que Hatchet acumuló durante 2 años.
- Aunque se use un ORM, los principios aplican igual, pero a medida que aumenta la escala hay muchas optimizaciones que solo son posibles si se escribe SQL directamente fuera de la capa de abstracción.
- Con funciones como Prisma TypedSQL, se puede usar el ORM junto con SQL directo.
- Hatchet, basado en Go, usa
sqlc, que ofrece un comportamiento similar.
- Para entornos donde Claude escribe consultas, recomiendan supabase/agent-skills.
Diseño de esquema difícil de cambiar
- Después del despliegue, lo más difícil es cambiar el esquema, así que conviene crear un borrador de tablas y claves primarias, escribir las consultas que necesita la aplicación e iterar el diseño.
- Durante el diseño, se revisa cómo se usarán las tablas con preguntas como estas:
- Qué ocurre con más frecuencia: lecturas o escrituras
- Cuáles son los filtros más usados al leer
- Cuáles son las columnas que más se actualizan
- Se pueden aplicar 1NF, 2NF y 3NF de la normalización de bases de datos, pero a veces las formas normales chocan con la eficiencia de las consultas o con la facilidad de uso necesaria para desarrollar más rápido.
- En algunos casos, es más simple poner los datos en una columna
jsonb.
- Estas son las reglas prácticas que aplican al diseñar esquemas:
- Usar enteros autoincrementales de tipo identity o UUID nativo de Postgres para las claves primarias.
- Las columnas identity son un poco más rápidas que
bigserial.
- Usar siempre
timestamptz para fechas y horas.
- Poner una clave primaria en todas las tablas.
- Usar claves foráneas, incluyendo cascade delete, en tablas pequeñas donde la consistencia y la precisión son importantes, pero con cuidado en entornos de alto volumen.
Consultas de lectura e índices
- Un modelo simple para entender
SELECT rápidos es que Postgres puede encontrar una fila rápido con un índice o leer todas las filas de una tabla con un escaneo secuencial (seq scan).
- Para buscar una sola fila rápidamente, se usan estas estructuras:
- Un índice explícito
- Un unique constraint, que es una forma especial de índice
- La clave primaria, que Postgres indexa automáticamente
- El índice básico usa
btree, y puede entenderse como una tabla separada que almacena los datos en una forma optimizada para búsqueda.
- El tiempo de búsqueda de filas es aproximadamente
log(n), donde n es la cantidad de filas de la tabla.
- Si no puede usarse un índice, se ejecuta un escaneo secuencial, pero las bases de datos modernas cargan filas en memoria muy rápido, así que en tablas con menos de 20 mil filas esto puede terminar casi de inmediato.
Joins e índices compuestos
- En general, el lado objetivo de un inner join debería usar una clave primaria; si no, puede haber un problema en el diseño del esquema o en la normalización.
- La cláusula
ON debe tratarse como la cláusula WHERE, y hay que usar el índice adecuado para la condición del join.
- Las consultas de listado sobre tablas grandes suelen ser las primeras que se vuelven lentas en una aplicación.
- Si se filtra y ordena a la vez por organización y fecha de creación, puede usarse un índice compuesto.
CREATE INDEX CONCURRENTLY idx_documents_org_created
ON documents (organization_id, created_at DESC);
- En consultas complejas, una regla práctica es poner la columna de
ORDER BY al final del índice y hacer coincidir también la dirección del ordenamiento.
- Como Postgres puede escanear
btree en ambos sentidos, en una sola columna DESC puede no tener importancia, pero en índices compuestos conviene alinearlo.
- El funcionamiento detallado de los índices descendentes puede revisarse en este material relacionado.
Escrituras, bloqueos y migraciones
- La primera condición para escrituras exitosas es mantener las transacciones cortas.
- Salvo que haya una razón especial, no se consulta un servicio externo durante una transacción.
- La segunda condición es bloquear solo las filas necesarias.
- Cuando se actualiza una fila, queda bloqueada hasta que la transacción haga commit.
- A medida que crece la carga del sistema, el impacto de los bloqueos se vuelve más visible.
- Si se ejecuta
CREATE INDEX normal sobre una tabla grande existente, la tabla queda bloqueada y se frenan insert y update, así que siempre se usa CREATE INDEX CONCURRENTLY.
- Una buena capacidad de migración de esquemas acelera el desarrollo iterativo y aumenta el uptime.
- En lo posible, evitar eliminar columnas y preferir cambios aditivos.
- Cuando se pueda, ejecutar dentro de una transacción para poder manejar rollback y aplicación parcial.
- Como enfoque más avanzado, se pueden usar migraciones expand and contract.
- Primero hay que evaluar si una migración bloqueará todas las escrituras.
- Crear un índice sin
CONCURRENTLY puede bloquear todas las escrituras y provocar downtime.
- Las operaciones
ALTER TABLE deben revisarse otra vez, y agregar un check constraint a una tabla grande también puede bloquear escrituras.
- Si se agrega el check constraint como
NOT VALID, se puede evitar ese bloqueo.
Gestión de conexiones
- Todas las consultas y transacciones usan una conexión a la base de datos, y como las conexiones tienen alto costo de CPU y memoria, conviene mantenerlas vivas.
- Crear y destruir conexiones con frecuencia desperdicia recursos.
- Si aparecen muchas conexiones nuevas al mismo tiempo, una connection storm puede causar problemas difíciles de depurar relacionados con bloqueos internos de Postgres.
- Primero conviene considerar un connection pooler externo como
pgbouncer; si no puede usarse, un pool de conexiones en memoria es la alternativa.
- Como Hatchet no puede asumir que la base de datos del usuario usa un pooler externo, utiliza pgxpool para Go.
Planificador de consultas y estadísticas
- Las consultas complejas con muchos joins o mezclas de varios métodos de join no se resuelven solo agregando índices.
- Los índices también tienen overhead, así que no deben agregarse sin límite.
- El planificador de consultas convierte SQL en operaciones internas de la base de datos y decide, entre otras cosas, si usar índices, pero por información limitada puede no elegir el plan óptimo.
- La información que usa el planificador son las estadísticas de las tablas, que pueden consultarse en
pg_stats.
SELECT *
FROM pg_stats
WHERE tablename = 'mytable';
- Las estadísticas se recolectan en
ANALYZE y también se actualizan cuando corre autovacuum.
- Si se aumenta la frecuencia de autovacuum, las estadísticas de consultas también se mantienen más actualizadas.
- Una causa común de consultas que se comportan mal es una frecuencia de análisis insuficiente.
- Si se juzga una consulta de forma simple según si cae en escaneo secuencial o no, se reduce el riesgo de aumentar la imprevisibilidad del planificador con microoptimizaciones.
- Si las consultas se centran en claves primarias e índices, al planificador le resulta más fácil elegir un plan.
Análisis de planes de ejecución y escaneo secuencial
- Algunos proveedores, como Google CloudSQL, muestrean consultas y guardan las lentas, pero no todos los servicios lo soportan.
EXPLAIN ANALYZE ejecuta la consulta de verdad y compara la cantidad de filas estimadas según las estadísticas de la tabla con la cantidad real de filas escaneadas.
- En producción, hay que tener cuidado porque la consulta sí se ejecuta realmente.
- Si se quiere ver solo el plan sin ejecutar, se usa
EXPLAIN sin ANALYZE.
- El plan detallado puede guardarse en JSON y visualizarse en explain.dalibo.com.
psql -XqAt -f explain.sql -d $DATABASE_URL > analyze.json
- Si las estadísticas y los índices están bien y aun así hace un escaneo secuencial, es posible que el planificador haya calculado que el costo del escaneo secuencial es menor.
- Los índices se almacenan por separado del heap donde están los datos reales de la tabla, así que leer varias filas encontradas en el índice implica volver a leerlas desde el heap.
- Si no puede reestructurarse mucho la consulta, conviene aceptar el escaneo secuencial o evaluar particionamiento.
Escrituras masivas y procesamiento por lotes
- Cada consulta tiene overhead: el tiempo de ida y vuelta a la base de datos, el tiempo para obtener una conexión del pool de la aplicación y el tiempo de procesamiento dentro de Postgres.
- Los bloqueos internos de Postgres también pueden volverse un cuello de botella en entornos de alto throughput.
- Si se agrupan varias filas en una sola consulta, esos costos se reducen.
- La forma más simple es enviar varias consultas juntas al servidor como una transacción implícita.
- En Go, puede usarse
SendBatch de pgx.
- En Hatchet, el procesamiento por lotes aumentó el throughput unas 10 veces, y hay más optimizaciones de inserción en su guía de inserciones rápidas en Postgres.
autovacuum y transaction ID wraparound
- autovacuum se encarga de limpiar dead tuples y gestionar transaction IDs, y en entornos de escritura de alta frecuencia puede requerir ajustes de configuración.
- Un tuple es una versión de una fila almacenada en el sistema de archivos.
- Aunque una fila se actualice o elimine, la versión anterior permanece hasta que todas las transacciones iniciadas antes de ese cambio hagan commit o rollback.
- Una versión que ya no puede ser leída por ninguna transacción es un dead tuple.
- Si la velocidad de escritura es demasiado alta, autovacuum puede no alcanzar el ritmo de creación de dead tuples y el estado de la base de datos puede degradarse rápidamente.
- Si al revisar procesos activos en
pg_stat_activity se ve que una consulta de autovacuum lleva alrededor de más de 1 hora ejecutándose, conviene considerar un cambio de configuración.
- Si se consumen todos los transaction IDs antes de que autovacuum los recupere, ocurre transaction ID wraparound y puede derivar en un downtime importante.
Hinchamiento de tablas e índices
- Postgres almacena filas en páginas de 8 KB en disco, y si no puede poner una fila nueva en una página existente, crea una nueva.
- Cuando se recuperan dead tuples y las páginas quedan parcialmente vacías, se produce el table bloat, lo que puede aumentar mucho el uso de disco.
- La mejor prevención es ajustar autovacuum antes de que aparezca el bloat.
- En tablas que ya tienen bloat, pueden usarse extensiones como
pg_repack.
- El comando integrado
VACUUM FULL casi nunca es una buena opción.
- En Postgres 19 se planea agregar
REPACK...CONCURRENTLY para reempaquetado concurrente de tablas, pero Hatchet todavía no lo ha probado.
- El index bloat también es una forma especial de bloat de tabla, y puede reducirse con una configuración apropiada de autovacuum.
- En índices que ya tienen bloat, puede usarse el comando integrado
REINDEX INDEX CONCURRENTLY.
Concurrencia basada en FOR UPDATE SKIP LOCKED
FOR UPDATE SKIP LOCKED reserva las filas seleccionadas para la transacción actual sin bloquear otras consultas.
- Hatchet lo usa en una cola de trabajos, donde una sola consulta puede bloquear tareas en espera y cambiar su estado a
RUNNING.
WITH eligible_tasks AS (
SELECT *
FROM tasks
WHERE status = 'QUEUED'
ORDER BY id ASC
FOR UPDATE SKIP LOCKED
LIMIT 100
)
UPDATE tasks
SET status = 'RUNNING'
FROM eligible_tasks
WHERE tasks.id = eligible_tasks.id
RETURNING tasks.*;
- También es útil cuando se actualizan al mismo tiempo filas independientes o cuando varias instancias de una aplicación gestionan el lease de un objeto.
- Hatchet lo usa para distribuir tenant leases entre varios engines.
Particionamiento
- El particionamiento integrado de Postgres divide una tabla según valores de fila como timestamp o hash.
- En datos de series temporales y en los datos históricos de trabajos de Hatchet, ofrece estas ventajas:
- Permite ejecutar autovacuum de forma independiente por partición y ampliar la escala del procesamiento de autovacuum de la tabla.
- Los datos antiguos pueden eliminarse casi de inmediato desmontando la tabla particionada, sin borrar fila por fila.
- Si Postgres no logra descartar particiones innecesarias en la etapa de planificación, puede agregarse overhead a las consultas de lectura.
Movimiento de datos entre tablas grandes
- La migración de tablas grandes aquí no se refiere a cambios de esquema, sino a mover grandes volúmenes de datos de una tabla a otra.
- Copiar una tabla muy grande en una sola transacción puede tomar horas.
- Una transacción larga impide que autovacuum funcione normalmente y provoca acumulación de dead tuples.
- Si la tabla anterior sigue recibiendo escrituras, esos datos no se reflejarán en la tabla nueva.
- Hatchet ejecuta un gran backfill por lotes fuera de la transacción, y las nuevas escrituras que ocurren después de iniciar la migración se copian a la tabla nueva con triggers de Postgres.
- Usan el unique constraint de la clave primaria para evitar escrituras duplicadas.
1 comentarios
Comentarios de Hacker News
Si es una base de datos de producción, me parece que lo primero debería ser tener un plan de respaldo y recuperación. La alta disponibilidad puede ser opcional al inicio, pero me sorprende que una guía de supervivencia no incluya respaldos y recuperación
Me pregunto si hoy en día todavía se usa mucho Barman(https://pgbarman.org/) para respaldos de PostgreSQL
pg_dump_alldesde cron, comprimirlo conzstdy copiarlo a S3, FTP o algo similar. Cuando los datos crecen, el tiempo y el costo del respaldo completo pesan, pero este método simple aguanta bastante tiempoEn AWS respaldé MongoDB de varios TB con snapshots de EBS para lograr respaldos incrementales y recuperación rápidas. No permite recuperación a un punto en el tiempo, pero como se pueden tomar con frecuencia cada hora, sirve bien como estrategia complementaria junto con herramientas específicas de PostgreSQL
Hay varias cosas que se podrían complementar. En vez de un UUIDv4 genérico, usaría UUIDv7, y para evitar interbloqueos no solo hay que unificar de forma determinista el orden de las filas bloqueadas, sino también el de todos los bloqueos en las consultas, por ejemplo con
id ASCCon
EXPLAIN (GENERIC_PLAN)puedes copiar la consulta manteniendo los placeholders de parámetros y también ver el plan de optimización cuando PostgreSQL no conoce los valores reales. En tablas vacías o pequeñas,SET enable_seqscan = offsirve para comprobar si existe posibilidad de usar índicesComo los índices B-tree que todos usan por defecto son pesados y tienden a inflarse, si solo haces búsquedas simples sin ordenamiento ni búsquedas por rango, también vale la pena considerar índices hash. No se pueden crear índices hash únicos, pero una restricción de exclusión hash puede dar un efecto parecido, y no admite índices únicos multicolumna
También conviene aprender sobre los índices GIN y GiST. A usuarios de MySQL quizá les sorprenda, pero pueden acelerar consultas normales como
LIKE '%foo%'sin necesidad de pasarse a búsqueda de texto completoORDER BYconsistente para el conjunto de filas a bloquear, sino también cuando el orden de bloqueo entre tablas difiere. Si una transacción bloqueatable_a,table_ben ese orden y otra transacción lo hace al revés, habrá interbloqueo aunque dentro de cada tabla usesORDER BYyFOR UPDATEEn teoría es obvio, pero en la práctica es mucho más difícil de depurar porque hay que entender globalmente qué tablas toca cada escritura, y me pasó de verdad con cierta extensión. Estoy probando GIN para búsquedas clave-valor sobre JSONB y la mejora de rendimiento fue enorme; también hubo una diferencia considerable entre
ANDyOREste consejo también es bueno, pero las startups con las que trabajé se toparon antes con problemas organizacionales que con problemas de escalabilidad. Conviene no usar ORM, usar claves primarias autoincrementales en vez de campos con significado y usar JSONB de forma limitada solo cuando sea realmente necesario
Los datos fuente deberían ser append-only, solo de inserción, sin actualizaciones ni borrados. Las tablas auxiliares desnormalizadas por rendimiento o conveniencia pueden cambiarse, pero no deberían tratarse como fuente de verdad
Usa pool de conexiones, pero cuidando la cantidad de conexiones; si no hay problemas, quizá ni siquiera haga falta PgBouncer. Evita las transacciones explícitas salvo que haya una razón clara, no las dejes abiertas durante tareas largas como RPC y casi nunca conviene usar
SERIALIZABLESi necesitas bloqueos explícitos como
SELECT FOR UPDATE, es posible que el diseño esté mal. No hagas que filas de una misma tabla tengan significados distintos según un valortype int, reinventando el sistema de tipos, ni imites una base de datos de grafos con tablasnodeyedgeautorreferenciadas. La mayoría de los casos se resuelven con tablas normalizadas comunesPreferiría dedicar tiempo al desarrollo del producto en vez de obsesionarme demasiado temprano con el esquema de la base de datos y optimizar prematuramente al inicio del proyecto
SELECT FOR UPDATEde forma útil en varios lugares y me pregunto cuál sería el problema. También me interesa saber si usar una fuente de verdad append-only elimina la necesidad de ese tipo de bloqueosEn cambio, me pregunto qué tal sería usar como fuente de verdad tablas relacionales tradicionales mutables y registrar un log de cambios mediante triggers
No me gusta el borrado en cascada. Como la mayoría de los desarrolladores vive más en la capa de aplicación —Python, Node, Go— que en la base de datos, el borrado en cascada puede parecer magia: borras una fila de la tabla A y también desaparecen datos de la tabla B. Si está mal configurado es todavía más peligroso, así que para mantenimiento a largo plazo son mejores los
DELETEexplícitos, y con solo usar bien las llaves foráneas ya se puede mantener la consistenciaSon válidas las trampas y soluciones alternativas en migraciones de tablas grandes, pero ya existen herramientas como pg-osc. Debería ser algo tan simple como ejecutar un comando y luego observar con tensión durante las 24 horas en que se copian los datos
Las implementaciones de la aplicación y de la base de datos deberían separarse desde temprano. Como no puedes desplegar cambios de esquema y de aplicación como una sola transacción totalmente simultánea, una vez en producción hace falta acostumbrarse a hacer solo cambios de esquema retrocompatibles, como crear columnas nuevas nullable o con valor por defecto, y no renombrar tablas ni columnas
La estrategia de gestión del esquema también debe definirse pronto. Hay que evitar procedimientos de despliegue donde un desarrollador senior ejecuta DDL manualmente en la base de producción desde su computadora; se pueden usar herramientas conocidas como Liquibase o Flyway
El planificador de consultas optimiza el caso promedio, pero en una aplicación a veces es más útil optimizar el peor caso. Un usuario promedio tenía pocas filas y cierto índice devolvía resultados en menos de 10 ms, pero para usuarios de alto uso la misma consulta podía tardar más de 1 segundo según los parámetros
Forcé otra ruta de índices con una consulta más compleja; el rendimiento promedio empeoró un poco, pero el peor caso también bajó a menos de 100 ms. Para la empresa era mucho más importante evitar timeouts que ahorrar 10 ms en promedio
SKIP LOCKEDsirve para colas de trabajo basadas en transacciones interactivas donde la aplicación mantiene la transacción abierta y bloquea filas mientras trabaja. En aplicaciones de alto rendimiento conviene evitar esas transacciones y actualizar la fila de inmediato apending, así queSKIP LOCKEDno hace faltaA medida que crece la escala, hay que reducir el estado que se mantiene en memoria dentro de la base de datos, y las transacciones interactivas también cuentan como ese tipo de estado. En entornos escalados, la idempotencia conviene más que la atomicidad
Las transacciones largas pueden dañar el estado de la base de datos, así que solo deberían usarse con una justificación fuerte. Usa
idle_in_transaction_session_timeoutpara evitar que transacciones inactivas retengan bloqueos o tuplas por demasiado tiempo, y configuralock_timeouten las migraciones para que una sola sentencia DDL no congele todo el sistemaTambién hay que configurar
statement_timeoutpara que una consulta costosa no paralice el sistemaTras operar PostgreSQL en una startup temprana, este artículo no enfatiza lo suficiente el monitoreo y las alertas. En PostgreSQL hay varios tipos clave de fallas que deben evitarse sí o sí, y con alertas se puede detectar el riesgo temprano
Aunque AWS mande un correo diciendo que el ciclo de IDs de transacción está por agotarse, en una startup eso puede pasarse por alto fácilmente, especialmente en días como Boxing Day. Las señales que vigila AWS deberían conectarse a un pager, no a un email
Hay grandes diferencias poco conocidas entre implementaciones de pool de conexiones. La mayoría de los pools de conexión de aplicaciones optimizan latencia baja y disponibilidad de conexiones con FIFO, pero al mantener las conexiones siempre calientes es difícil reducir las innecesarias
PgBouncer y algunos poolers externos usan LIFO para optimizar la cantidad de conexiones que llegan a PostgreSQL y el throughput. Si se reutiliza primero la conexión más reciente, las conexiones sobrantes se enfrían de forma natural y se cierran
Para aplicaciones nuevas, FIFO basta, pero a mayor escala conviene usar herramientas como PgBouncer para reducir cientos de conexiones hasta en un 90%. Como PostgreSQL crea un proceso por conexión, funciona mejor cuanto menos conexiones tenga
En situaciones muy específicas, se obtuvieron buenos resultados haciendo joins en la memoria de la aplicación. A veces, para reducir los viajes de ida y vuelta a la base de datos, se termina creando una sola consulta con
JOIN,UNIONyCASEcomplejos mezclados.En cambio, ejecutar varias consultas simples de forma independiente y luego recorrer los resultados para vincular las filas relacionadas con mapas puede resultar más conveniente, aunque agregue más viajes de ida y vuelta y costo de iteración, porque el plan de consulta se vuelve más predecible. Debe usarse de forma limitada, y no se recomienda ciegamente solo porque algunos ORM funcionan así internamente.
Pero un inner join selectivo produce un resultado mucho más pequeño que los datos originales, por lo que traer todos los registros y hacer localmente la intersección y el filtrado resulta mucho más costoso. En un index join, el planificador de consultas también puede aprovechar los índices para evitar escaneos completos de tablas, ordenamientos y filtrados indiscriminados.