Transacciones y propiedades ACID

"Una transacción es la unidad de trabajo más pequeña que tiene sentido semántico para la aplicación." — Jim Gray, padre del concepto de transacciones.

Qué vas a aprender en este capítulo

Cuando el mozo de La Esquina envía un pedido, el sistema necesita: (1) registrar el pedido, (2) descontar del inventario, y (3) notificar a la cocina — todo en un solo paso atómico. Si el servidor falla después del paso 2 pero antes del 3, ¿queda el inventario descontado sin un pedido? Las transacciones son el mecanismo que garantiza que eso nunca ocurra.


1.1 ¿Qué es una transacción?

💡 Intuición

Pensá en una transferencia bancaria: mover $100 de la cuenta A a la cuenta B requiere dos operaciones: restar de A y sumar a B. Si el sistema falla entre las dos, el dinero desaparece. La única solución es tratarlas como una sola operación indivisible — una transacción.

Una transacción es como un "guardar todo o no guardar nada" para la base de datos.

📐 Fundamento

Definición: Una transacción es una secuencia de operaciones de base de datos que se ejecutan como una unidad lógica indivisible.

Sintaxis SQL:

BEGIN;                        -- inicio de la transacción

UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1;
UPDATE cuentas SET saldo = saldo + 100 WHERE id = 2;

COMMIT;                       -- confirmar todos los cambios
-- o ROLLBACK; para deshacer todo

Las propiedades ACID (Atomicity, Consistency, Isolation, Durability):

Propiedad Qué garantiza Ejemplo
Atomicidad O se ejecutan todas las operaciones, o ninguna La transferencia no puede quedar a mitad
Consistencia La BD pasa de un estado válido a otro válido Las restricciones (FK, CHECK) siempre se cumplen
Aislamiento Las transacciones concurrentes no se interfieren Dos mozos pueden tomar pedidos simultáneamente
Durabilidad Una vez confirmada, la transacción sobrevive a fallos Un COMMIT no se pierde aunque el servidor se apague

¿Cómo implementa cada propiedad el motor?

  • Atomicidad → Write-Ahead Log (WAL): escribe los cambios en un log antes de aplicarlos. Si falla, puede deshacer.
  • Consistencia → Restricciones: FK, CHECK, UNIQUE, NOT NULL validadas en COMMIT.
  • Aislamiento → Locks o MVCC (ver sección 1.3).
  • Durabilidad → fsync: fuerza el vaciado del buffer de disco al confirmar.

1.2 Anomalías de concurrencia

💡 Intuición

Imaginá que dos mozos leen el inventario al mismo tiempo: ambos ven que quedan 5 pupusas. El primer mozo vende 3; el segundo mozo (que todavía ve 5) vende otras 3. Resultado: vendieron 6 pupusas de las 5 que había. Eso es una anomalía de concurrencia.

Hay tres tipos clásicos, cada vez más difíciles de prevenir.

📐 Fundamento

Dirty Read (lectura sucia):

La transacción T2 lee datos que T1 escribió pero aún no confirmó. Si T1 hace ROLLBACK, T2 leyó datos que nunca existieron.

T1: BEGIN
T1: UPDATE inventario SET cantidad = 0 WHERE platillo = 'pupusa'
T2: SELECT cantidad FROM inventario WHERE platillo = 'pupusa'  -- lee 0 (sucio!)
T1: ROLLBACK  -- la cantidad vuelve a ser 5
T2: ya tomó una decisión basada en 0 — ¡error!

Non-Repeatable Read (lectura no repetible):

T1 lee un valor, T2 lo modifica y confirma, T1 lo lee de nuevo y obtiene un valor diferente dentro de la misma transacción.

T1: SELECT precio FROM platillos WHERE id = 1  -- lee $1.50
T2: UPDATE platillos SET precio = $2.00 WHERE id = 1; COMMIT
T1: SELECT precio FROM platillos WHERE id = 1  -- lee <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>2.00</mn><mo stretchy="false">(</mo><mo stretchy="false">!</mo><mo>=</mo></mrow><annotation encoding="application/x-tex">2.00 (!= </annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord">2.00</span><span class="mopen">(</span><span class="mclose">!</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span></span></span></span>1.50)

Phantom Read (lectura fantasma):

T1 ejecuta una consulta con un rango y obtiene N filas. T2 inserta una fila dentro del rango y confirma. T1 ejecuta la misma consulta y obtiene N+1 filas — apareció un "fantasma".

T1: SELECT * FROM pedidos WHERE mesa = 5  -- devuelve 2 pedidos
T2: INSERT INTO pedidos (mesa, ...) VALUES (5, ...); COMMIT
T1: SELECT * FROM pedidos WHERE mesa = 5  -- devuelve 3 pedidos (fantasma!)

Resumen de anomalías por nivel de aislamiento (estándar SQL):

Nivel de aislamiento Dirty Read Non-Repeatable Phantom
READ UNCOMMITTED ✗ posible ✗ posible ✗ posible
READ COMMITTED ✓ prevenido ✗ posible ✗ posible
REPEATABLE READ ✓ prevenido ✓ prevenido ✗ posible
SERIALIZABLE ✓ prevenido ✓ prevenido ✓ prevenido

PostgreSQL por defecto: READ COMMITTED. MySQL InnoDB por defecto: REPEATABLE READ.

-- Cambiar el nivel de aislamiento para una transacción
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
-- ... operaciones ...
COMMIT;

1.3 MVCC: Control de concurrencia multiversión

💡 Intuición

Los locks son como semáforos: solo uno puede pasar a la vez. Esto garantiza seguridad, pero puede causar cuellos de botella graves en sistemas con muchas lecturas.

MVCC (Multi-Version Concurrency Control) es diferente: en lugar de bloquear, guarda múltiples versiones de cada fila. Los lectores ven la versión válida en el momento en que empezaron su transacción, sin bloquear a los escritores.

Es como si la biblioteca tuviera fotocopias del libro: vos podés leer la copia del momento en que empezaste, aunque alguien esté marcando la copia actual.

📐 Fundamento

Cómo funciona MVCC en PostgreSQL:

Cada fila tiene metadatos ocultos:

  • xmin: ID de la transacción que creó esta versión.
  • xmax: ID de la transacción que la eliminó (0 si aún está vigente).
-- Estado de la tabla pedidos (simplificado):
id | mesa | estado  | xmin | xmax
1  | 3    | ABIERTO | 100  | 0      ← versión actual

Cuando T1 (xid=200) actualiza el pedido:

UPDATE pedidos SET estado = 'CERRADO' WHERE id = 1;

PostgreSQL crea una nueva fila en lugar de modificar la existente:

id | mesa | estado  | xmin | xmax
1  | 3    | ABIERTO | 100  | 200   ← marcada como eliminada por T200
1  | 3    | CERRADO | 200  | 0     ← nueva versión creada por T200

Una transacción anterior (xid=150) que aún está corriendo verá la fila con xmin=100, xmax=0 — su versión correcta — sin necesidad de bloquear.

Ventaja: Los lectores no bloquean a los escritores y viceversa.
Desventaja: Se acumulan filas muertas (dead tuples) que hay que limpiar con VACUUM.

Locks pesimistas vs MVCC:

Locks pesimistas MVCC
Lectores bloquean escritores No
Escritores bloquean lectores No
Overhead de memoria Bajo Alto (múltiples versiones)
Riesgo de deadlock Reducido
Usado en MySQL MyISAM PostgreSQL, MySQL InnoDB, Oracle

1.4 Deadlocks

💡 Intuición

T1 quiere A y está esperando B. T2 quiere B y está esperando A. Ambas esperan para siempre — deadlock.

Como dos personas caminando por un pasillo angosto: cada una espera que la otra se mueva primero.

📐 Fundamento

Ejemplo de deadlock:

T1: LOCK TABLE inventario FOR UPDATE  -- T1 bloquea inventario
T2: LOCK TABLE pedidos FOR UPDATE     -- T2 bloquea pedidos
T1: LOCK TABLE pedidos FOR UPDATE     -- T1 espera a T2
T2: LOCK TABLE inventario FOR UPDATE  -- T2 espera a T1 → DEADLOCK

Cómo los detecta el motor:

El motor de BD mantiene un wait-for graph (grafo de espera). Si detecta un ciclo → hay un deadlock. El motor elige una víctima (normalmente la transacción con menos trabajo hecho) y la cancela con un error:

ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678;
        blocked by process 5678.
        Process 5678 waits for ShareLock on transaction 1234;
        blocked by process 1234.
HINT: See server log for query details.

Estrategias para evitar deadlocks:

  1. Orden consistente de bloqueo: Siempre bloquear las tablas en el mismo orden (ej: siempre inventario antes de pedidos).
  2. Transacciones cortas: Cuanto menos dure una transacción, menos tiempo tiene para interferir.
  3. SELECT FOR UPDATE: Bloquear al leer, no después:
    BEGIN;
    SELECT * FROM inventario WHERE platillo_id = 1 FOR UPDATE;
    -- ahora podés modificar con seguridad, sin race condition
    UPDATE inventario SET cantidad = cantidad - 1 WHERE platillo_id = 1;
    COMMIT;
    
  4. Reintentar: En aplicaciones, capturar el error de deadlock y reintentar la transacción.

🛠️ En la práctica

La Esquina: registrar pedido con inventario atómico

-- Función que registra un pedido y descuenta inventario en una transacción
BEGIN;

-- 1. Crear el pedido
INSERT INTO pedidos (mesa_id, mozo_id, estado, fecha)
VALUES (3, 7, 'ABIERTO', NOW())
RETURNING id INTO v_pedido_id;

-- 2. Agregar ítems y verificar inventario (con lock para evitar race condition)
INSERT INTO items_pedido (pedido_id, platillo_id, cantidad, precio_unit)
VALUES (v_pedido_id, 1, 2, 1.50);

-- 3. Descontar inventario con lock
SELECT cantidad FROM inventario 
WHERE platillo_id = 1 FOR UPDATE;   -- bloquea la fila

IF cantidad < 2 THEN
  ROLLBACK;                          -- no hay stock suficiente
  RAISE EXCEPTION 'Stock insuficiente';
END IF;

UPDATE inventario SET cantidad = cantidad - 2
WHERE platillo_id = 1;

COMMIT;                              -- todo o nada

Resultado: Si el servidor falla después del INSERT pero antes del COMMIT, el WAL permite deshacer todo. El pedido nunca queda "a mitad".


1.5 Ejercicios

✏️ Ejercicio 1.1 — Identificar anomalías

Para cada escenario, identificá qué anomalía de concurrencia ocurre y en qué nivel de aislamiento se previene:

a. El cajero consulta el saldo de una cuenta (500),elsistemadescuenta500), el sistema descuenta 200 (otro proceso), el cajero vuelve a consultar y ve $300 en la misma transacción.

b. T1 lee datos que T2 escribió pero todavía no confirmó. T2 hace ROLLBACK.

c. T1 busca todos los pedidos de la mesa 5 y encuentra 2. T2 inserta un nuevo pedido para la mesa 5. T1 repite la búsqueda dentro de la misma transacción y encuentra 3.

✏️ Ejercicio 1.2 — Diseñar transacción

Escribí en pseudoSQL (o SQL estándar) una transacción para el siguiente escenario de La Esquina:

Cierre de cuenta de mesa: Cuando el cliente pide la cuenta, el sistema debe:

  1. Marcar todos los pedidos de esa mesa como CERRADO.
  2. Calcular el total (suma de precio_unit * cantidad de todos los ítems).
  3. Insertar un registro en la tabla facturas con el total calculado.
  4. Si el total es 0 (mesa sin pedidos), hacer ROLLBACK con mensaje de error.

El proceso debe ser completamente atómico.

✏️ Ejercicio 1.3 — MVCC conceptual

Explicá con tus propias palabras por qué MVCC permite que lectores y escritores no se bloqueen entre sí. ¿Qué precio (desventaja) paga el sistema por esta capacidad?


1.6 Para profundizar

1.7 Errores comunes

⚠️ Trampa común

Pensar que SERIALIZABLE "no tiene errores". Sí, te da el aislamiento más fuerte; no, no significa que tu código corra bien. En PostgreSQL, SERIALIZABLE puede abortar una transacción con serialization_failure cuando detecta un conflicto. Si tu app no maneja ese error con un retry, lo único que conseguís es exponer al usuario un error 500 que en READ COMMITTED no aparecía.

Tip: cualquier transacción que use SERIALIZABLE debe envolverse en un try/except que reintente la operación 2-3 veces antes de fallar al usuario.

⚠️ Trampa común

Mantener una transacción abierta esperando input del usuario. Abrir transacción → mostrar pantalla → esperar que el cajero confirme → commit. Mientras tanto, bloqueás filas durante minutos. Bajo carga, eso vuelve la base inutilizable. La transacción debe ser lo más corta posible: validar, escribir, commitear. La interacción con el usuario va fuera.

Tip: el patrón correcto es optimistic locking: leés sin bloqueo, mostrás el form, y al guardar comparás un campo version para detectar conflictos.


Definiciones nuevas: transacción, ACID, atomicidad, consistencia, aislamiento, durabilidad, WAL, dirty read, non-repeatable read, phantom read, READ COMMITTED, REPEATABLE READ, SERIALIZABLE, MVCC, dead tuple, VACUUM, deadlock, wait-for graph, SELECT FOR UPDATE.