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
CHECKdice 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:
| |
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:
| |
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:
| |
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ía | Qué pasa |
|---|---|
| Dirty read | La 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 read | A lee la misma fila dos veces en una transacción y obtiene valores distintos, porque B confirmó un cambio en medio. |
| Phantom read | A 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 aislamiento | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| Read Uncommitted | Posible | Posible | Posible |
| Read Committed | Evitado | Posible | Posible |
| Repeatable Read | Evitado | Evitado | Posible (permitido por el estándar) |
| Serializable | Evitado | Evitado | Evitado |
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
SELECTen 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
SELECTposterior 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:
| |
En MySQL, la forma SESSION cambia el nivel para cada transacción siguiente en esa conexión:
| |
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:
| |
Sesión B, mientras la transacción de A sigue abierta:
| |
Sesión A, misma transacción abierta, repitiendo la consulta idéntica:
| |
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:
| |
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
UPDATEtoma un bloqueo de fila en el momento en que se ejecuta y lo mantiene hastaCOMMIToROLLBACK, sin importar el nivel de aislamiento. Lo que añadenRepeatable ReadySerializablees mantener el mismo snapshot (o, enSerializable, bloqueos de predicado sobre las filas que examinaron tus consultas) durante toda la transacción. Haz una llamada lenta a una API externa entreBEGINyCOMMITy cada fila que has escrito queda bloqueada, y cada fila que has leído bajoSerializablequeda 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 Readpuede lanzar el mismo error40001ante una simple actualización concurrente, así que el código de reintento no es solo paraSerializable— 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
VACUUMrecupere ese espacio, y unALTER TABLEsobre una tabla que tocó espera a que termine. - No toda escritura necesita una transacción explícita. Una sola instrucción
UPDATEya es atómica. Envolver una enBEGIN/COMMITañ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 enuserscomo enaccounts, 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ón | Read 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ás | Repeatable 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 rendimiento | Serializable, 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 relacionadas | No 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.