DEV Community

Cover image for MVCC: cómo Postgres y MySQL leen sin bloquear escrituras
lu1tr0n
lu1tr0n

Posted on Originally published at elsolitario.org

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

Una consulta SELECT en PostgreSQL nunca espera a que termine un UPDATE sobre la misma fila: lee una versión anterior mientras la escritura ocurre en paralelo, sin locks de por medio. Ese comportamiento tiene nombre: MVCC (Multi-Version Concurrency Control), el mecanismo que PostgreSQL, MySQL/InnoDB, Oracle, SQLite y CockroachDB usan para que lectores y escritores no se bloqueen entre sí.

Entender MVCC no es trivia de bases de datos: explica por qué una tabla crece aunque borres filas, por qué existe el comando VACUUM, y por qué dos transacciones simultáneas a veces fallan con un error de serialización que hay que reintentar.

TL;DR

  • Vas a entender por qué un SELECT nunca bloquea un UPDATE en Postgres, MySQL o SQLite gracias a MVCC.- Vas a poder leer las columnas internas xmin y xmax de Postgres para saber qué versión de una fila estás viendo.- Vas a saber diferenciar Read Committed, Repeatable Read y Serializable, y elegir el nivel correcto para tu caso.- Vas a poder escribir un retry loop que maneje errores de serialización (SQLSTATE 40001) en transacciones concurrentes.- Vas a entender por qué existe VACUUM y cómo medir el bloat de una tabla con pg_stat_user_tables.- Vas a poder explicar la diferencia entre el modelo de versiones de Postgres y el undo log de InnoDB.- Vas a saber qué es write skew y por qué ni Repeatable Read lo evita siempre.

Qué es MVCC y por qué importa

MVCC es una estrategia de control de concurrencia: en vez de bloquear una fila para que solo una transacción la toque a la vez, la base de datos guarda múltiples versiones de esa fila y le muestra a cada transacción la versión que corresponde a su propio momento en el tiempo. Un lector nunca espera a un escritor, y un escritor nunca espera a un lector: cada uno trabaja sobre su propia foto del dato.

El modelo alternativo, el bloqueo de dos fases (2PL, two-phase locking), obliga a que cualquier transacción que quiera leer una fila espere si otra la tiene bloqueada para escritura. Funciona, pero en cargas con muchas lecturas concurrentes genera colas de espera que MVCC evita por diseño. Por eso PostgreSQL, MySQL/InnoDB, Oracle, SQL Server (con snapshot isolation activado) y SQLite en modo WAL lo adoptaron como base de su motor transaccional.

La diferencia se nota en producción: una aplicación con MVCC puede correr reportes largos con SELECT sin frenar los INSERT y UPDATE que llegan al mismo tiempo. El costo no desaparece, se traslada: alguien tiene que limpiar las versiones viejas que ya nadie necesita, y ese trabajo lo hace el proceso de vacuum en Postgres o el purge thread en InnoDB.

Cómo funciona MVCC por dentro

Cada motor implementa MVCC distinto, pero la idea de base es la misma: cada fila lleva metadata invisible que marca cuándo nació y cuándo murió esa versión.

PostgreSQL agrega dos columnas ocultas a cada fila física: xmin, el ID de la transacción que la creó, y xmax, el ID de la transacción que la reemplazó o borró (vacío mientras la versión sigue viva). Cuando hacés un UPDATE, Postgres no modifica la fila en el lugar: crea una fila nueva con un xmin nuevo y marca el xmax de la fila vieja con la transacción actual, tal como describe la documentación oficial de control de concurrencia. Cada transacción decide qué versión de una fila "ve" comparando esos IDs contra su propio snapshot.

Qué es exactamente un snapshot

Un snapshot no es una copia de los datos: es una lista de IDs de transacción. Cuando una transacción arranca (o ejecuta su primer SELECT, según el nivel de aislamiento), Postgres anota qué transacciones están activas en ese instante. Cualquier fila creada por una transacción que estaba en esa lista, o por una transacción que todavía no había empezado, se considera invisible. El resto es visible. Comparar un puñado de números enteros es mucho más barato que copiar datos, y es lo que hace que tomar un snapshot sea prácticamente gratis.

SELECT xmin, xmax, id, saldo FROM cuentas;
Enter fullscreen mode Exit fullscreen mode

Corriendo esta consulta vas a ver que las columnas xmin y xmax existen aunque nunca las declaraste: son metadata interna que Postgres expone bajo pedido para depurar exactamente este mecanismo.

MySQL/InnoDB hace lo opuesto: modifica la fila en el lugar, pero antes de sobreescribirla copia la versión anterior a un undo log separado. Si otra transacción necesita una versión vieja, InnoDB la reconstruye leyendo el undo log hacia atrás, según explica la documentación de InnoDB. Esto hace que Postgres tienda a acumular más espacio en disco por versiones viejas (el conocido bloat), mientras que InnoDB paga el costo reconstruyendo filas viejas cuando el undo log crece.

SQLite, en su modo WAL (Write-Ahead Logging), logra un efecto similar: los escritores agregan páginas nuevas al final de un log en vez de modificar el archivo principal, y los lectores siguen viendo una versión consistente del archivo mientras el log crece, según la documentación oficial de WAL.

flowchart TD
Fila["Fila original: xmin=100, xmax=vacío"] -->|"UPDATE de la tx 105"| V2["Versión nueva: xmin=105, xmax=vacío"]
Fila -->|"xmax se marca en 105"| Muerta["Versión vieja: xmin=100, xmax=105"]
Muerta -->|"VACUUM la recicla"| Libre["Espacio reutilizable"]
Enter fullscreen mode Exit fullscreen mode

Cada UPDATE crea una versión nueva; la vieja queda marcada para el vacuum.

Ejemplos prácticos

El ejemplo más simple para ver MVCC en acción son dos sesiones de psql abiertas al mismo tiempo:

-- Sesión A: abre una transacción y todavía no confirma
BEGIN;
UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1;
-- sin COMMIT todavía

-- Sesión B: en paralelo, en otra conexión
BEGIN;
SELECT saldo FROM cuentas WHERE id = 1;
-- devuelve el saldo ANTERIOR al UPDATE de la sesión A, sin esperar ningún lock
COMMIT;
Enter fullscreen mode Exit fullscreen mode

La sesión B lee la versión que era visible cuando arrancó su transacción, sin importar que la sesión A esté escribiendo sobre la misma fila. Ningún lock detiene la lectura.

sequenceDiagram
participant W as Transacción escritora
participant D as Motor MVCC
participant R as Transacción lectora
W->>D: BEGIN
W->>D: UPDATE cuentas SET saldo = saldo - 100
R->>D: BEGIN
R->>D: SELECT saldo FROM cuentas
D-->>R: versión anterior al UPDATE de W
W->>D: COMMIT
Note over W,R: R nunca esperó el lock de W
Enter fullscreen mode Exit fullscreen mode

El segundo ejemplo es más realista: una transferencia bancaria con reintentos automáticos cuando el motor detecta un conflicto de serialización.

import psycopg2
from psycopg2 import errors

def transferir(conn, origen, destino, monto, intentos=3):
    for intento in range(intentos):
        try:
            with conn:
                with conn.cursor() as cur:
                    cur.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
                    cur.execute(
                        "UPDATE cuentas SET saldo = saldo - %s WHERE id = %s",
                        (monto, origen),
                    )
                    cur.execute(
                        "UPDATE cuentas SET saldo = saldo + %s WHERE id = %s",
                        (monto, destino),
                    )
            return True
        except errors.SerializationFailure:
            conn.rollback()
    return False
Enter fullscreen mode Exit fullscreen mode

Si dos transferencias concurrentes generan un conflicto que MVCC no puede resolver en silencio, Postgres aborta una de las dos con el código SQLSTATE 40001. La función captura ese error, hace rollback y reintenta: es el patrón estándar para trabajar con SERIALIZABLE en producción.

Niveles de aislamiento: qué anomalías tolerás

El estándar SQL define niveles de aislamiento según qué anomalías de concurrencia permiten. MVCC no elimina la necesidad de elegir un nivel: solo cambia cómo se implementa por debajo.
Nivel de aislamientoDirty readNon-repeatable readPhantom readCuándo usarloRead UncommittedPosiblePosiblePosibleCasi nunca en producciónRead Committed (default en Postgres y MySQL)NoPosiblePosibleMayoría de apps OLTPRepeatable ReadNoNoPosible en MySQL; no en PostgresReportes consistentes dentro de una transacciónSerializableNoNoNoTransferencias bancarias, inventario crítico
Postgres implementa Repeatable Read y Serializable con dos estrategias distintas de snapshot: Repeatable Read usa snapshot isolation clásico, mientras que Serializable agrega Serializable Snapshot Isolation (SSI), que detecta patrones de conflicto imposibles de serializar y aborta una de las transacciones en lugar de dejar pasar una anomalía silenciosa.
Read Committed es el nivel por defecto tanto en Postgres como en MySQL.

Cómo empezar: probarlo vos mismo

No hace falta nada más que Docker para reproducir los ejemplos anteriores en minutos:

docker run --name pg-mvcc -e POSTGRES_PASSWORD=demo -p 5432:5432 -d postgres:16
psql -h localhost -U postgres
Enter fullscreen mode Exit fullscreen mode

Con la conexión abierta, creá la tabla de prueba y corré el ejemplo de las dos sesiones de la sección anterior en dos terminales distintas:

CREATE TABLE cuentas (id serial PRIMARY KEY, saldo numeric NOT NULL);
INSERT INTO cuentas (saldo) VALUES (1000), (500);
Enter fullscreen mode Exit fullscreen mode

Para confirmar qué está pasando por debajo, tres consultas de verificación:

SHOW transaction_isolation;
SELECT pid, state, query FROM pg_stat_activity WHERE state = 'active';
SELECT relname, n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'cuentas';
Enter fullscreen mode Exit fullscreen mode

La última consulta es la clave: n_dead_tup muestra cuántas versiones viejas todavía no recicló el autovacuum. Si ese número crece sin parar mientras la tabla no crece en filas reales, tenés una transacción larga bloqueando la limpieza.

💡 Tip: Corré EXPLAIN (ANALYZE, BUFFERS) sobre una consulta lenta en una tabla con mucho n_dead_tup: vas a ver cuántas versiones descartadas tuvo que recorrer el planificador antes de encontrar la fila visible.

Casos de uso reales

Cualquier aplicación OLTP con lecturas y escrituras simultáneas se beneficia de MVCC: un e-commerce que muestra stock mientras se procesan compras, un sistema bancario que corre reportes de saldo mientras llegan transferencias, o un dashboard analítico que consulta una tabla que se actualiza en tiempo real.

Un caso concreto: una tienda online que lanza una oferta flash mantiene el catálogo con lecturas masivas de stock mientras miles de compras actualizan las mismas filas. Sin MVCC, cada lectura de stock competiría por el mismo lock que las escrituras de compra, y el sitio se volvería inusable en el pico de tráfico. Con MVCC, las lecturas ven una foto consistente del stock sin frenar ni una sola escritura.

Las bases distribuidas modernas llevan la idea un paso más allá: CockroachDB y otras bases compatibles con el protocolo de PostgreSQL extienden MVCC a través de múltiples nodos, usando timestamps híbridos en lugar de un contador de transacciones local, para que el mismo principio (lectores sin locks) funcione incluso cuando los datos están repartidos geográficamente.

Errores comunes y buenas prácticas

El error más frecuente es dejar una transacción abierta sin darse cuenta: una conexión de un ORM que no cierra el cursor, un cliente de psql olvidado con un BEGIN; sin COMMIT. Mientras esa transacción siga viva, Postgres no puede reciclar ninguna versión que sea más nueva que su snapshot, así que el bloat crece aunque el resto de la aplicación funcione perfecto.

El segundo error es asumir que Repeatable Read evita todos los conflictos de concurrencia. El caso clásico de write skew: dos médicos de guardia consultan si hay al menos otro médico disponible antes de pedir su día libre. Ambos leen "sí, hay otro disponible" en Repeatable Read, ambos piden el día, y el hospital se queda sin nadie de guardia. Ninguna fila individual tuvo una escritura conflictiva, así que Repeatable Read no lo detecta: hace falta Serializable para que Postgres identifique el patrón y aborte una de las dos transacciones.

⚠️ Ojo: Una transacción abierta durante horas, por una conexión colgada o un cursor sin cerrar, impide que VACUUM libere versiones viejas: la tabla y sus índices crecen sin control aunque borres filas todos los días.

Tercer error: no capturar el error de serialización. Si tu código asume que un COMMIT siempre funciona y no maneja SerializationFailure (Postgres) o Deadlock found (MySQL), la aplicación va a devolver errores 500 intermitentes bajo carga real en lugar de reintentar automáticamente.

Comparativa con alternativas: MVCC frente a locking pesimista

El bloqueo de dos fases (2PL) sigue siendo la opción correcta cuando los conflictos son frecuentes y detectarlos tarde sale caro: si dos transacciones casi siempre van a chocar por la misma fila, esperar con un lock puede ser más barato que dejarlas avanzar y abortar una al final. MVCC brilla en el caso contrario: cargas con muchas lecturas y pocos conflictos reales, donde bloquear a un lector por un escritor que ni siquiera toca la misma fila sería desperdiciar concurrencia.

El control de concurrencia optimista (OCC), usado por ejemplo en algunos ORMs con columnas de versión (version int), es una variante manual del mismo principio: en vez de que el motor gestione versiones internamente, la aplicación compara un número de versión antes de escribir y rechaza el UPDATE si cambió desde que se leyó.

Profundizando: vacuum, snapshots y SSI

El autovacuum de Postgres no borra versiones viejas apenas nadie las usa: espera hasta que ninguna transacción activa en el sistema pueda necesitarlas. Ese límite se llama el xmin horizon: la transacción activa más vieja del sistema. Mientras exista una sola transacción abierta con un snapshot antiguo, todas las versiones posteriores a ese punto quedan retenidas, sin importar cuántas transacciones nuevas hayan pasado después.

Serializable Snapshot Isolation (SSI), el algoritmo que usa Postgres para el nivel Serializable, no bloquea nada por adelantado. Deja que las transacciones corran en paralelo sobre snapshots independientes y, en el momento del COMMIT, revisa si existe un patrón de dependencias circulares entre lecturas y escrituras que haría imposible ordenar esas transacciones de forma serial. Si lo encuentra, aborta la transacción que hizo COMMIT más tarde con el error de serialización visto en el ejemplo de Python.

Cuando el bloat se vuelve un problema real, la primera palanca es autovacuum_vacuum_scale_factor: el porcentaje de filas muertas que dispara un vacuum automático (10% por defecto). Bajarlo en tablas con mucha escritura hace que el autovacuum corra más seguido, en lotes más chicos, en vez de acumular millones de versiones muertas y pagar un vacuum gigante de una sola vez.

stateDiagram-v2
[*] --> Viva
Viva --> Muerta: UPDATE o DELETE crea una versión nueva
Muerta --> Reciclada: VACUUM libera el espacio
Reciclada --> [*]
Enter fullscreen mode Exit fullscreen mode

📖 Resumen en Telegram: Ver resumen

Tu próximo paso: levantá el contenedor de Postgres de la sección "Cómo empezar", abrí dos sesiones de psql y reproducí el ejemplo de write skew con Repeatable Read primero y Serializable después para ver la diferencia en vivo.

Preguntas frecuentes

¿MVCC reemplaza completamente a los locks?

No. MVCC evita locks entre lectores y escritores, pero dos escritores que modifican la misma fila al mismo tiempo sí compiten por un lock de fila; uno espera a que el otro termine su transacción.

¿Por qué mi tabla en Postgres ocupa más espacio si nunca borro filas?

Cada UPDATE crea una fila nueva en vez de modificar la existente. Si el autovacuum no alcanza a limpiar las versiones viejas (por ejemplo, por una transacción larga abierta), el espacio se acumula como bloat.

¿MySQL/InnoDB usa MVCC igual que PostgreSQL?

El objetivo es el mismo, la implementación no: InnoDB modifica la fila en el lugar y guarda versiones anteriores en un undo log separado, mientras que Postgres mantiene todas las versiones dentro de la propia tabla.

¿SQLite tiene MVCC?

En modo WAL se aproxima: los escritores agregan páginas nuevas a un log sin tocar el archivo principal, así que los lectores pueden seguir viendo una versión consistente del archivo mientras hay una escritura en curso.

¿Cuándo conviene usar Serializable si es más lento?

Cuando el costo de una anomalía de concurrencia (como el write skew del ejemplo de los médicos de guardia) es más caro que el costo de reintentar una transacción abortada: transferencias de dinero, reservas de inventario, asignación de turnos.

¿Qué diferencia hay entre snapshot isolation y serializability real?

Snapshot isolation (lo que da Repeatable Read en Postgres) garantiza que cada transacción vea una foto consistente, pero permite anomalías como el write skew. Serializability real garantiza que el resultado final sea equivalente a correr todas las transacciones una detrás de otra, sin superposición; es una garantía más fuerte y más cara de sostener bajo carga.

Referencias

📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.

Top comments (0)