Transacciones y aislamiento en PostgreSQL: MVCC, niveles y anomalías que debes conocer

Las transacciones son la base de la consistencia en cualquier base de datos relacional, pero el nivel de aislamiento que configuras determina exactamente qué anomalías puedes ver y cuáles no. PostgreSQL implementa el aislamiento mediante MVCC, un mecanismo que permite lecturas y escrituras concurrentes sin bloquearse mutuamente en la mayoría de los casos.

Cómo funciona MVCC por dentro

MVCC (Multi-Version Concurrency Control) es el mecanismo que usa PostgreSQL para la concurrencia. La idea es que en lugar de bloquear una fila cuando la escribes, PostgreSQL mantiene varias versiones de la misma fila simultáneamente. Cada transacción ve una instantánea consistente de los datos en el momento en que empezó.

Por dentro, cada fila tiene dos campos de sistema: xmin, que guarda el ID de la transacción que creó esa versión de la fila, y xmax, que guarda el ID de la transacción que la eliminó o actualizó. Cuando haces una query, PostgreSQL filtra las versiones de filas que son visibles para tu transacción según esos valores y el snapshot que tienes.

-- Puedes ver estos campos de sistema directamente
SELECT xmin, xmax, id, nombre FROM usuarios LIMIT 5;

Esto explica por qué en PostgreSQL los lectores no bloquean a los escritores y los escritores no bloquean a los lectores: cada uno ve su propia versión de los datos. La consecuencia, sin embargo, es que las filas antiguas acumulan versiones muertas que hay que limpiar con VACUUM.

Los niveles de aislamiento

PostgreSQL soporta cuatro niveles del estándar SQL, aunque en la práctica implementa tres de forma distinta:

  • READ UNCOMMITTED: en el estándar permite dirty reads, pero PostgreSQL lo trata igual que READ COMMITTED. No existe dirty read en PostgreSQL.
  • READ COMMITTED (por defecto): cada sentencia dentro de la transacción ve el estado commiteado en el momento en que esa sentencia empieza. Si otra transacción hace commit entre dos SELECT de la misma transacción, el segundo SELECT verá los nuevos datos.
  • REPEATABLE READ: toda la transacción ve el snapshot del momento en que empezó. Evita non-repeatable reads pero no evita phantom reads en el estándar SQL. En PostgreSQL, sin embargo, sí los evita gracias a MVCC.
  • SERIALIZABLE: el nivel más fuerte. Las transacciones se ejecutan como si fueran estrictamente secuenciales. Evita todas las anomalías, incluyendo write skew.
-- Cambiar nivel de aislamiento en la transaccion actual
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- tus queries aqui
COMMIT;

-- O al iniciar la transaccion
BEGIN ISOLATION LEVEL SERIALIZABLE;

Las anomalías que debes conocer

Dirty read: leer datos no commiteados de otra transacción. PostgreSQL lo evita en todos los niveles.

Non-repeatable read: leer la misma fila dos veces en la misma transacción y obtener resultados diferentes porque otra transacción hizo commit entre medias. Ocurre en READ COMMITTED, no en REPEATABLE READ ni SERIALIZABLE.

Phantom read: ejecutar la misma query dos veces y obtener filas adicionales porque otra transacción insertó filas que cumplen el predicado. El estándar SQL dice que REPEATABLE READ no lo evita, pero la implementación MVCC de PostgreSQL sí.

Write skew: la anomalía más sutil. Ocurre cuando dos transacciones leen los mismos datos, toman decisiones basadas en lo que leen, y cada una escribe algo diferente. El resultado final viola una restricción que ninguna de las dos violó individualmente.

-- Ejemplo de write skew en nivel REPEATABLE READ:
-- T1: SELECT COUNT(*) FROM medicos_de_guardia; -- devuelve 2
-- T2: SELECT COUNT(*) FROM medicos_de_guardia; -- devuelve 2
-- T1: UPDATE medicos SET de_guardia = false WHERE id = 1; -- ahora hay 1
-- T2: UPDATE medicos SET de_guardia = false WHERE id = 2; -- ahora hay 0
-- Ambas commitean. Resultado: 0 medicos de guardia. Restriccion violada.

-- La solucion: SERIALIZABLE
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ahora PostgreSQL detecta el conflicto y aborta una de las dos

Cuándo usar cada nivel

READ COMMITTED es suficiente para la mayoría de las aplicaciones web. REPEATABLE READ viene bien cuando necesitas consistencia dentro de una transacción larga que hace varios SELECT sobre los mismos datos. SERIALIZABLE es necesario cuando tienes invariantes de negocio complejas que dependen de lecturas y escrituras combinadas, como el ejemplo de médicos de guardia de arriba.

El coste de SERIALIZABLE no es enorme en PostgreSQL (usa Serializable Snapshot Isolation, no bloqueos de tabla), pero puede haber más aborts y reintentos en carga alta, así que hay que implementar la lógica de reintento en la aplicación.

Este tema conecta directamente con cómo los ORMs gestionan las transacciones. Si usas Elixir, el artículo sobre Ecto y changesets explica cómo Ecto envuelve las operaciones en transacciones. Y si el rendimiento de queries bajo carga es tu preocupación, el artículo sobre EXPLAIN ANALYZE te dará las herramientas para diagnosticar los planes de ejecución en esas condiciones.

Imagen: Pexels / Morthy Jameson

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP