Una instantánea larga de PostgreSQL puede conservar versiones intermedias de filas que nunca lee. Vacuum utiliza un horizonte de transacciones conservador para decidir qué se puede eliminar. Este tutorial explica por qué y luego reproduce el comportamiento con filas de artículos sintéticas y node-postgres en un Mac.
La tarea consiste en distinguir una instantánea que retrasa la limpieza de un vacuum que aún no se ha ejecutado. El ejemplo está dirigido a PostgreSQL 18 y utiliza dos conexiones a la base de datos: una lectora que mantiene una instantánea Repeatable Read y una escritora que actualiza un solo artículo. La documentación de PostgreSQL identifica actualmente la versión 18 como la versión principal compatible más reciente; los detalles de implementación que aparecen a continuación corresponden explícitamente a su rama 18. Documentación de PostgreSQL 18.
¿Por qué conservar versiones que el lector antiguo no puede ver?
Una actualización crea una nueva versión de la fila. PostgreSQL conserva la versión anterior mientras otra transacción pueda necesitarla. Con el tiempo, vacuum puede recuperar las versiones obsoletas y dejar que su espacio de almacenamiento vuelva a utilizarse. Ese es el ciclo de vida básico de MVCC descrito en Vacuum rutinario.
La sutileza está en que la visibilidad de las instantáneas y la posibilidad física de eliminar versiones responden a preguntas distintas. La visibilidad determina si una consulta concreta debe devolver una versión. La limpieza utiliza un límite conservador, en vez de demostrar para cada versión qué instantáneas existentes podrían leerla.
En PostgreSQL 18, HeapTupleSatisfiesVacuum compara la transacción que volvió obsoleta una versión con OldestXmin. Una eliminación o sustitución confirmada no hace que la versión anterior pueda eliminarse de inmediato: las versiones cuya transacción de eliminación se encuentra en ese límite o después de él siguen clasificándose como recientemente muertas. Esta regla está documentada directamente en la implementación oficial de visibilidad de tuplas.
Considera esta secuencia ilustrativa. Cada actualización se confirma por separado y no hay otros lectores:
| Paso | Acción | Lo que ve el lector antiguo | Última versión confirmada |
|---|---|---|---|
| 1 | Insertar la revisión 0 del artículo | — | Revisión 0 |
| 2 | El lector establece una instantánea Repeatable Read | Revisión 0 | Revisión 0 |
| 3 | La escritora confirma la revisión 1 | Revisión 0 | Revisión 1 |
| 4 | La escritora confirma la revisión 2 | Revisión 0 | Revisión 2 |
| 5 | La escritora confirma la revisión 3 | Revisión 0 | Revisión 3 |
El lector necesita directamente la revisión 0. Las revisiones 1 y 2 son versiones intermedias: este lector no puede verlas porque se crearon después de su instantánea. Aun así, las transacciones que las sustituyeron también son más recientes que el límite de limpieza vigente. Aplicar la regla documentada del límite explica por qué estas versiones pueden permanecer junto con la revisión 0. Que se conserven no significa que el lector pueda recorrerlas como resultados de consultas históricas. Implementación de visibilidad de tuplas de PostgreSQL 18.
El horizonte abarca más que la fila seleccionada por el lector. PostgreSQL 18 calcula horizontes adecuados para cada relación a partir de los ID de transacción de los procesos y de los horizontes de las instantáneas; por lo general, para los datos de tablas ordinarias se tienen en cuenta las sesiones de la misma base de datos. No realiza un seguimiento únicamente de las filas devueltas por una SELECT concreta. Esto explica por qué una consulta pequeña puede afectar la limpieza en otras partes de esa base de datos. Las relaciones compartidas y otros tipos de relaciones tienen reglas distintas, así que «una instantánea detiene todo vacuum en todas partes» es una afirmación demasiado amplia. Implementación de horizontes de PostgreSQL 18.
¿Qué transacción mantiene realmente una instantánea larga?
Repeatable Read establece su instantánea en la primera instrucción que no sea de control de transacciones y luego la utiliza para las consultas posteriores. Ejecutar únicamente BEGIN no basta para esta demostración; la consulta inicial del artículo establece la vista que debe conservarse. Read Committed, el nivel de aislamiento predeterminado de PostgreSQL, obtiene una nueva instantánea para cada comando. Aislamiento de transacciones.
Por lo tanto, una transacción Repeatable Read inactiva que haya consultado la tabla puede mantener su instantánea después de que termine la consulta. Una instrucción Read Committed de larga duración también puede conservar una instantánea mientras se ejecuta, pero las instrucciones separadas que ya terminaron en ese nivel no comparten la misma vista fija. Por eso, la duración de una transacción por sí sola no determina qué instantánea se conserva. Aislamiento de transacciones.
Una transacción de solo lectura puede contribuir al horizonte sin tener un ID de transacción de nivel superior propio. La implementación tiene en cuenta tanto los ID de transacción de los procesos como los horizontes de las instantáneas. En este ejemplo, un backend_xid nulo junto con un backend_xmin no nulo es compatible con que el lector conserve una instantánea. Implementación de horizontes de PostgreSQL 18.
Reproduce el comportamiento desde Terminal en Mac
Necesitas un servidor local de PostgreSQL 18 en ejecución, sus herramientas de línea de comandos y Node.js con npm. La documentación de compatibilidad de node-postgres incluye Node.js 20 o 24. Este tutorial supone que la configuración de conexión local existente funciona. Crea una base de datos aparte y utiliza únicamente texto sintético para el artículo. Introducción e instalación de node-postgres.
En Terminal, crea un directorio de trabajo, instala el controlador y crea la base de datos de ejemplo:
mkdir article-snapshot-lab
cd article-snapshot-lab
npm init -y
npm install pg
createdb snapshot_lab
Guarda el programa completo que aparece a continuación como snapshot-demo.cjs. Sus conexiones heredan la configuración del entorno de PostgreSQL, incluidos PGDATABASE, PGHOST y PGPORT, tal como se documenta en Conexión de node-postgres. Usa la misma configuración de conexión local para createdb y el programa.
El lector mantiene todas las instrucciones de la transacción en un único cliente dedicado. node-postgres lo requiere: una transacción debe usar el mismo cliente durante todo su ciclo, por lo que no es adecuado enviar sus instrucciones mediante pool.query(). El segundo cliente ejecuta las actualizaciones y los comandos vacuum de forma independiente. Transacciones en node-postgres.
El ejemplo también indexa revision, cuyo valor cambia con cada actualización. Esto impide deliberadamente que estas actualizaciones usen HOT y facilita la interpretación de la demostración. PostgreSQL documenta que HOT requiere que las actualizaciones no modifiquen las columnas referenciadas por índices ordinarios, además de que haya espacio suficiente en la página original. Tuplas solo de montón.
const { Client } = require('pg');
async function main() {
const reader = new Client({
application_name: 'article_snapshot_reader',
connectionTimeoutMillis: 5000
});
const writer = new Client({
application_name: 'article_snapshot_writer',
connectionTimeoutMillis: 5000
});
let readerOpen = false;
writer.on('notice', notice => console.log(notice.message));
try {
await reader.connect();
await writer.connect();
const version = await writer.query('SHOW server_version_num');
const major = Math.floor(Number(version.rows[0].server_version_num) / 10000);
if (major !== 18) throw new Error('This walkthrough targets PostgreSQL 18.');
await writer.query(
'CREATE TABLE public.article_snapshot_demo (' +
'id integer PRIMARY KEY, revision integer NOT NULL, title text NOT NULL)'
);
await writer.query(
'CREATE INDEX article_snapshot_demo_revision_idx ' +
'ON public.article_snapshot_demo (revision)'
);
await writer.query(
'INSERT INTO public.article_snapshot_demo VALUES (1, 0, $1)',
['Article draft']
);
await reader.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
readerOpen = true;
const selectRow = 'SELECT id, revision, title FROM public.article_snapshot_demo';
console.log('Reader before:', (await reader.query(selectRow)).rows);
for (const revision of [1, 2, 3]) {
await writer.query(
'UPDATE public.article_snapshot_demo SET revision = $1, title = $2 WHERE id = 1',
[revision, 'Article revision ' + revision]
);
}
const pid = (await reader.query('SELECT pg_backend_pid() AS pid')).rows[0].pid;
console.log('Reader activity:', (await writer.query(
'SELECT state, backend_xid, backend_xmin FROM pg_stat_activity WHERE pid = $1',
[pid]
)).rows);
console.log('Writer latest:', (await writer.query(selectRow)).rows);
console.log('Reader still:', (await reader.query(selectRow)).rows);
console.log('Vacuum with snapshot open:');
await writer.query('VACUUM (VERBOSE) public.article_snapshot_demo');
await reader.query('COMMIT');
readerOpen = false;
console.log('Vacuum after reader commit:');
await writer.query('VACUUM (VERBOSE) public.article_snapshot_demo');
console.log('Reader fresh:', (await reader.query(selectRow)).rows);
} finally {
if (readerOpen) await reader.query('ROLLBACK').catch(console.error);
await Promise.allSettled([reader.end(), writer.end()]);
}
}
main().catch(error => {
console.error(error.message);
process.exitCode = 1;
});
Ejecútalo especificando la base de datos:
PGDATABASE=snapshot_lab node snapshot-demo.cjs
La sintaxis de JavaScript del programa se comprobó con Node.js. La secuencia de operaciones en la base de datos se contrastó con la documentación consultada; no se ejecutó en un servidor PostgreSQL para este artículo.
Cómo interpretar las dos pasadas de vacuum
Los resultados esperados de las consultas son la revisión 0 para Reader before, la revisión 3 para Writer latest y nuevamente la revisión 0 para Reader still. Después de que el lector confirme la transacción, su siguiente consulta independiente debería devolver la revisión 3. Estos son resultados esperados de la secuencia sintética, no resultados de pruebas registrados.
El resultado de la actividad debería identificar al lector como idle in transaction mientras la escritora lo inspecciona, con un backend_xmin retenido. PostgreSQL define ese estado como una transacción a la espera de otro comando y backend_xmin como el horizonte actual del proceso servidor. El sistema de estadísticas acumulativas.
Consulta el primer informe de vacuum para ver las versiones obsoletas que siguen sin poder eliminarse. Después de que el lector confirme la transacción, compara el segundo informe para comprobar si esas versiones se eliminaron. En una base de datos inactiva, las tres sustituciones confirmadas proporcionan tres versiones obsoletas que examinar. Otros horizontes retenidos o tareas de mantenimiento concurrentes pueden afectar el informe exacto, así que utiliza el patrón antes y después en vez de exigir que el texto de salida sea idéntico.
VACUUM (VERBOSE) genera un informe de actividad detallado. El programa imprime las notificaciones del servidor mediante el evento notice documentado por node-postgres. Ambas llamadas a vacuum se ejecutan fuera de bloques de transacciones explícitos porque PostgreSQL no permite ejecutar VACUUM dentro de uno. VACUUM de PostgreSQL, API Client de node-postgres.
Utiliza esta guía de diagnóstico si el resultado difiere de lo esperado:
| Observación | Siguiente comprobación |
|---|---|
| El lector ve la revisión 3 antes de confirmar la transacción | Confirma que se inició Repeatable Read y que la primera consulta del artículo se ejecutó antes de las actualizaciones. |
| El primer vacuum conserva versiones obsoletas | Compáralo con la pasada posterior a la confirmación del lector; es normal que se conserven mientras la instantánea está abierta. |
| Quedan versiones después de que el lector confirme la transacción | Comprueba si hay otro horizonte retenido; finalizar esta transacción solo elimina la contribución de este lector. |
| Un recuento de estadísticas de la tabla parece incoherente | Considera n_dead_tup como una estimación y compara los informes de vacuum. |
| El archivo de la tabla no se hace más pequeño | Comprueba si el espacio quedó disponible para reutilizarse; reducir el archivo es un resultado distinto. |
PostgreSQL describe explícitamente n_dead_tup como un recuento estimado. La fecha registrada de vacuum también confirma que se realizó el mantenimiento, pero no que todas las versiones obsoletas pudieran eliminarse. Estos campos ayudan al diagnóstico, pero ninguno sustituye el examen de la instantánea retenida y del informe de vacuum. El sistema de estadísticas acumulativas.
Por lo general, vacuum simple deja el espacio disponible para reutilizarse dentro de la tabla en vez de devolverlo a macOS. Que las páginas finales estén completamente vacías constituye una excepción documentada. Por tanto, un tamaño de archivo estable después de la limpieza no demuestra que esta haya fallado. Vacuum rutinario.
¿Qué debería cambiar en la aplicación?
Si varias consultas de artículos deben compartir una vista coherente de la base de datos, limita la transacción a esas consultas y termínala cuando finalicen. Como consecuencia del diseño de la aplicación ante la retención de instantáneas, realiza los procesos no relacionados y la interacción con el escritorio después de que termine esa transacción, siempre que la tarea lo permita.
Si cada consulta puede usar por separado la vista confirmada más reciente, Read Committed proporciona instantáneas a nivel de comando. Cambiar el nivel de aislamiento o confirmar la transacción entre lotes modifica la semántica de consistencia: las consultas posteriores pueden ver revisiones de artículos confirmadas recientemente. Elige esa opción únicamente si esos resultados cambiantes se ajustan a la tarea del lector. Aislamiento de transacciones.
Aumentar la frecuencia de vacuum no permite que una versión supere un límite de posibilidad de eliminación que no ha cambiado. Finalizar la instantánea innecesaria elimina esa contribución a la retención. Del mismo modo, VACUUM FULL reescribe la tabla y requiere un bloqueo exclusivo y espacio adicional; no es la respuesta rutinaria a este ejemplo. Implementación de visibilidad de tuplas de PostgreSQL 18, VACUUM de PostgreSQL.
El script deja disponible su tabla sintética para inspeccionarla. Falla deliberadamente si esa tabla ya existe, en lugar de reemplazar su contenido. Después de inspeccionarla, ejecuta el siguiente comando en una sesión de consulta conectada a snapshot_lab si quieres eliminar la tabla de ejemplo. Si una ejecución falló durante la configuración, inspecciona esa misma tabla antes de eliminarla y volver a ejecutar el programa.
DROP TABLE public.article_snapshot_demo;
El diagnóstico útil consiste en observar la transición: establecer una instantánea antigua, confirmar varias sustituciones, ejecutar vacuum, finalizar la instantánea y volver a ejecutar vacuum. Ver que la revisión original sigue siendo legible mientras las versiones intermedias también se resisten a la limpieza permite distinguir claramente ambos conceptos. Las reglas de visibilidad de PostgreSQL determinan qué devuelve la consulta; su horizonte de limpieza determina cuándo puede reutilizarse el almacenamiento obsoleto.
Fuentes
13.2. Aislamiento de transacciones — Grupo de Desarrollo Global de PostgreSQL.
24.1. Vacuum rutinario — Grupo de Desarrollo Global de PostgreSQL.
VACUUM — Grupo de Desarrollo Global de PostgreSQL.
27.2. El sistema de estadísticas acumulativas — Grupo de Desarrollo Global de PostgreSQL.
66.7. Tuplas solo de montón (HOT) — Grupo de Desarrollo Global de PostgreSQL.
heapam_visibility.c, REL_18_STABLE — Proyecto PostgreSQL.
procarray.c, REL_18_STABLE — Proyecto PostgreSQL.
Introducción — node-postgres.
Conexión — node-postgres.
Transacciones — node-postgres.
API Client — node-postgres.