MVCC: cómo Postgres y MySQL leen sin bloquear escrituras

MVCC: cómo Postgres y MySQL leen sin bloquear escrituras

@programacion

Lo esencial

  • MVCC (Multiversion Concurrency Control) guarda varias versiones de cada fila en vez de bloquearla al leerla.
  • Postgres marca cada fila con las columnas ocultas xmin y xmax para saber qué versión es visible.
  • MySQL con InnoDB no duplica la fila: reconstruye versiones anteriores desde un undo log.
  • En ambos motores, un SELECT nunca espera un lock de escritura: lee la versión que corresponde a su snapshot.
  • READ COMMITTED toma un snapshot nuevo por cada sentencia; REPEATABLE READ toma uno solo por transacción.
  • Postgres limpia las filas muertas con VACUUM; si no corre a tiempo, la tabla crece (bloat).
  • MVCC no evita conflictos entre dos escrituras a la misma fila: ahí siguen existiendo los locks.

Cuando ejecutás un SELECT en Postgres mientras otra transacción actualiza esa misma fila, tu consulta no espera ni un milisegundo: lee una versión congelada del dato, aunque ese dato esté cambiando en ese instante.

Qué es MVCC

MVCC (Multiversion Concurrency Control, control de concurrencia multiversión) es la estrategia que usan Postgres, MySQL con InnoDB, Oracle y SQL Server en su modo snapshot para que lecturas y escrituras convivan sin pisarse. En lugar de bloquear una fila cuando alguien la lee, la base de datos guarda varias versiones de esa fila al mismo tiempo.

La idea central: cada transacción ve una fotografía (snapshot) de la base tomada en un instante concreto. Si otra transacción modifica una fila después de esa fotografía, la primera transacción no ve el cambio hasta que arranca una consulta nueva.

El problema que resuelve: lectores contra escritores

Sin MVCC, la forma clásica de evitar que dos transacciones choquen es con locks: un lector pide un shared lock, un escritor pide un exclusive lock, y si coinciden uno de los dos espera. Un SELECT largo puede bloquear un UPDATE urgente durante varios segundos.

MVCC elimina ese bloqueo para el caso más común: lecturas contra escrituras. Un SELECT nunca espera a un UPDATE ni viceversa, porque cada uno trabaja sobre su propia versión de los datos. Los locks siguen existiendo, pero solo entre escritores que tocan la misma fila.

Cómo lo implementa Postgres: xmin y xmax

Cada fila en Postgres tiene dos columnas ocultas: xmin (el ID de la transacción que creó esa versión) y xmax (el ID de la transacción que la reemplazó, 0 si sigue vigente). Podés verlas directamente:

BEGIN;
SELECT xmin, xmax, ctid, saldo FROM cuentas WHERE id = 1;

Cuando ejecutás un UPDATE, Postgres no modifica la fila en su lugar: inserta una fila nueva con un xmin distinto y le pone el xmax de la transacción actual a la fila vieja. El mecanismo completo está descrito en la introducción a MVCC de la documentación oficial de Postgres. Cada transacción decide qué versión leer comparando su propio ID contra el xmin y xmax de cada fila candidata.

Esto significa que un UPDATE en Postgres siempre deja basura atrás: la fila vieja queda marcada como muerta pero ocupa espacio hasta que algo la limpie.

Cómo lo implementa MySQL: el undo log de InnoDB

InnoDB, el motor de almacenamiento por defecto de MySQL, resuelve MVCC distinto. En vez de guardar versiones completas dentro de la tabla, mantiene la fila actual en su lugar y escribe los cambios previos en un undo log segmentado.

Cuando una transacción vieja necesita leer una versión anterior de la fila, InnoDB reconstruye esa versión aplicando los registros del undo log hacia atrás. La referencia oficial está en la documentación de InnoDB Multi-Versioning.

La diferencia práctica: Postgres paga el costo de MVCC en la tabla principal (bloat, requiere VACUUM); MySQL lo paga en el undo log, que también hay que purgar pero no infla la tabla de datos.

Niveles de aislamiento y snapshots

MVCC no define un único comportamiento: depende del nivel de aislamiento. En READ COMMITTED (default en Postgres), cada sentencia SQL toma su propio snapshot; si corrés dos SELECT dentro de la misma transacción, el segundo puede ver cambios que el primero no vio.

En REPEATABLE READ (default de MySQL/InnoDB), la transacción entera usa un solo snapshot tomado al principio. Postgres también soporta este nivel, pero ahí toda la transacción congela su vista de los datos, no solo la sentencia.

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT saldo FROM cuentas WHERE id = 1;
-- otra sesión hace: UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1; COMMIT;
SELECT saldo FROM cuentas WHERE id = 1; -- devuelve el mismo valor que antes
COMMIT;

Ese comportamiento, leer siempre el mismo valor dentro de la transacción, es exactamente lo que MVCC hace posible sin tomar ningún lock de lectura.

El costo oculto: bloat y VACUUM

En Postgres, cada UPDATE o DELETE deja filas muertas que ocupan espacio en disco hasta que el proceso VACUUM las recicla. Si una tabla recibe muchos updates y VACUUM no corre con suficiente frecuencia, la tabla crece y los índices se vuelven menos eficientes.

Para medir cuántas filas muertas tiene una tabla concreta, sin adivinar el número, se corre:

SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'cuentas';

Si n_dead_tup crece sin bajar, VACUUM no está alcanzando el ritmo de escrituras y conviene revisar el parámetro autovacuum_vacuum_scale_factor. MySQL/InnoDB tiene un problema análogo: si una transacción larga queda abierta, el hilo purge no puede liberar el undo log y este crece sin límite.

Cuándo MVCC no alcanza

MVCC resuelve lecturas contra escrituras, pero no elimina los conflictos entre dos escrituras concurrentes sobre la misma fila: ahí sigue habiendo locks, y una de las dos transacciones espera o falla. Tampoco previene anomalías como el write skew en REPEATABLE READ, donde dos transacciones leen datos distintos, cada una toma una decisión válida por separado, pero juntas violan una regla de negocio.

Para esos casos, Postgres ofrece el nivel SERIALIZABLE, que detecta esos conflictos y aborta una de las transacciones con un error que la aplicación debe reintentar. Ese nivel agrega verificación adicional y es más lento, así que conviene reservarlo para las operaciones donde el error de negocio es inaceptable, no usarlo por defecto en toda la aplicación.

Conclusión

MVCC es la razón por la que un reporte pesado no frena el checkout de una tienda que corre sobre la misma base de datos. Postgres y MySQL llegan al mismo resultado por caminos distintos, versiones completas en la tabla contra un undo log reconstruible, pero comparten la idea central: leer no debería bloquear escribir, y viceversa.

📖 Versión extendida con más detalle: https://elsolitario.org/2026/08/26/mvcc-multiversion-bases-de-datos/?utm_source=telegraph&utm_medium=instant_view&utm_campaign=programacion

Report Page