Backup Incremental: Una Feature Largamente Esperada
Antes de PG 17, pg_basebackup siempre tomaba un backup full. Siempre. Una base de 500GB significaba copiar 500GB, ya sea que cambiaras 10 filas o 10 millones desde el último backup. Las alternativas eran herramientas de terceros como pgBackRest (excelente, pero otra cosa más para operar) o snapshots a nivel de filesystem (que tienen sus propios problemas).
PG 17 agrega soporte nativo de backup incremental. Así funciona:
# Paso 1: Habilitar summarización de WAL (requerido, hacé esto primero!)
# postgresql.conf
summarize_wal = on
# Reiniciar PostgreSQL para que tome efecto
sudo systemctl restart postgresql@17-main
# Paso 2: Tomar el primer backup full (este es el baseline)
pg_basebackup -D /backups/full_20260114 \
--checkpoint=fast \
--wal-method=stream \
--manifest-checksums=SHA256 \
-U replication_user
# Paso 3: Tomar backups incrementales (solo bloques cambiados)
pg_basebackup -D /backups/incr_20260115 \
--incremental=/backups/full_20260114/backup_manifest \
--checkpoint=fast \
--wal-method=stream \
-U replication_user
El flag --incremental apunta al archivo de manifiesto del backup previo. PostgreSQL compara los resúmenes de WAL para determinar qué bloques de 8KB cambiaron desde que se creó ese manifiesto, y solo transfiere ese es bloques.
Números del mundo real de una base de datos de producción representativa (480GB):
| Tipo de backup | Tamaño | Duración | Notas |
|---|---|---|---|
| Backup full | 480GB | 47 min | Baseline |
| Día 1 incremental | 8.2GB | 4.2 min | ~1.7% tasa de cambio diario |
| Día 2 incremental | 9.1GB | 4.8 min | Día un poco más movido |
| Día 7 incremental | 7.6GB | 3.9 min | Finde, menos actividad |
| Full sintético semanal | 480GB | 12 min | Reconstruido de la cadena |
Esa última fila es importante. Puede reconstruir un backup full a partir de una cadena incremental usando pg_combinebackup:
# Combinar full + incrementales en un backup full sintético
pg_combinebackup \
/backups/full_20260114 \
/backups/incr_20260115 \
/backups/incr_20260116 \
/backups/incr_20260117 \
-o /backups/synthetic_full_20260117
# El full sintético se puede usar como nuevo baseline
# para futuras cadenas incrementales
La reconstrucción toma ~12 minutos para una base de 480GB porque es mayormente lecturas y escrituras secuenciales, no se necesita transferencia de red si todos los backups están en el mismo storage.
El gotcha de la summarización de WAL: El setting summarize_wal crea archivos de resumen en pg_wal/summaries/. Son chicos (unos pocos KB cada uno), pero solo se crean para WAL que se genera después de que se habilita el setting. Si lo se habilita e inmediatamente se intenta un backup incremental, va a fallar porque no hay resumen cubriendo el período entre tu backup full anterior y ahora. Habilitalo primero, tomá un backup full fresco, después iniciar tu cadena incremental.
Failover de Replicación Lógica: Finalmente
PG 17 agrega failover = true en subscriptions y el parámetro sync en slots de replicación. Esto significa que los slots de replicación lógica pueden sobrevivir un failover a un standby. Antes de esto, si tu primary se caía y promovías un standby, todos los subscribers de replicación lógica tenían que ser reinicializados desde cero. En una base de 500GB, eso son horas de downtime.
Acá está el setup:
-- En el primary: crear una publication
CREATE PUBLICATION my_pub FOR ALL TABLES;
-- En el subscriber: crear subscription con soporte de failover
CREATE SUBSCRIPTION my_sub
CONNECTION 'host=primary-host port=5432 dbname=mydb'
PUBLICATION my_pub
WITH (failover = true);
En el primary, se va a ver que el slot de replicación tiene failover = true:
SELECT slot_name, plugin, failover, synced
FROM pg_replication_slots
WHERE slot_name = 'my_sub';
-- slot_name | plugin | failover | synced
-- -----------+---------+----------+--------
-- my_sub | pgoutput| t | f
La columna synced pasa a true una vez que el standby sincronizó la posición del slot. Cuando pasa el failover, el nuevo primary tiene la posición LSN exacta del subscriber, así que la replicación se reanuda sin pérdida de datos ni re-sync.
La configuración en el standby que habilita esto:
# postgresql.conf en standby
sync_replication_slots = on
hot_standby_feedback = on
Probando con un failover simulado (promoviendo el standby vía pg_ctl promote), el subscriber se reconecta y reanuda la replicación en 3 segundos. Antes de PG 17, el mismo escenario requería dropear y recrear la subscription, lo que significaba un sync completo de tabla, 2 horas para un dataset comparable.
JSON_TABLE: SQL/JSON Hecho Bien
Escribir jsonb_array_elements con lateral joins siempre se sintió como un workaround. PG 17 implementa JSON_TABLE() del estándar SQL/JSON, y es sustancialmente más limpio:
-- La forma vieja (PG 16 y anteriores)
SELECT
e->>'id' AS id,
e->>'name' AS name,
e->>'email' AS email
FROM orders o,
LATERAL jsonb_array_elements(o.metadata->'contacts') AS e
WHERE o.created_at > '2026-01-01';
-- La forma nueva (PG 17)
SELECT t.*
FROM orders o,
JSON_TABLE(
o.metadata,
'$.contacts[*]'
COLUMNS (
id INTEGER PATH '$.id',
name TEXT PATH '$.name',
email TEXT PATH '$.email'
)
) AS t
WHERE o.created_at > '2026-01-01';
Pero el poder real está en los paths anidados y el manejo de errores:
-- JSON_TABLE anidado con manejo de errores
SELECT t.*
FROM api_responses r,
JSON_TABLE(
r.body,
'$.data.users[*]'
COLUMNS (
user_id INTEGER PATH '$.id',
username TEXT PATH '$.profile.name'
DEFAULT 'unknown' ON EMPTY
DEFAULT 'error' ON ERROR,
NESTED PATH '$.orders[*]' COLUMNS (
order_id INTEGER PATH '$.id',
total NUMERIC(10,2) PATH '$.total',
items_count INTEGER PATH '$.items.size()'
)
)
) AS t;
Las cláusulas ON EMPTY y ON ERROR son un game-changer para lidiar con estructuras JSON inconsistentes. Se acabaron las cadenas de COALESCE(jsonb_extract_path_text(...), 'default').
En cuanto a rendimiento, JSON_TABLE no es magia, sigue haciendo el mismo trabajo por debajo. Pero el query planner puede razonar mejor porque ve una sola función que retorna tabla en vez de una cadena de lateral joins. En un query que toca 50,000 filas con un promedio de 8 elementos JSON en array cada una, los benchmarks muestran una mejora del 22% en tiempo de ejecución versus el approach equivalente con jsonb_array_elements.
pg_stat_io: Entendiendo Tus Patrones de I/O
Esta vista se agregó en PG 16, pero PG 17 la expandió significativamente con mejor categorización de backend types y estadísticas de extend. Se convierte en una herramienta de diagnóstico esencial.
-- Top consumidores de I/O por tipo de backend
SELECT
backend_type,
io_object,
io_context,
reads,
read_bytes / (1024*1024*1024.0) AS read_gb,
writes,
write_bytes / (1024*1024*1024.0) AS write_gb,
extends,
fsyncs
FROM pg_stat_io
WHERE reads > 0 OR writes > 0
ORDER BY reads + writes DESC
LIMIT 20;
Lo que esto revela en un server de producción representativo:
backend_type | io_object | io_context | reads | read_gb | writes | write_gb
-------------------+-----------+------------+----------+---------+----------+---------
autovacuum worker | relation | normal | 14822041 | 112.8 | 2841023 | 21.6
client backend | relation | normal | 4201882 | 32.0 | 891204 | 6.8
client backend | relation | bulkread | 3102441 | 23.6 | 0 | 0.0
background writer | relation | normal | 0 | 0.0 | 1204812 | 9.2
checkpointer | relation | normal | 0 | 0.0 | 982104 | 7.5
Autovacuum estaba haciendo 3.5x más lecturas que todos los queries de clientes combinados. Eso no es necesariamente malo, autovacuum está haciendo su trabajo, pero indica que el autovacuum_vacuum_cost_delay era demasiado agresivo y vacuum estaba escaneando tablas que no lo necesitaban.
Después del tuning:
-- Settings de autovacuum por tabla para tablas grandes, mayormente estáticas
ALTER TABLE historical_events SET (
autovacuum_vacuum_threshold = 10000,
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_analyze_threshold = 5000
);
Las lecturas de autovacuum cayeron un 60%, y el rendimiento de I/O de queries de clientes mejoró mediblemente porque había menos contención en el buffer pool.
Mejoras de VACUUM: 8x Menos Lecturas de Páginas
PG 17 cambió fundamentalmente cómo VACUUM maneja las páginas. El comportamiento anterior era leer cada página de una tabla para checkear dead tuples, incluso si la página ya estaba completamente frozen (cada tuple visible para todas las transacciones). PG 17 trackea el estado de páginas frozen en el visibility map y las saltea completamente.
Para una tabla grande con una ratio alta de páginas frozen sobre total (común para tablas append-heavy como logs de eventos), esto es transformador:
-- Verificar la ratio de páginas frozen de tu tabla
SELECT
relname,
pg_size_pretty(pg_relation_size(oid)) AS table_size,
n_tup_ins AS inserts_since_vacuum,
n_tup_upd AS updates_since_vacuum,
n_dead_tup AS dead_tuples,
last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'events'
ORDER BY pg_relation_size(oid) DESC;
En una tabla events representativa (89GB, 95% páginas frozen), el tiempo de VACUUM cayó de 18 minutos a 2.2 minutos. La reducción de I/O fue incluso más dramática, de 89GB de lecturas a menos de 5GB.
El Camino de Migración: PG 15/16 → PG 17
Esta migración se realizó múltiples veces en diferentes entornos. Acá está el playbook que funciona:
Opción A: pg_upgrade (más simple, breve downtime)
# 1. Instalar PG 17 junto a la versión existente
sudo apt install postgresql-17
# 2. Parar ambos clusters
sudo systemctl stop postgresql@16-main
sudo systemctl stop postgresql@17-main
# 3. Correr pg_upgrade en modo check primero
sudo -u postgres pg_upgrade \
--old-datadir=/var/lib/postgresql/16/main \
--new-datadir=/var/lib/postgresql/17/main \
--old-bindir=/usr/lib/postgresql/16/bin \
--new-bindir=/usr/lib/postgresql/17/bin \
--check
# 4. Si el check pasa, correr el upgrade real
sudo -u postgres pg_upgrade \
--old-datadir=/var/lib/postgresql/16/main \
--new-datadir=/var/lib/postgresql/17/main \
--old-bindir=/usr/lib/postgresql/16/bin \
--new-bindir=/usr/lib/postgresql/17/bin \
--link # Usar hardlinks, 10x más rápido
# 5. Arrancar PG 17 y correr el script post-upgrade
sudo systemctl start postgresql@17-main
sudo -u postgres /var/lib/postgresql/17/main/analyze_new_cluster.sh
La opción --link es crítica, crea hardlinks en vez de copiar archivos de datos, reduciendo el tiempo de upgrade de horas a minutos para bases grandes. Los archivos de datos del cluster viejo se vuelven inválidos después de esto (comparten páginas con el cluster nuevo), así que mantener un backup apropiado.
Para una base de 480GB, tiempos típicos de upgrade:
- Con
--link: 4 minutos (mayormente operaciones de catálogo) - Sin
--link: 3 horas 20 minutos (copiando 480GB)
Opción B: Replicación lógica (zero downtime)
# 1. Levantar PG 17 como instancia fresca
# 2. Crear el schema en PG 17 (pg_dump --schema-only)
pg_dump -h pg16-host -d mydb --schema-only | psql -h pg17-host -d mydb
# 3. Crear publication en PG 16
psql -h pg16-host -d mydb -c "CREATE PUBLICATION upgrade_pub FOR ALL TABLES;"
# 4. Crear subscription en PG 17
psql -h pg17-host -d mydb -c "
CREATE SUBSCRIPTION upgrade_sub
CONNECTION 'host=pg16-host port=5432 dbname=mydb'
PUBLICATION upgrade_pub;"
# 5. Esperar al sync inicial (checkear pg_stat_subscription)
# 6. Cuando esté al día, apuntar la aplicación a PG 17
# 7. Dropear subscription y publication
Este approach tiene zero downtime pero requiere más cuidado operacional, teners que manejar sequences, large objects, y cualquier cambio DDL durante la ventana de migración.
El gotcha específico de PG 17
Si se está usando replicación lógica en PG 16 y upgradeando a PG 17, tener en cuenta que PG 17 cambió cómo funciona el tracking de LSN de slots de replicación con la nueva feature de failover. Si tus slots de replicación fueron creados sin failover = true (el default), van a funcionar bien después del upgrade. Pero si se intenta agregar failover = true a slots existentes post-upgrade, se necesita asegurarte de que sync_replication_slots = on en todos los standbys primero, o el slot va a parecer sano pero no va a estar realmente sincronizado.
Limitaciones y Advertencias
No todo en PG 17 es una ganancia para cada workload:
- Las mejoras de MERGE están buenas pero siguen por detrás del INSERT ON DUPLICATE KEY UPDATE de MySQL en rendimiento crudo para patrones de upsert con alta concurrencia.
- El lenguaje de path SQL/JSON es poderoso pero los mensajes de error cuando una expresión de path está mal son crípticos. Debuggear queries de
JSON_TABLErequiere paciencia. - La nueva opción DEFAULT de COPY es útil pero tiene una interacción sorprendente con columnas GENERATED ALWAYS, se necesita especificar listas de columnas explícitamente para evitar errores.
Estas son quejas menores en el contexto de un release genuinamente sólido. PG 17 es el tipo de upgrade que no requiere que cambies nada pero hace que todo funcione un poco mejor. Y la feature de backup incremental sola justifica el upgrade para cualquiera manejando bases de datos de más de 100GB.
La recomendación: si se está en PG 15, iniciar a planear la migración ahora. Si se está en PG 16, el upgrade es directo y las mejoras operacionales valen la pena. Si seguís en PG 14 o menos, realmente se necesita moverte, PG 14 llega a end-of-life en noviembre 2026.