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 | Sí | No |
| Escritores bloquean lectores | Sí | No |
| Overhead de memoria | Bajo | Alto (múltiples versiones) |
| Riesgo de deadlock | Sí | 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:
- Orden consistente de bloqueo: Siempre bloquear las tablas en el mismo orden (ej: siempre
inventarioantes depedidos). - Transacciones cortas: Cuanto menos dure una transacción, menos tiempo tiene para interferir.
- 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; - 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 (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.
Solución
a. Non-Repeatable Read — T1 leyó el mismo dato dos veces y obtuvo valores diferentes porque T2 lo modificó entre las dos lecturas. Se previene con REPEATABLE READ.
b. Dirty Read — T1 leyó datos no confirmados. Se previene con READ COMMITTED.
c. Phantom Read — aparecieron filas nuevas en un rango que ya fue consultado. Se previene con SERIALIZABLE.
✏️ 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:
- Marcar todos los pedidos de esa mesa como
CERRADO. - Calcular el total (suma de
precio_unit * cantidadde todos los ítems). - Insertar un registro en la tabla
facturascon el total calculado. - Si el total es 0 (mesa sin pedidos), hacer ROLLBACK con mensaje de error.
El proceso debe ser completamente atómico.
Solución
BEGIN;
-- 1. Bloquear pedidos de la mesa para evitar nuevos pedidos durante el cierre
SELECT id FROM pedidos WHERE mesa_id = :mesa AND estado = 'ABIERTO' FOR UPDATE;
-- 2. Calcular total
SELECT SUM(ip.precio_unit * ip.cantidad) INTO v_total
FROM items_pedido ip
JOIN pedidos p ON ip.pedido_id = p.id
WHERE p.mesa_id = :mesa AND p.estado = 'ABIERTO';
-- 3. Verificar que hay consumo
IF v_total IS NULL OR v_total = 0 THEN
ROLLBACK;
RAISE EXCEPTION 'La mesa % no tiene pedidos activos', :mesa;
END IF;
-- 4. Cerrar pedidos
UPDATE pedidos SET estado = 'CERRADO', fecha_cierre = NOW()
WHERE mesa_id = :mesa AND estado = 'ABIERTO';
-- 5. Insertar factura
INSERT INTO facturas (mesa_id, total, iva, total_con_iva, fecha)
VALUES (:mesa, v_total, v_total * 0.13, v_total * 1.13, NOW());
COMMIT;
✏️ 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?
Solución
Por qué no se bloquean: MVCC mantiene múltiples versiones de cada fila. Cuando un escritor modifica una fila, no sobrescribe la versión anterior — crea una nueva. Los lectores que iniciaron antes del cambio siguen viendo la versión antigua sin necesidad de un lock. Los escritores pueden crear nuevas versiones sin esperar a que los lectores terminen.
El precio: El motor acumula versiones antiguas de filas que ya nadie necesita ("dead tuples"). Si no se limpian, la base de datos crece indefinidamente y las consultas se vuelven más lentas (tienen que saltar versiones muertas). PostgreSQL usa el proceso VACUUM para limpiarlas periódicamente. En sistemas muy activos, el VACUUM puede quedarse atrás y esto se vuelve un problema de mantenimiento.
1.6 Para profundizar
- Gray & Reuter, Transaction Processing: Concepts and Techniques — el libro fundacional sobre transacciones.
- PostgreSQL docs: Concurrency Control — documentación oficial de MVCC en PostgreSQL.
- Kleppmann, Designing Data-Intensive Applications, cap. 7 — la mejor explicación moderna de transacciones distribuidas.
- Siguiente: Índices y optimización de consultas.
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.