ALMACENAMIENTO · MVCC · VACUUM
Postgres no
sobrescribe filas.
Una tabla es un archivo, ese archivo es un arreglo de páginas de 8 KB, y la fila que devuelve un SELECT es en realidad una versión guardada dentro de una de esas páginas. Entender esa cadena explica de una sola vez el ctid, qué guarda un índice, por qué dos conexiones pueden ver precios distintos y por qué una transacción abierta durante horas degrada toda la base.
- 01 ARCHIVOUna tabla es uno o más archivos en disco, partidos en segmentos de 1 GB.↓
- 02 PÁGINAEl archivo es un arreglo de bloques de 8 KB: tamaño fijo y offset calculable.↓
- 03 LINE POINTERDentro de la página, un puntero dice dónde empieza cada tupla.↓
- 04 TUPLAEl dato físico: una versión de la fila, con xmin y xmax adentro.
TABLAVERSIÓN
01 / DEL ARCHIVO A LA PÁGINA
Una tabla es un archivo
con bloques de tamaño fijo.
Postgres no lee filas sueltas del disco: lee páginas completas. Ese detalle, que parece burocrático, define el costo de casi todo lo demás.
MIRALO EN TU BASE
Todo esto se consulta desde psql.
El archivo, el tamaño de bloque, el ctid y las columnas de sistema no son internals ocultos: son consultables en cualquier instancia.
- 01 · ARCHIVO
SELECT pg_relation_filepath('items'); - 02 · BLOQUE
SHOW block_size; - 03 · VERSIONES
SELECT ctid, xmin, xmax, price FROM items; - 04 · SALUD
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'items';
Seis piezas del modelo de almacenamiento
Archivo y segmentos
Cada tabla e índice tiene su propio archivo, dividido en segmentos de 1 GB dentro del directorio del cluster.
Página de 8 KB
El bloque es fijo: 8192 bytes por defecto, definidos al compilar. El motor razona en páginas, no en filas.
Offset calculable
La página N empieza en N × 8192. No hay que buscarla: se salta directo a esa posición y se carga el bloque.
Shared buffers
Las páginas se leen y se modifican en memoria compartida; el disco recibe el WAL primero y las páginas después.
TOAST
Un valor demasiado grande para una página se comprime y se guarda fuera de línea, en una tabla asociada.
WAL
La durabilidad no depende de escribir la página: depende de que el registro del cambio llegue antes al log.
02 / DENTRO DE UNA PÁGINA
La página tiene
su propio directorio.
Una página no es un bloque plano de filas. Tiene una cabecera, un arreglo de punteros que crece hacia adelante y las tuplas escritas desde el final hacia atrás. En el medio queda el espacio libre.
24 bytes: checksum, LSN y dónde empieza y termina el espacio libre.
los punteros crecen hacia adelante · las tuplas se escriben desde el final
El ctid es el par (número de página, índice del puntero). (0,1) significa página 0, primer puntero. Con eso, encontrar una tupla es aritmética y no búsqueda.
El puntero es la indirección
Mover una tupla dentro de su página no obliga a tocar nada más: alcanza con actualizar el line pointer.
El ctid ubica una versión
Cambia con cada UPDATE y puede cambiar durante un VACUUM FULL o un CLUSTER. Sirve para inspeccionar, no para identificar.
El espacio libre se administra
fillfactor reserva lugar en cada página para versiones futuras y el free space map recuerda dónde queda espacio.
REGLAEl ctid ubica una versión. La clave primaria identifica una fila.
03 / EL HEAP Y LOS ÍNDICES
El índice no guarda filas.
Guarda direcciones.
El heap es el conjunto de páginas donde viven las tuplas. Un índice B-tree es otra estructura, con sus propias páginas, cuyas hojas guardan una clave y el ctid al que apunta.
Descenso ordenado
De la raíz a la hoja, comparando claves en cada nivel.
Clave → ctid
La hoja no tiene el precio: tiene la dirección de una versión.
Fetch de la página
Postgres trae la página 0, sigue el puntero 2 y recién ahí lee la tupla.
La visibilidad no está en el índice: vive en la tupla. Por eso un index scan casi siempre termina leyendo el heap, y por eso existe el visibility map, que habilita index-only scans cuando la página entera ya es visible para todos.
04 / UPDATE NO SOBRESCRIBE
Cada cambio escribe
una versión nueva.
Postgres implementa MVCC guardando las versiones dentro de la misma tabla. Un UPDATE busca espacio libre, escribe la tupla nueva y marca la anterior como obsoleta a partir de una transacción determinada.
| CTID | XMIN | XMAX | PRICE | ESTADO |
|---|---|---|---|---|
| (0,1) | 712 | 964 | 10.00 | versión anterior · candidata a muerta |
| (0,2) | 964 | 0 | 20.00 | versión vigente |
xmin es la transacción que creó la tupla; xmax, la que la eliminó o la dejó obsoleta al actualizarla, y queda en 0 mientras la versión sigue vigente. Son columnas de sistema: se consultan, no se escriben.
INSERT
Escribe una tupla con xmin igual a tu transacción y xmax en 0.Si la página elegida no tiene lugar se usa otra, y si no hay ninguna se extiende el archivo.
UPDATE
Escribe una tupla nueva y pone xmax en la anterior.La versión vieja sigue ocupando espacio hasta que vacuum la recicle.
DELETE
No borra nada: sólo escribe xmax en la versión vigente.El espacio se libera después, cuando ninguna transacción pueda ver esa tupla.
05 / VISIBILIDAD
Dos conexiones,
dos verdades válidas.
Cuando una consulta llega al heap puede encontrar varias versiones de la misma fila. Elegir cuál devolver no es una decisión global: depende del snapshot de esa transacción y del estado de commit de xmin y xmax.
En Read Committed cada sentencia toma un snapshot nuevo, así que dos SELECT dentro de la misma transacción pueden ver valores distintos. En Repeatable Read el snapshot se fija en la primera sentencia y no se mueve hasta el final.
06 / LA EXCEPCIÓN: HOT
No todo UPDATE
toca los índices.
La versión corta —“un UPDATE obliga a actualizar todos los índices”— es la que más conviene corregir. Cuando ninguna columna indexada cambia y la versión nueva entra en la misma página, Postgres escribe un heap-only tuple: encadena el puntero viejo al nuevo y los índices quedan intactos.
Entrada nueva en el índice
Si cambió una columna indexada, o la página no tiene espacio, hay que escribir la dirección nueva en cada índice afectado.
Redirect dentro de la página
El puntero viejo redirige al nuevo y el índice sigue apuntando al mismo ctid. Además, la limpieza puede recuperar ese espacio sin esperar un vacuum completo.
Ninguna columna indexada modificada, más espacio libre en la misma página. fillfactor reserva ese espacio; indexar columnas que se actualizan seguido desactiva el camino HOT en la práctica.
07 / MUERTAS, BLOAT Y VACUUM
Nadie puede limpiar
lo que alguien podría leer.
Una versión vieja está muerta sólo cuando ninguna transacción activa —ni ninguna que pueda empezar— puede necesitarla. Ese límite es el horizonte, y lo fija la transacción más antigua que sigue abierta.
Mientras T1 siga abierta, autovacuum puede correr, encontrar las versiones viejas y no poder eliminarlas. La tabla y sus índices crecen, los scans leen más páginas y la latencia sube sin que ninguna consulta haya cambiado.
Seis términos que aparecen en cada incidente
Tupla muerta
Versión que ya no es visible para ninguna transacción posible; su espacio puede reciclarse.
Horizonte
Lo fija la transacción abierta más antigua. También lo retienen los slots de replicación y las réplicas con hot_standby_feedback.
autovacuum
Corre solo según umbrales por tabla; en tablas muy escritas casi nunca alcanza con los valores globales por defecto.
VACUUM y VACUUM FULL
VACUUM recicla espacio dentro de las páginas; VACUUM FULL reescribe la tabla, devuelve espacio al sistema y toma un lock exclusivo.
Visibility map
Marca las páginas totalmente visibles: habilita index-only scans y le ahorra trabajo al próximo vacuum.
Freeze
Vacuum también congela tuplas viejas para evitar el wraparound del contador de transacciones de 32 bits.
08 / QUÉ HACER CON ESTO
Del modelo mental
a decisiones operativas.
El valor de entender páginas, tuplas y MVCC aparece cuando cambia lo que hacés en el diseño y en la operación diaria.
- 01
Mantené las transacciones cortas
Abrir, hacer el trabajo y cerrar. Nada de esperar input humano o llamadas de red con un BEGIN abierto.
- 02
Mirá el trabajo pendiente
n_dead_tup en pg_stat_user_tables y la edad de la transacción más vieja en pg_stat_activity explican la mayoría de los casos de bloat.
- 03
Ajustá autovacuum por tabla
Las tablas calientes rara vez se sirven bien con los umbrales globales por defecto.
- 04
Reservá espacio donde hay updates
Bajar fillfactor en tablas muy actualizadas hace más probable el camino HOT.
- 05
Indexá con criterio
Cada índice sobre una columna que se actualiza seguido cancela HOT y suma escrituras.
- 06
No uses ctid como identificador
Es una dirección física: cambia con updates, con VACUUM FULL y con CLUSTER.
REVISIÓN DE BASE
Preguntas para una revisión de base
- 01¿Cuál es la transacción abierta más antigua ahora mismo?
- 02¿Cuántas tuplas muertas acumulan las tablas más escritas?
- 03¿Autovacuum llega a tiempo o queda atrás en los picos?
- 04¿Qué índices existen sobre columnas que se actualizan seguido?
- 05¿Hay slots de replicación inactivos reteniendo el horizonte?
- 06¿El tamaño en disco creció sin que crecieran las filas?
- 07¿Alguna parte del código guarda o compara ctid?
EL MODELO EN UNA LÍNEA
Página, puntero, versión.
Lo demás es consecuencia.
El ctid, los índices que apuntan al heap, dos conexiones con precios distintos, el bloat y el vacuum no son temas separados: son la misma decisión de diseño vista desde ángulos distintos. Postgres prefiere escribir una versión nueva antes que pisar la anterior, y todo su modelo operativo sale de ahí.
09 / FUENTES
Para seguir
profundizando.
- 01Videoyou won’t forget how postgres works after this
El video que disparó esta nota · Hussein Nasser ↗
- 02PostgreSQLDatabase Page Layout
Cabecera, punteros de línea y layout físico ↗
- 03PostgreSQLHeap-Only Tuples (HOT)
Actualizaciones que no tocan los índices ↗
- 04PostgreSQLConcurrency Control: Introduction
Snapshots, xmin, xmax y control de concurrencia ↗
- 05PostgreSQLTransaction Isolation
Read Committed, Repeatable Read y Serializable ↗
- 06PostgreSQLRoutine Vacuuming
Tuplas muertas, horizonte, freeze y wraparound ↗
- 07PostgreSQLSystem Columns
ctid, xmin, xmax y demás columnas de sistema ↗