Por qué una transferencia bancaria necesita más que dos UPDATE

Mueve 50€ de una cuenta a otra y la versión naive son dos instrucciones: resta de la cuenta A, suma a la cuenta B. Ejecútalas por separado y, si el proceso falla o la conexión se cae entre las dos, el dinero desaparece de A y nunca llega a B. La base de datos hizo exactamente lo que se le pidió, dos veces. El negocio perdió 50€ igualmente.

Una transacción envuelve ambas instrucciones para que la base de datos las trate como una sola unidad: o pasan las dos, o ninguna. Esa garantía, más otras tres, es lo que el término “ACID” designa. Es la razón por la que puedes confiarle a una base de datos dinero o niveles de stock, donde quedarte a medias es peor que no hacer nada.

Qué garantizan las cuatro propiedades ACID

  • Atomicidad. Todas las instrucciones de una transacción hacen commit juntas, o ninguna lo hace. Si el proceso falla después del débito pero antes del abono, la base de datos revierte también el débito. No queda ningún estado parcial que limpiar a mano.
  • Consistencia. Una transacción solo puede llevar la base de datos de un estado válido a otro. Si una restricción CHECK dice que un saldo no puede ser negativo, ninguna transacción puede hacer commit de una fila que la viole, sin importar lo que intentara el código de la aplicación.
  • Aislamiento. Las transacciones que corren al mismo tiempo no ven el trabajo a medias de las otras. Qué tan estricto se aplica esto es un dial, no una regla fija: ese dial es el nivel de aislamiento, que verás más abajo.
  • Durabilidad. Una vez que la base de datos dice que una transacción hizo commit, ese dato sobrevive a un fallo un segundo después. Para esto existe el write-ahead log: el cambio está en disco (o replicado) antes de que el commit retorne.

La atomicidad y la durabilidad son trabajo de la base de datos, y los motores más usados las hacen bien. El aislamiento es el que tú controlas. Un aislamiento más estricto cuesta concurrencia, y ese coste se nota en las gráficas de latencia en producción.

Cómo ejecutar una transacción con BEGIN, COMMIT y ROLLBACK

En PostgreSQL (y en la mayoría de bases de datos SQL, con pequeñas diferencias de sintaxis), una transacción son las instrucciones entre BEGIN y COMMIT:

1
2
3
4
5
6
BEGIN;

UPDATE accounts SET balance = balance - 50 WHERE id = 1;
UPDATE accounts SET balance = balance + 50 WHERE id = 2;

COMMIT;

Si algo sale mal antes del COMMIT (una violación de restricción, o una comprobación de negocio en el código de la aplicación), ROLLBACK deshace todo lo que pasó desde BEGIN:

1
2
3
4
5
BEGIN;

UPDATE accounts SET balance = balance - 50 WHERE id = 1;
-- la comprobación de negocio en el código de la aplicación encuentra el saldo negativo
ROLLBACK;

Después del rollback, el saldo de la cuenta 1 es exactamente el que tenía antes de que se ejecutara BEGIN. Para una transacción larga donde solo quieres deshacer parte del trabajo, un savepoint te da un rollback parcial:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
BEGIN;

UPDATE accounts SET balance = balance - 50 WHERE id = 1;
SAVEPOINT before_credit;
UPDATE accounts SET balance = balance + 50 WHERE id = 2;

ROLLBACK TO before_credit;  -- deshace solo el abono
UPDATE accounts SET balance = balance + 50 WHERE id = 3; -- abona a una cuenta distinta

COMMIT;

Fuera de un BEGIN explícito, la mayoría de los drivers ejecuta cada instrucción en su propia transacción con auto-commit. Cada instrucción es atómica por sí sola, pero nada conecta dos instrucciones entre sí. Salta el BEGIN en la transferencia bancaria y vuelves a los 50€ perdidos.

Qué previenen los niveles de aislamiento de transacciones

Dos transacciones concurrentes pueden interferir entre sí de tres formas concretas, y los niveles de aislamiento se definen según cuáles de ellas permiten:

AnomalíaQué pasa
Dirty readLa transacción A lee una fila que B escribió pero aún no ha confirmado. Si B hace rollback, A leyó un dato que nunca existió.
Non-repeatable readA lee la misma fila dos veces en una transacción y obtiene valores distintos, porque B confirmó un cambio en medio.
Phantom readA vuelve a ejecutar la misma consulta WHERE dos veces y obtiene un conjunto de filas distinto, porque B insertó o eliminó una fila que coincidía en medio.

El estándar SQL define cuatro niveles en torno a estas tres anomalías:

Nivel de aislamientoDirty readNon-repeatable readPhantom read
Read UncommittedPosiblePosiblePosible
Read CommittedEvitadoPosiblePosible
Repeatable ReadEvitadoEvitadoPosible (permitido por el estándar)
SerializableEvitadoEvitadoEvitado

Rara vez querrás usar READ UNCOMMITTED. PostgreSQL ni siquiera lo implementa y ejecuta Read Committed en su lugar sin avisar; MySQL sí lo respeta y te mostrará dirty reads. En la práctica los dos niveles que importan son Read Committed y Repeatable Read, y las dos bases de datos open source principales usan defaults distintos:

  • PostgreSQL usa por defecto Read Committed. Cada instrucción dentro de una transacción ve un snapshot nuevo tomado cuando esa instrucción empieza, así que dos SELECT en la misma transacción pueden devolver datos distintos si otra transacción hizo commit en medio.
  • MySQL (InnoDB) usa por defecto Repeatable Read. El snapshot se toma una sola vez, en la primera lectura de la transacción, y cada SELECT posterior en esa transacción ve ese mismo snapshot, incluso si otra transacción ha confirmado una actualización desde entonces.

En PostgreSQL, fija el nivel justo después de BEGIN, antes de que corra ninguna consulta:

1
2
3
4
5
6
7
-- PostgreSQL
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
-- ... más adelante en la misma transacción ...
SELECT balance FROM accounts WHERE id = 1; -- garantizado el mismo valor que arriba
COMMIT;

En MySQL, la forma SESSION cambia el nivel para cada transacción siguiente en esa conexión:

1
2
-- MySQL
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

Cómo Repeatable Read bloquea un phantom read en PostgreSQL

Abre dos sesiones de psql contra la misma tabla y ejecuta esto. Verlo una vez se te queda mejor que memorizar la tabla.

Sesión A:

1
2
3
4
5
6
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT id, balance FROM accounts WHERE balance > 1000;
--  id | balance
-- ----+---------
--   3 |    1500

Sesión B, mientras la transacción de A sigue abierta:

1
2
3
BEGIN;
INSERT INTO accounts (id, balance) VALUES (9, 2000);
COMMIT;

Sesión A, misma transacción abierta, repitiendo la consulta idéntica:

1
2
3
4
SELECT id, balance FROM accounts WHERE balance > 1000;
--  id | balance
-- ----+---------
--   3 |    1500

La fila que B insertó y confirmó no aparece. El Repeatable Read de PostgreSQL toma su snapshot en la primera consulta de la transacción de A y lo mantiene hasta el COMMIT, así que las filas nuevas de otras transacciones nunca aparecen. Eso es más estricto de lo que pide el estándar: la tabla de arriba permite phantoms en este nivel, pero la implementación de PostgreSQL no los produce. Ejecuta la misma secuencia con A en Read Committed y el segundo SELECT devuelve la fila nueva, porque Read Committed toma un snapshot nuevo por instrucción.

Serializable va más allá. Detecta cuando dos transacciones concurrentes produjeron un resultado que no podría haber pasado en ningún orden secuencial, y aborta una de ellas con un error de serialización (PostgreSQL devuelve el SQLSTATE 40001) en lugar de dejar que la anomalía haga commit. Es el nivel que cierra la clásica race condition donde dos transacciones leen un valor, deciden según él, y las dos escriben. El código que usa SERIALIZABLE tiene que estar listo para reintentar:

1
2
3
4
5
6
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ... instrucciones ...
COMMIT;
-- ante el error 40001 ("could not serialize access due to read/write dependencies"):
-- haz rollback y reintenta toda la transacción

Qué cuestan los niveles de aislamiento más estrictos

El precio de un aislamiento más estricto es la concurrencia:

  • Las escrituras quedan bloqueadas hasta el commit, en todos los niveles. Un UPDATE toma un bloqueo de fila en el momento en que se ejecuta y lo mantiene hasta COMMIT o ROLLBACK, sin importar el nivel de aislamiento. Lo que añaden Repeatable Read y Serializable es mantener el mismo snapshot (o, en Serializable, bloqueos de predicado sobre las filas que examinaron tus consultas) durante toda la transacción. Haz una llamada lenta a una API externa entre BEGIN y COMMIT y cada fila que has escrito queda bloqueada, y cada fila que has leído bajo Serializable queda registrada, todo el tiempo que dure esa llamada.
  • Los reintentos no son opcionales bajo Serializable. Rechazará transacciones que parecen válidas bajo carga concurrente, por diseño. Si tu aplicación no captura los errores de serialización y reintenta, esas transacciones fallan de forma intermitente, bajo carga, que es el peor momento para descubrirlo. Repeatable Read puede lanzar el mismo error 40001 ante una simple actualización concurrente, así que el código de reintento no es solo para Serializable — cualquier transacción por encima de Read Committed lo necesita.
  • Las transacciones largas bloquean cambios de esquema e hinchan el almacenamiento. El MVCC de PostgreSQL conserva las versiones antiguas de las filas mientras alguna transacción abierta todavía pueda necesitarlas. Una transacción dejada abierta durante horas (un bug habitual: abrir una en una petición web y nunca hacer commit ni rollback) impide que VACUUM recupere ese espacio, y un ALTER TABLE sobre una tabla que tocó espera a que termine.
  • No toda escritura necesita una transacción explícita. Una sola instrucción UPDATE ya es atómica. Envolver una en BEGIN/COMMIT añade un round trip sin ninguna garantía extra. Usa una transacción explícita cuando la corrección dependa de que dos o más instrucciones tengan éxito o fallen juntas: la transferencia bancaria, un registro que escribe tanto una fila en users como en accounts, un pedido que descuenta inventario e inserta el registro del pedido.

Los ORM (Entity Framework, SQLAlchemy, ActiveRecord) suelen abrir una transacción por unidad de trabajo y hacerle commit por ti. No vayas contra eso. El bug que hay que vigilar es el contrario: código que llama a .save() o .commit() por separado para dos escrituras relacionadas, tirando a la basura la atomicidad que el ORM te habría dado.

Qué nivel de aislamiento usar

Si tu carga de trabajo es…Usa
CRUD típico de una web app, lecturas y escrituras por peticiónRead Committed. Es el default de PostgreSQL por una razón: contención mínima, y los dirty reads ya están descartados.
Reportes o jobs batch que necesitan que cada consulta de la transacción concuerde con las demásRepeatable Read. Cada consulta lee el mismo snapshot, así que los totales y las filas de detalle no pueden desalinearse a mitad del reporte.
Transferencias financieras, conteos de inventario, cualquier caso donde una carrera entre dos transacciones sea un bug de corrección, no una molestia de rendimientoSerializable, con lógica de reintento sobre 40001. Mantén estas transacciones cortas y el coste de concurrencia se queda bajo.
Una sola instrucción sin escrituras relacionadasNo hace falta transacción explícita. La instrucción ya es atómica.

Empieza en Read Committed y sube solo para las transacciones concretas donde puedas nombrar la carrera que estás cerrando. Es la misma disciplina que añadir un índice solo después de encontrar la consulta lenta. Si no puedes escribir esa carrera en una sola frase, todavía no necesitas un nivel más estricto.

Artículos relacionados