Tema
Transacciones y ACID
Cuando un cliente compra en la tienda pasan varias cosas: se crea el pedido, se guardan sus productos y se descuenta el stock. ¿Qué pasa si el servidor se cae justo después de crear el pedido pero antes de descontar el stock? Datos inconsistentes, inventario falso, clientes molestos. Las transacciones garantizan que un grupo de operaciones se ejecute completo o nada. Aquí verás cómo usarlas, qué significa ACID, los niveles de aislamiento y cómo evitar vender el mismo producto dos veces.
Concepto
Piensa en una transferencia bancaria de 1,000 pesos de tu cuenta a la de un amigo:
- Restar 1,000 de tu cuenta.
- Sumar 1,000 a la de tu amigo.
Si el paso 1 se ejecuta y el 2 falla, el dinero desaparece. Una transacción agrupa ambos pasos en una unidad indivisible: o se aplican los dos, o ninguno.
txt
BEGIN ──► paso 1 ──► paso 2 ──► paso 3 ──► COMMIT ✔ todo se guarda
│ │
└── error ─┴──────────────► ROLLBACK ✘ nada se guarda, como si no hubiera pasadoComandos básicos
| Comando | Qué hace |
|---|---|
BEGIN / START TRANSACTION | Inicia una transacción |
COMMIT | Confirma: los cambios se vuelven permanentes y visibles |
ROLLBACK | Deshace todo lo hecho desde el BEGIN |
SAVEPOINT nombre | Marca un punto intermedio |
ROLLBACK TO SAVEPOINT nombre | Deshace solo hasta ese punto |
sql
BEGIN; -- en MySQL también: START TRANSACTION;
UPDATE productos SET precio = precio * 1.10 WHERE categoria_id = 1;
-- Revisa el resultado...
SELECT nombre, precio FROM productos WHERE categoria_id = 1;
-- ¿Te equivocaste? Deshaz todo:
ROLLBACK;
-- ¿Todo bien? Confirma:
-- COMMIT;Autocommit
Por defecto, MySQL, PostgreSQL y SQLite están en modo autocommit: cada instrucción suelta es su propia transacción y se confirma al instante. Por eso un UPDATE sin BEGIN no se puede deshacer. Cuando escribes BEGIN, el autocommit se suspende hasta el COMMIT o ROLLBACK.
Ejemplo: registrar una compra
Carlos (cliente 2) compra 2 Audífonos Bluetooth y 1 Lámpara de escritorio:
sql
BEGIN;
-- 1. Crear el pedido
INSERT INTO pedidos (id, cliente_id, fecha, estado)
VALUES (6, 2, '2024-07-01', 'pendiente');
-- 2. Guardar los productos con el precio actual
INSERT INTO detalle_pedido (pedido_id, producto_id, cantidad, precio_unitario) VALUES
(6, 1, 2, 899.00),
(6, 6, 1, 459.00);
-- 3. Descontar inventario, solo si alcanza
UPDATE productos SET stock = stock - 2 WHERE id = 1 AND stock >= 2;
UPDATE productos SET stock = stock - 1 WHERE id = 6 AND stock >= 1;
COMMIT;En una aplicación real, el código revisa que cada UPDATE haya afectado 1 fila; si afectó 0 (no había stock), hace ROLLBACK y avisa al cliente. En PHP con PDO:
php
<?php
$pdo->beginTransaction();
try {
$pdo->prepare('INSERT INTO pedidos (cliente_id, fecha, estado) VALUES (?, CURRENT_DATE, ?)')
->execute([$clienteId, 'pendiente']);
$pedidoId = $pdo->lastInsertId();
foreach ($carrito as $item) {
$upd = $pdo->prepare('UPDATE productos SET stock = stock - ? WHERE id = ? AND stock >= ?');
$upd->execute([$item['cantidad'], $item['id'], $item['cantidad']]);
if ($upd->rowCount() === 0) {
throw new Exception("Sin stock suficiente del producto {$item['id']}");
}
$pdo->prepare('INSERT INTO detalle_pedido VALUES (?, ?, ?, ?)')
->execute([$pedidoId, $item['id'], $item['cantidad'], $item['precio']]);
}
$pdo->commit(); // todo bien
} catch (Throwable $e) {
$pdo->rollBack(); // algo falló: no queda nada a medias
throw $e;
}ACID: las 4 garantías
| Letra | Propiedad | Significa | En la compra |
|---|---|---|---|
| A | Atomicidad (Atomicity) | Todo o nada | No queda un pedido sin descuento de stock |
| C | Consistencia | Se respetan todas las reglas (FK, CHECK, UNIQUE) | El stock nunca queda negativo si hay CHECK (stock >= 0) |
| I | Aislamiento (Isolation) | Las transacciones simultáneas no se estorban | Dos clientes comprando a la vez no ven datos a medias |
| D | Durabilidad | Tras el COMMIT, el cambio sobrevive a un apagón | El pedido confirmado no se pierde si se va la luz |
MyISAM no es ACID
En MySQL, las tablas con motor MyISAM ignoran las transacciones (ROLLBACK no deshace nada). Usa siempre InnoDB, que es el motor por defecto desde MySQL 5.5. Lo verás en Motores: InnoDB.
SAVEPOINT: deshacer solo una parte
sql
BEGIN;
INSERT INTO categorias (id, nombre) VALUES (5, 'Juguetes');
SAVEPOINT antes_de_productos;
INSERT INTO productos (id, nombre, categoria_id, precio, stock)
VALUES (20, 'Pelota', 5, -10.00, 3); -- ¡error! precio negativo viola el CHECK
ROLLBACK TO SAVEPOINT antes_de_productos; -- deshace solo el producto
INSERT INTO productos (id, nombre, categoria_id, precio, stock)
VALUES (20, 'Pelota', 5, 150.00, 3);
COMMIT; -- se guardan la categoría y la pelota correctaPostgreSQL y los errores
En PostgreSQL, cuando una instrucción falla dentro de una transacción, toda la transacción queda abortada ("current transaction is aborted") y solo acepta ROLLBACK o ROLLBACK TO SAVEPOINT. MySQL, en cambio, solo descarta la instrucción fallida y te deja seguir.
Aislamiento: cuando dos usuarios actúan a la vez
En una tienda real hay cientos de transacciones simultáneas. El aislamiento define qué puede ver una transacción de las que aún no terminan. Sin suficiente aislamiento aparecen estas anomalías:
| Anomalía | Qué pasa | Ejemplo |
|---|---|---|
| Lectura sucia (dirty read) | Lees cambios que otra transacción aún no confirma (y quizá deshaga) | Ves stock 0 de un producto cuya venta luego se canceló |
| Lectura no repetible | Lees la misma fila dos veces y cambió entre lecturas | El precio cambia mientras generas la factura |
| Lectura fantasma (phantom) | Repites una consulta y aparecen filas nuevas | Cuentas pedidos de hoy dos veces y da distinto |
| Actualización perdida | Dos transacciones leen, calculan y escriben; una pisa a la otra | Dos personas compran la última pieza |
Niveles de aislamiento
| Nivel | Lectura sucia | No repetible | Fantasma | Por defecto en |
|---|---|---|---|---|
READ UNCOMMITTED | Posible | Posible | Posible | — |
READ COMMITTED | No | Posible | Posible | PostgreSQL, SQL Server, Oracle |
REPEATABLE READ | No | No | Posible* | MySQL (InnoDB) |
SERIALIZABLE | No | No | No | — |
* En la práctica, InnoDB y PostgreSQL evitan la mayoría de fantasmas en REPEATABLE READ.
sql
-- Cambiar el nivel para la siguiente transacción
-- MySQL:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
-- ...
COMMIT;
-- PostgreSQL:
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;Más aislamiento = más seguridad pero más bloqueos y errores de serialización que tu app debe reintentar. Los valores por defecto funcionan bien en la mayoría de los casos si usas bloqueos explícitos donde importa.
El problema de la última pieza
Quedan 5 monitores. Dos clientes compran 5 al mismo tiempo:
txt
Tiempo Transacción A Transacción B
────── ───────────────────────────── ─────────────────────────────
t1 SELECT stock → 5
t2 SELECT stock → 5
t3 "hay suficiente" → UPDATE stock = 0
t4 "hay suficiente" → UPDATE stock = 0
t5 COMMIT COMMIT
→ ¡vendiste 10 monitores teniendo 5!Solución 1: actualización condicional atómica
Deja que la BD haga la comprobación y el cambio en una sola instrucción:
sql
UPDATE productos
SET stock = stock - 5
WHERE id = 3 AND stock >= 5;
-- Si devuelve "0 filas afectadas", no había stock: ROLLBACK.El UPDATE bloquea la fila; la segunda transacción espera, y cuando continúa ya ve stock = 0 y no actualiza nada. Es la solución más simple y eficiente.
Solución 2: SELECT ... FOR UPDATE
Cuando necesitas leer, aplicar lógica en tu código y después escribir:
sql
BEGIN;
-- Bloquea la fila: otras transacciones que quieran modificarla (o leerla con FOR UPDATE) esperan
SELECT stock, precio FROM productos WHERE id = 3 FOR UPDATE;
-- ... tu código revisa stock, calcula descuentos, etc. ...
UPDATE productos SET stock = stock - 5 WHERE id = 3;
COMMIT; -- libera el bloqueotxt
Tiempo Transacción A Transacción B
────── ───────────────────────────── ───────────────────────────────
t1 SELECT ... FOR UPDATE → 5 (bloquea)
t2 SELECT ... FOR UPDATE → espera...
t3 UPDATE stock = 0; COMMIT (libera)
t4 ...obtiene stock = 0 → "agotado"Variantes útiles: FOR UPDATE NOWAIT (falla de inmediato si está bloqueada) y FOR UPDATE SKIP LOCKED (salta las filas bloqueadas; ideal para colas de trabajos).
Deadlocks (bloqueos mutuos)
txt
A bloquea producto 1 ──► quiere producto 3 (lo tiene B) ──► espera
B bloquea producto 3 ──► quiere producto 1 (lo tiene A) ──► espera
= espera eterna → la BD mata una de las dosLa BD detecta el deadlock y aborta una transacción con un error. Para reducirlos:
- Bloquea las filas siempre en el mismo orden (por ejemplo, ordena los productos del carrito por
id). - Mantén las transacciones cortas.
- Programa tu app para reintentar cuando reciba un error de deadlock.
Transacciones largas
Nunca dejes una transacción abierta esperando algo externo: la respuesta de una pasarela de pago, un correo o la acción del usuario. Mantiene bloqueos durante segundos o minutos y frena a todos los demás. Primero haz lo externo, luego la transacción (o usa estados como pendiente_pago).
Buena práctica
Toda operación que modifique varias tablas o filas relacionadas (compra, transferencia, inscripción a un curso con cupo) va dentro de una transacción. En tu código, usa siempre el patrón try { ... commit } catch { rollback }.
Ejercicios
- Escribe una transacción que mueva 10 piezas de stock del producto 4 al producto 5 (como si reclasificaras inventario), asegurando que el producto 4 tenga suficiente.
- Dentro de una transacción, borra todos los pedidos cancelados (y su detalle), revisa cuántos quedan y luego deshaz todo.
- ¿Qué propiedad ACID se viola si tras un
COMMITse va la luz y el pedido desaparece? ¿Y si queda un pedido sin su detalle? - ¿Por qué
SELECT stock ...seguido deUPDATE productos SET stock = 4 ...(valor calculado en la app) es peligroso con usuarios simultáneos? ¿Cómo lo arreglas?
Ver solución
sql
-- 1
BEGIN;
UPDATE productos SET stock = stock - 10 WHERE id = 4 AND stock >= 10;
-- (la app verifica que se afectó 1 fila; si no, ROLLBACK)
UPDATE productos SET stock = stock + 10 WHERE id = 5;
COMMIT;
-- 2
BEGIN;
DELETE FROM detalle_pedido
WHERE pedido_id IN (SELECT id FROM pedidos WHERE estado = 'cancelado');
DELETE FROM pedidos WHERE estado = 'cancelado';
SELECT COUNT(*) FROM pedidos; -- 4
ROLLBACK;
SELECT COUNT(*) FROM pedidos; -- 5 otra vez- Durabilidad en el primer caso; atomicidad en el segundo.
- Es una "actualización perdida": dos transacciones leen el mismo valor y una pisa el cálculo de la otra. Se arregla calculando en la BD (
SET stock = stock - 1 WHERE id = ? AND stock >= 1) o bloqueando conSELECT ... FOR UPDATEdentro de una transacción.
Resumen
- Una transacción agrupa operaciones en una unidad todo o nada:
BEGIN...COMMIT/ROLLBACK. - ACID: Atomicidad, Consistencia, Aislamiento y Durabilidad.
SAVEPOINTpermite deshacer solo una parte.- Los niveles de aislamiento (
READ COMMITTED,REPEATABLE READ,SERIALIZABLE) equilibran seguridad y concurrencia. - Evita la sobreventa con
UPDATE ... WHERE stock >= noSELECT ... FOR UPDATE. - Transacciones cortas, bloqueos en orden consistente y reintentos ante deadlocks.