Matías Fernández / System Design
EN POSTGRES · FIELD NOTE 03

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.

DE LA TABLA A LA VERSIÓNCUATRO NIVELES
  1. 01
    ARCHIVOUna tabla es uno o más archivos en disco, partidos en segmentos de 1 GB.
  2. 02
    PÁGINAEl archivo es un arreglo de bloques de 8 KB: tamaño fijo y offset calculable.
  3. 03
    LINE POINTERDentro de la página, un puntero dice dónde empieza cada tupla.
  4. 04
    TUPLAEl dato físico: una versión de la fila, con xmin y xmax adentro.

TABLAVERSIÓN

PUBLICADO
26 AGO 2026
LECTURA
~16 min
IDIOMA
ES · EN
ORIGEN
Video de Hussein Nasser

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.

  1. 01 · ARCHIVOSELECT pg_relation_filepath('items');
  2. 02 · BLOQUESHOW block_size;
  3. 03 · VERSIONESSELECT ctid, xmin, xmax, price FROM items;
  4. 04 · SALUDSELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'items';

Seis piezas del modelo de almacenamiento

01

Archivo y segmentos

Cada tabla e índice tiene su propio archivo, dividido en segmentos de 1 GB dentro del directorio del cluster.

02

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.

03

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.

04

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.

05

TOAST

Un valor demasiado grande para una página se comprime y se guarda fuera de línea, en una tabla asociada.

06

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.

PÁGINA 0 · 8192 BYTESCABECERA · PUNTEROS · TUPLAS
PAGE HEADER

24 bytes: checksum, LSN y dónde empieza y termina el espacio libre.

LINE POINTERS
lp 1 → 7104lp 2 → 6848lp 3 → free
ESPACIO LIBRE

los punteros crecen hacia adelante · las tuplas se escriben desde el final

TUPLAS
TUPLA · ctid (0,2)price 20.00 · xmin 964 · xmax 0
TUPLA · ctid (0,1)price 10.00 · xmin 712 · xmax 964
CTID

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.

01

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.

02

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.

03

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.

REGLA

El 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.

ÍNDICE items_pkey · HEAP itemsBÚSQUEDA DE item_id = 100
B-TREE

Descenso ordenado

root · 50 | 150branch · 80 | 100leaf · 100 → (0,2)

De la raíz a la hoja, comparando claves en cada nivel.

ENTRADA

Clave → ctid

key · 100ctid · (0,2)no row data

La hoja no tiene el precio: tiene la dirección de una versión.

HEAP

Fetch de la página

page 0 · lp 1page 0 · lp 2tuple · price 20.00

Postgres trae la página 0, sigue el puntero 2 y recién ahí lee la tupla.

POR QUÉ HAY QUE IR AL HEAP

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.

UPDATE items SET price = 20 WHERE item_id = 100TRANSACCIÓN 964
CTIDXMINXMAXPRICEESTADO
(0,1)71296410.00 versión anterior · candidata a muerta
(0,2)964020.00 versión vigente
DOS CAMPOS OCULTOS

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.

01

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.

02

UPDATE

Escribe una tupla nueva y pone xmax en la anterior.

La versión vieja sigue ocupando espacio hasta que vacuum la recicle.

03

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.

MISMA FILA · MISMO INSTANTEUN SNAPSHOT POR TRANSACCIÓN
T1 · ABIERTA ANTESBEGIN previo al commitSu snapshot no incluye a la transacción 964, así que sigue viendo la versión anterior.
BEGIN SELECT price 10.00
T2 · POSTERIORBEGIN después del commitVe la versión creada por 964 y descarta la anterior por su xmax.
BEGIN SELECT price 20.00
NIVEL DE AISLAMIENTO

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.

UPDATE ORDINARIO

Entrada nueva en el índice

ÍNDICEkey 100 → (0,2)
(0,1)versión vieja
(0,2)versión nueva

Si cambió una columna indexada, o la página no tiene espacio, hay que escribir la dirección nueva en cada índice afectado.

HOT UPDATE

Redirect dentro de la página

ÍNDICEkey 100 → (0,1)
(0,1)redirect → lp 3
(0,3)versión nueva

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.

CONDICIONES

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.

LÍNEA DE TRANSACCIONESEL HORIZONTE LO FIJA LA MÁS VIEJA
T1 · REPORTE

BEGIN hace tres horas · sigue abierta

T2 · UPDATE

commit

T3 · UPDATE

commit

TUPLAS MUERTAS

no reciclables mientras T1 viva

EL EFECTO

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

01

Tupla muerta

Versión que ya no es visible para ninguna transacción posible; su espacio puede reciclarse.

02

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.

03

autovacuum

Corre solo según umbrales por tabla; en tablas muy escritas casi nunca alcanza con los valores globales por defecto.

04

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.

05

Visibility map

Marca las páginas totalmente visibles: habilita index-only scans y le ahorra trabajo al próximo vacuum.

06

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.

  1. 01

    Mantené las transacciones cortas

    Abrir, hacer el trabajo y cerrar. Nada de esperar input humano o llamadas de red con un BEGIN abierto.

  2. 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.

  3. 03

    Ajustá autovacuum por tabla

    Las tablas calientes rara vez se sirven bien con los umbrales globales por defecto.

  4. 04

    Reservá espacio donde hay updates

    Bajar fillfactor en tablas muy actualizadas hace más probable el camino HOT.

  5. 05

    Indexá con criterio

    Cada índice sobre una columna que se actualiza seguido cancela HOT y suma escrituras.

  6. 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

  1. 01¿Cuál es la transacción abierta más antigua ahora mismo?
  2. 02¿Cuántas tuplas muertas acumulan las tablas más escritas?
  3. 03¿Autovacuum llega a tiempo o queda atrás en los picos?
  4. 04¿Qué índices existen sobre columnas que se actualizan seguido?
  5. 05¿Hay slots de replicación inactivos reteniendo el horizonte?
  6. 06¿El tamaño en disco creció sin que crecieran las filas?
  7. 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.

  1. 01Videoyou won’t forget how postgres works after this

    El video que disparó esta nota · Hussein Nasser ↗

  2. 02PostgreSQLDatabase Page Layout

    Cabecera, punteros de línea y layout físico ↗

  3. 03PostgreSQLHeap-Only Tuples (HOT)

    Actualizaciones que no tocan los índices ↗

  4. 04PostgreSQLConcurrency Control: Introduction

    Snapshots, xmin, xmax y control de concurrencia ↗

  5. 05PostgreSQLTransaction Isolation

    Read Committed, Repeatable Read y Serializable ↗

  6. 06PostgreSQLRoutine Vacuuming

    Tuplas muertas, horizonte, freeze y wraparound ↗

  7. 07PostgreSQLSystem Columns

    ctid, xmin, xmax y demás columnas de sistema ↗