MVCC (Multi-Version Concurrency Control)

Avanzado
SQL
SQL
Actualizado: 18/04/2026

Diagrama: Sql mvcc control concurrencia

El problema de la concurrencia

Cuando una tabla es accedida por decenas o miles de transacciones simultáneas, el motor de base de datos debe decidir qué ve cada una. Las dos estrategias históricas son:

  1. Bloqueo (lock-based): cuando una transacción modifica una fila, las demás se bloquean hasta que termine. Simple pero poco escalable: los lectores se bloquean ante un escritor, los escritores entre sí, y la concurrencia se desploma.
  2. MVCC (Multi-Version Concurrency Control): el motor mantiene varias versiones de cada fila. Cada transacción ve la versión que le corresponde según su snapshot. Lectores y escritores no se bloquean entre sí.

PostgreSQL, Oracle, SQL Server (con RCSI activado), MySQL InnoDB, CockroachDB y la mayoría de motores modernos usan MVCC como estrategia principal.

Cómo funciona: xmin y xmax

En PostgreSQL cada fila tiene dos campos ocultos:

  • xmin: el ID de la transacción (XID) que la creó.
  • xmax: el XID de la transacción que la borró (o 0 si la fila sigue viva).
SELECT xmin, xmax, * FROM productos LIMIT 5;
-- xmin |  xmax  | id | nombre
-- 1023 |   0    |  1 | Laptop
-- 1045 |   0    |  2 | Tablet
-- 1023 | 1067   |  3 | Mouse      <- borrada por xid 1067

Cuando una transacción empieza, recibe un snapshot que incluye:

  • xmin_snapshot: todas las transacciones con XID menor ya estaban confirmadas.
  • xmax_snapshot: el XID "siguiente"; transacciones con este XID o mayor no se ven.
  • Lista de XIDs activos: transacciones que estaban ejecutándose en ese momento y que la tuya no ve.

Una fila es visible para una transacción si:

  • Su xmin fue confirmado antes del snapshot (y no está en la lista de activos).
  • Su xmax es 0, no confirmado aún, o posterior al snapshot.

UPDATE no modifica: crea una nueva versión

Esta es la clave del modelo. Cuando haces un UPDATE, el motor NO modifica la fila existente. En su lugar:

  1. Marca la fila vieja con xmax = XID_actual (lógicamente borrada para transacciones futuras).
  2. Inserta una nueva fila con los valores actualizados y xmin = XID_actual.
Estado antes del UPDATE:
  xmin=1000, xmax=0, id=1, precio=100

Despues de UPDATE productos SET precio=120 WHERE id=1 (xid=1500):
  xmin=1000, xmax=1500, id=1, precio=100   <- version vieja
  xmin=1500, xmax=0,    id=1, precio=120   <- version nueva

Una transacción con snapshot anterior a 1500 sigue viendo la versión vieja. Una transacción nueva ve la versión nueva. Ambas coexisten sin bloqueos.

Ventajas: lectores no bloquean escritores

El efecto práctico más importante: SELECT nunca bloquea y nunca es bloqueado por INSERT/UPDATE/DELETE de otras transacciones. Esto permite:

  • Ejecutar reports largos sin frenar las escrituras de la aplicación.
  • Consistencia de la lectura sin repetir queries.
  • Backup online (pg_dump) mientras la aplicación escribe.
-- Transaccion A (largo reporte)
BEGIN;
SELECT SUM(importe) FROM ventas WHERE anio = 2026;  -- tarda 30s
-- Mientras tanto, transaccion B:
--   BEGIN;
--   UPDATE ventas SET importe = importe * 1.05 WHERE id = 123;
--   COMMIT;
-- La transaccion A no se ve afectada, sigue viendo su snapshot inicial
SELECT ...;  -- sigue viendo los datos antiguos
COMMIT;

Contraparte: bloat y VACUUM

El precio a pagar: las filas muertas (tuples marcadas con xmax confirmado cuyo xmax es anterior al snapshot mínimo activo) ocupan espacio. En PostgreSQL, ese espacio no se libera automáticamente al COMMIT. Hay que ejecutar VACUUM periódicamente para reclamarlo.

-- Inspeccionar bloat
SELECT schemaname, relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS pct_bloat
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

-- Ejecutar VACUUM manual
VACUUM ANALYZE productos;

-- VACUUM agresivo (reclama espacio al SO, pero bloquea)
VACUUM FULL productos;

Por defecto, PostgreSQL ejecuta autovacuum en segundo plano. Configurar sus umbrales (autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay) es crítico en bases de datos grandes.

El problema de las transacciones largas

Una transacción ABIERTA mucho tiempo impide que VACUUM limpie filas muertas creadas desde que empezó. El motor no puede declarar como "muerta" una fila que la transacción larga podría aún querer leer.

-- Transaccion A (olvidada abierta)
BEGIN;
SELECT * FROM productos;
-- sin COMMIT durante horas

-- Mientras tanto millones de UPDATEs en otras transacciones generan
-- millones de filas muertas. Autovacuum no puede limpiarlas porque
-- la transaccion A podria aun verlas.

Síntoma: la tabla crece en disco pero tiene pocas filas activas. Herramienta de diagnóstico:

SELECT pid, state, xact_start, query_start,
       NOW() - xact_start AS duracion_tx
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'active')
  AND xact_start IS NOT NULL
ORDER BY xact_start;

Mata las transacciones inactivas peligrosas con pg_terminate_backend(pid). Mejor aún: configura idle_in_transaction_session_timeout para que el motor mate automáticamente las transacciones ociosas.

Diferencias entre PostgreSQL y MySQL InnoDB

| Aspecto | PostgreSQL | MySQL InnoDB | |---------|-----------|--------------| | Dónde guarda las versiones viejas | En el heap junto a las vivas | En rollback segments (undo) separados | | UPDATE escribe | Nueva fila en heap | Solo deltas en el rollback segment | | Reclamación de espacio | VACUUM (explícito o autovacuum) | Purge thread (automático) | | Bloat típico | Puede acumularse mucho | Menos visible, pero puede llenar undo | | Impacto de transacciones largas | Impide vacuum, crece bloat | Llena undo, puede causar snapshot too old |

InnoDB y Oracle separan las versiones antiguas en un undo log separado. PostgreSQL las mantiene en la propia tabla (heap-only tuples, HOT updates). Ambos modelos funcionan; PostgreSQL sacrifica simplicidad de diseño a cambio de la dependencia de VACUUM.

Ver MVCC en acción

-- Session 1
BEGIN;
SELECT txid_current();  -- digamos devuelve 5001
SELECT * FROM productos WHERE id = 1;
-- (no hacer COMMIT todavia)

-- Session 2 (otra conexion)
BEGIN;
UPDATE productos SET precio = 200 WHERE id = 1;
COMMIT;

-- Session 1 (vuelve)
SELECT * FROM productos WHERE id = 1;  -- sigue viendo el precio viejo
COMMIT;

-- Session 1 (tras COMMIT, nueva query)
SELECT * FROM productos WHERE id = 1;  -- ahora ve 200

La transacción 1 es consistente en el tiempo: durante toda su vida ve la misma foto. Tras COMMIT, la siguiente consulta ya pertenece a otra transacción con un snapshot más reciente.

Implicaciones para el desarrollador

  • Las transacciones deben ser cortas: minimiza el tiempo entre BEGIN y COMMIT.
  • Evita hacer lógica de negocio costosa dentro de una transacción: calcular, mandar emails, llamar APIs externas.
  • SELECT ... FOR UPDATE si necesitas que la fila leída no cambie hasta tu COMMIT (combina con MVCC para bloqueo explícito).
  • Configura idle_in_transaction_session_timeout en producción para que conexiones olvidadas no bloqueen VACUUM.
  • Monitoriza bloat periódicamente y ajusta autovacuum si crece.
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, SQL es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de SQL

Explora más contenido relacionado con SQL y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Comprender como MVCC mantiene multiples versiones de cada fila con xmin y xmax. Entender por que en MVCC los lectores no bloquean escritores. Conocer el concepto de snapshot y como cada transaccion lo adquiere. Identificar el problema de bloat y como VACUUM lo resuelve en PostgreSQL. Comparar la implementacion MVCC de PostgreSQL (undo en heap) con la de MySQL InnoDB (undo en rollback segment).

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje