Saltar al contenido
Computer Science Fundamentals
Código fuente
← Inicio

Transacciones

100 preguntas

Correctas
0
Incorrectas
0
Sin responder
100
Dificultad
Número de preguntas

100 preguntas en este filtro

Resumen teórico

Imagina que le das 10 monedas de tu alcancía a un amigo. Tienen que pasar dos cosas: tu alcancía pierde 10 monedas y la alcancía de tu amigo gana 10 monedas. ¿Y si se va la luz justo en el medio? Tus monedas desaparecerían y tu amigo no recibiría nada. Nadie quiere eso. Una transacción es una forma de decir: 'haz todos estos pasos juntos, o no hagas ninguno'.

Las transacciones viven dentro de una . Cada vez que pagas en una tienda en línea, reservas un asiento en el cine o envías dinero con el celular, una transacción protege los datos. Resuelven tres problemas a la vez: una falla en medio del trabajo, muchas personas tocando los mismos datos al mismo tiempo y varias computadoras que deben ponerse de acuerdo.

En este resumen vas a aprender: cuáles son las cuatro promesas de ACID, qué puede salir mal cuando mucha gente comparte los mismos datos (las anomalías) y cómo los niveles de aislamiento lo controlan, cómo los bloqueos y las varias versiones de una fila (MVCC) mantienen el orden, cómo ocurren los deadlocks y cómo salir de ellos, cómo el log permite que una base sobreviva a una caída, cómo varias bases pueden hacer commit juntas (two-phase commit), qué hacer cuando no se puede (saga y outbox), por qué repetir una petición debe ser seguro (idempotencia) y qué dice el teorema CAP sobre las copias de los datos. Al final estarás listo para el quiz.

El panorama: ¿qué es una transacción?

Una transacción es un grupo de operaciones que la base de datos trata como una única unidad de trabajo. La empiezas con BEGIN. Y la terminas de solo dos formas: COMMIT (guarda todo) o ROLLBACK (tira todo, como si nada hubiera pasado).

BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
COMMIT;
Una transferencia de dinero: dos escrituras que deben quedar juntas.

Tres enemigos de tus datos

EnemigoEjemploQuién nos defiende
Una falla en medio del trabajoEl servidor pierde la energía después del débito y antes del créditoAtomicidad y durabilidad, construidas con el log
Muchos usuarios al mismo tiempoDos compradores se llevan el último artículo en el mismo milisegundoAislamiento, construido con bloqueos o versiones
Muchas computadorasLa tienda y el banco son dos sistemas distintosTwo-phase commit, o saga y outbox
Mira el video: Transactions: myths, surprises and opportunities (Martin Kleppmann, Strange Loop), en inglés↑ Volver al índice

ACID: las cuatro promesas

ACID es una palabra hecha con cuatro primeras letras. Cada letra es una promesa que hace la transacción. Vamos a conocerlas pensando en la transferencia entre alcancías.

LetraPromesaEn palabras simples
AtomicidadTodas las operaciones surten efecto, o ninguna lo haceNunca existe trabajo a medias
ConsistenciaEn el momento del COMMIT se respetan las reglas de integridadLa base pasa de un estado válido a otro estado válido
Isolation (aislamiento)Las transacciones que corren juntas no se estorbanCada una trabaja como si estuviera sola (en su forma más fuerte)
DurabilidadUn resultado confirmado no se pierdeDespués del 'COMMIT hecho', una caída no lo deshace

Atomicidad: todo o nada

Si el servidor se cae después del débito y antes del COMMIT, la base actúa como si la transacción nunca hubiera existido. Al reiniciar, usa su para deshacer el trabajo incompleto. El comando ROLLBACK (sin savepoint) pide lo mismo a propósito.

La atomicidad solo cubre lo que está dentro de una transacción. En el no hay BEGIN, así que un débito y un crédito enviados como dos comandos son dos transacciones separadas. Si el programa muere entre ellos, el débito queda y el crédito se pierde. Para mantener dos escrituras juntas, envuélvelas en BEGIN y COMMIT.

Savepoints y errores dentro de una transacción

Un savepoint es un marcador de página dentro de la transacción. ROLLBACK TO SAVEPOINT deshace solo el trabajo posterior al marcador y mantiene la transacción abierta.

BEGIN;
INSERT INTO logs (msg) VALUES ('a');
SAVEPOINT s1;
INSERT INTO logs (msg) VALUES ('b');
ROLLBACK TO SAVEPOINT s1;   -- deshace solo 'b'
INSERT INTO logs (msg) VALUES ('c');
COMMIT;                     -- guarda 'a' y 'c'
Después del COMMIT la tabla tiene las filas a y c.

En PostgreSQL, cuando un comando falla dentro de una transacción (por ejemplo, una clave duplicada), toda la transacción entra en estado abortado. Cada comando siguiente es rechazado con 'current transaction is aborted', y hasta un COMMIT solo lo deshace todo. Si esperas un error y quieres seguir, pon un savepoint antes del comando arriesgado.

Consistencia: las reglas siguen siendo verdad

Consistencia en ACID significa: cuando la transacción termina, la base respeta sus reglas de integridad (claves primarias, CHECK, claves foráneas). Durante la transacción las reglas pueden romperse por un momento, pero no en el COMMIT. Es la única letra de ACID compartida entre la base y el programa. La base solo conoce las reglas que fueron declaradas. Si la regla 'la suma de todos los saldos nunca cambia' no se declaró, y una transacción con un bug debita 100 y acredita 90, la base la confirma sin quejarse. Las otras tres letras existen para que un programa correcto no sea estorbado.

Durabilidad: confirmado significa guardado

Cuando la base dice 'COMMIT hecho', la información necesaria para rehacer la transacción ya está en almacenamiento persistente (normalmente en el log). Cada COMMIT cuesta una escritura en disco que debe llegar de verdad al disco. PostgreSQL tiene un ajuste, synchronous_commit = off, que responde antes de esa escritura. Es más rápido, pero aceptas una pequeña ventana en la que las últimas transacciones pueden perderse en una caída. La atomicidad y la consistencia no se ven afectadas, porque el log se sigue escribiendo en el orden correcto.

Una trampa con los ORM

Una transacción pertenece a una conexión. Con un ORM como Prisma, el objeto que recibe el callback (tx) es el único que habla por esa conexión.

await prisma.$transaction(async (tx) => {
	const order = await tx.order.create({ data: { total: 100 } });
	await prisma.payment.create({ data: { orderId: order.id } }); // BUG: cliente global
	throw new Error("stock check failed");
});
El pago usa el cliente global, así que corre en otra conexión y se confirma solo. El error deshace el pedido pero no el pago.
↑ Volver al índice

Niveles de aislamiento y anomalías

Imagina dos cocineros que comparten la misma tarjeta de receta. Uno tacha un número mientras el otro lo está leyendo. Si no se ponen de acuerdo en unas reglas, el pastel sale mal. El aislamiento es el conjunto de reglas que decide cuánto pueden ver dos transacciones que corren al mismo tiempo del trabajo de la otra. Un error causado por esa mezcla se llama anomalía.

Las cinco anomalías

AnomalíaQué sale mal
Dirty read (lectura sucia)Lees un cambio que otra transacción no ha confirmado. Si hace rollback, usaste un valor que oficialmente nunca existió
Non-repeatable read (lectura no repetible)Lees la misma fila dos veces y obtienes dos valores, porque otra transacción confirmó un cambio en el medio
Phantom read (lectura fantasma)Haces la misma búsqueda dos veces y el conjunto de filas cambia, porque otra transacción insertó o borró una fila que coincide
Lost update (actualización perdida)Dos transacciones leen el mismo valor, cada una calcula un valor nuevo en el programa y lo escribe. La última escritura borra la otra
Write skewDos transacciones leen el mismo conjunto, deciden y escriben en filas distintas. Cada escritura está bien sola, pero juntas rompen una regla

El estándar SQL solo nombra las tres primeras (dirty read, non-repeatable read y phantom). Lost update y write skew quedaron fuera de su tabla. Por eso una base puede seguir la letra de un nivel y aun así no ser serializable.

Los cuatro niveles del estándar SQL

El estándar define cada nivel por la lista de fenómenos que prohíbe. Del más débil al más fuerte: READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE. Un nivel más alto permite menos anomalías y normalmente cuesta más (espera o transacciones abortadas).

'Posible' es un permiso, no una obligación. Una base puede ser más estricta.
Nivel (estándar SQL)Dirty readNon-repeatable readPhantom
READ UNCOMMITTEDposibleposibleposible
READ COMMITTEDimposibleposibleposible
REPEATABLE READimposibleimposibleposible
SERIALIZABLEimposibleimposibleimposible

PostgreSQL es más estricto que el estándar

  • El nivel por defecto es READ COMMITTED (ajuste default_transaction_isolation).
  • READ UNCOMMITTED se acepta, pero funciona exactamente como READ COMMITTED. Una dirty read nunca ocurre, en ningún nivel.
  • REPEATABLE READ es snapshot isolation: la transacción ve la base como estaba en su primer comando. Así que tampoco hay phantoms, aunque el estándar los permitiría.
  • SERIALIZABLE es serializable snapshot isolation (SSI): snapshots, más el seguimiento de quién leyó y quién escribió qué.

La gran diferencia entre los dos primeros niveles reales es cuánto dura la foto. En READ COMMITTED cada comando toma un snapshot nuevo, así que dos SELECT count(*) en la misma transacción pueden dar respuestas distintas. En REPEATABLE READ un solo snapshot sirve para toda la transacción.

Lost update, paso a paso

tiempo Sesión A                      Sesión B
1      SELECT stock -> 10
2                                    SELECT stock -> 10
3      UPDATE SET stock = 9; COMMIT
4                                    UPDATE SET stock = 9; COMMIT

stock final = 9   (dos ventas, debería ser 8)
Leer, calcular en el programa, escribir: gana la última escritura y una venta desaparece.

Esto es posible en READ COMMITTED. En REPEATABLE READ de PostgreSQL, si una transacción intenta cambiar una fila que otra transacción cambió y confirmó después de su snapshot, es abortada con el SQLSTATE 40001 (un código que significa 'no se pudo serializar el acceso'). No es un bug: es el contrato. El programa debe repetir la transacción completa.

También hay un truco que evita el problema sin un nivel alto. Un único comando como el de abajo hace la verificación y la escritura dentro de la base. Si otra transacción sostiene la fila, el comando espera; cuando esa confirma, el WHERE se vuelve a evaluar sobre la nueva versión de la fila.

UPDATE accounts
SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
Seguro en READ COMMITTED de PostgreSQL: verificar y escribir es un solo paso.

Write skew: los médicos de guardia

regla: al menos un médico de guardia

tiempo Ana                           Beto
1      count on_call -> 2
2                                    count on_call -> 2
3      UPDATE Ana  on_call = false
4                                    UPDATE Beto on_call = false
5      COMMIT                        COMMIT

resultado: 0 médicos de guardia
Se escribieron filas distintas, así que no hay conflicto de escritura con escritura.

REPEATABLE READ no lo nota, porque solo revisa dos escrituras en la misma fila. SERIALIZABLE en PostgreSQL vigila las dependencias entre lecturas y escrituras, y aborta una de las dos con 40001. Es el único nivel que impide el write skew, y de nuevo el programa debe reintentar.

↑ Volver al índice

Locking: en dos fases, pesimista y optimista

Hay dos grandes formas de evitar que las transacciones se pisen: bloquear los datos para que los demás esperen, o dejar que todos trabajen y revisar al final. Empecemos con los bloqueos.

Bloqueos compartidos y exclusivos

Quien pide un bloqueo incompatible espera.
BloqueoSe usa paraJunto con SJunto con X
S (shared, compartido)lecturacompatibledebe esperar
X (exclusive, exclusivo)escrituradebe esperardebe esperar

Two-phase locking (2PL)

La regla: la vida de una transacción tiene una fase de crecimiento, en la que solo adquiere bloqueos, y una fase de encogimiento, en la que solo los libera. Después de la primera liberación, no se puede adquirir ningún bloqueo nuevo. Si toda transacción sigue esta regla, la ejecución concurrente equivale a algún orden serial: es serializable. Pero el 2PL no evita los deadlocks.

bloqueos
sostenidos
   ^
   |        /\
   |       /  \
   |      /    \
   |     /      \
   |    /        \
   +---+----------+--------> tiempo
    crecimiento  encogimiento
    (solo        (solo
    adquiere)    libera)
La forma del two-phase locking.

En el 2PL básico, un bloqueo de escritura puede liberarse antes del final, lo que expone datos aún no confirmados. El 2PL estricto mantiene los bloqueos exclusivos hasta el COMMIT o el ROLLBACK. Esto elimina el problema de que otras transacciones dependan de datos que todavía pueden deshacerse.

Pesimista y optimista

EnfoqueIdeaEl precioBueno cuando
PesimistaEspera un conflicto. Bloquea primero y hace esperar a los demásEsperar en una filaLos conflictos son comunes
OptimistaEspera que no haya conflicto. Trabaja libre, revisa al escribir y rechaza a quien llegó tardeTrabajo desperdiciado y reintentosLos conflictos son raros

Pesimista en SQL es SELECT ... FOR UPDATE: la transacción lee las filas y las reserva en el mismo comando, lo que cierra la ventana entre leer y escribir, donde nacen las lost updates.

El control optimista suele usar una columna de versión. Lees la fila y su versión sin bloquear. Al escribir, exiges que la versión siga siendo la misma y la aumentas.

UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 7;
-- UPDATE 0   <- alguien escribió antes
Cero filas afectadas significa conflicto. La base no lanza ningún error.

Detalles que importan en PostgreSQL

  • Los bloqueos de fila solo hacen fila a quienes también quieren un bloqueo de fila: UPDATE, DELETE, SELECT FOR UPDATE y FOR SHARE. Un SELECT común lo atienden las versiones y no se bloquea. Por eso FOR UPDATE solo protege si toda transacción concurrente también lo usa.
  • Envolver lectura, verificación y escritura en una transacción no basta en READ COMMITTED, porque una lectura común no reserva la fila. Así es como un checkout vende más stock del que existe. Tres arreglos: FOR UPDATE, una columna de versión, o SERIALIZABLE con reintento.
  • Con 200 compradores disputando 10 unidades en una sola fila, el control optimista desperdicia trabajo (gana una escritura por ronda, el resto rehace), mientras que FOR UPDATE los hace esperar en fila. En el laboratorio de overselling de este repositorio, el código ingenuo creó de 150 a 180 pedidos para 10 unidades, y cada arreglo creó exactamente 10.
  • Los bloqueos de fila no impiden los phantoms. Un phantom es una fila nueva que empieza a cumplir la consulta, y todavía no existe para ser bloqueada. Para impedirlo hay que bloquear la condición misma: predicate locks, bloqueos de rango de índice (next-key) o la tabla entera.
↑ Volver al índice

MVCC: muchas versiones de una fila

Los bloqueos hacen que lectores y escritores se esperen entre sí. El control de concurrencia multiversión (MVCC) tiene una idea más lista: una escritura nunca destruye el valor viejo. Agrega una versión nueva de la fila. Cada transacción ve la versión que corresponde a su propio momento en el tiempo.

Por qué MVCC es bueno

Con bloqueo puro, una lectura larga frena las escrituras y una escritura frena las lecturas. Con MVCC, las lecturas las atienden versiones ya confirmadas, así que los lectores no bloquean a los escritores y los escritores no bloquean a los lectores. Pero el conflicto entre dos escrituras en la misma fila sigue ahí: la segunda espera a la primera. Después, según el nivel, continúa sobre la versión nueva o es abortada.

Snapshots

Un snapshot es solo una regla de visibilidad: dice qué transacciones cuentan como ya confirmadas para quien lee. Para cada fila, el lector recibe la versión más nueva creada por una de esas transacciones. No se hace ninguna copia física. El snapshot también recuerda quién estaba corriendo cuando se tomó: esas transacciones se tratan como no confirmadas para siempre, aunque confirmen un instante después, y lo mismo vale para las que empiezan más tarde.

Cómo guarda PostgreSQL las versiones

PostgreSQL guarda todas las versiones en la propia tabla. Cada versión tiene dos columnas ocultas: xmin, la transacción que la creó, y xmax, la transacción que la expiró (la borró o la reemplazó). Un UPDATE en realidad es: marcar la versión actual como expirada e insertar una nueva.

SELECT ctid, xmin, stock FROM products WHERE id = 1;
--  (0,1) | 740 | 10
UPDATE products SET stock = 9 WHERE id = 1;
SELECT ctid, xmin, stock FROM products WHERE id = 1;
--  (0,2) | 752 | 9
Después del UPDATE la fila tiene un nuevo xmin y una nueva posición física (ctid): es una versión nueva.

Una versión es visible para un snapshot cuando la transacción en xmin está confirmada para él y la transacción en xmax no lo está. Si el xmax pertenece a una transacción aún en curso (o abortada), la versión sigue visible. Así un DELETE o UPDATE pendiente permanece invisible para los demás.

versión de la fila: xmin = 100  xmax = 105  stock = 10

transacción 100: CONFIRMADA
transacción 105: EN CURSO
transacción 110: ejecuta SELECT ahora

100 confirmada          -> creada: OK
105 aún no confirma     -> no expiró para la 110
=> la transacción 110 VE esta versión
Un cambio pendiente de otra transacción no oculta la versión vieja.

El costo: versiones muertas y VACUUM

Cada UPDATE o DELETE deja atrás una versión muerta. VACUUM (normalmente lo inicia el autovacuum) encuentra las versiones que ningún snapshot activo puede ver y libera su espacio. Una versión solo se puede descartar cuando nadie la necesita. Entonces una transacción dejada abierta durante horas en REPEATABLE READ, sin hacer nada, clava el 'punto más antiguo necesario' en el pasado: toda versión creada desde entonces se conserva. Las tablas y los índices crecen (bloat) y las consultas se vuelven más lentas.

Snapshot isolation y su límite

La snapshot isolation aplica una regla: entre transacciones concurrentes que escriben la misma fila, solo una puede confirmar. Eso impide la lost update. Pero no sabe qué leyó cada transacción, así que no puede notar el write skew. Detectar el write skew exige seguir las lecturas, y eso es lo que agrega el SERIALIZABLE de PostgreSQL.

↑ Volver al índice

Deadlocks

Dos niños, dos juguetes. Ana sostiene el carrito y quiere la muñeca. Beto sostiene la muñeca y quiere el carrito. Ninguno suelta el suyo antes de recibir el otro, así que esperan para siempre. Eso es un deadlock: un grupo de transacciones, cada una sosteniendo un bloqueo y pidiendo un bloqueo que pertenece a otra del grupo, formando un ciclo. La espera nunca termina sola.

T1 tiene el bloqueo A, quiere el B
T2 tiene el bloqueo B, quiere el A

   grafo de espera (wait-for):

   +----+   espera   +----+
   | T1 | ----------> | T2 |
   |    | <---------- |    |
   +----+   espera   +----+

   un ciclo = deadlock
Cada nodo es una transacción. Una flecha T1 a T2 significa 'T1 espera un bloqueo que tiene T2'.

El grafo de espera (wait-for graph) es la herramienta clásica para detectar deadlocks. Mientras no tenga ciclo, siempre hay alguien que no espera a nadie y puede terminar. Un ciclo significa espera circular. La cura es elegir una víctima en el ciclo y abortarla.

Qué hace PostgreSQL

PostgreSQL no previene los deadlocks, los detecta. Cuando una espera por un bloqueo supera deadlock_timeout (1 segundo por defecto), el servidor busca un ciclo. Si lo encuentra, aborta una transacción con el error 40P01 (deadlock detected). El rollback de la víctima libera sus bloqueos y las demás continúan.

Cómo manejar los deadlocks en tu aplicación

  • Reintentar. La víctima se aborta completa, no solo el comando que estaba esperando. El error 40P01 es transitorio. Captúralo, haz ROLLBACK y ejecuta la transacción otra vez desde el BEGIN, con un número limitado de intentos.
  • Bloquear siempre en el mismo orden. Un ciclo solo se forma cuando las transacciones piden los mismos bloqueos en órdenes distintos. Si toda transferencia bloquea primero la cuenta de menor id, la segunda transacción se detiene en el primer bloqueo y no sostiene nada que la primera necesite. Es la prevención más simple.

Prevención por edad: wait-die y wound-wait

Otra forma es darle a cada transacción un timestamp (su edad) cuando empieza y permitir esperar en una sola dirección. Como todas las esperas van hacia el mismo lado en la línea de edades, un ciclo es imposible.

Una transacción reiniciada conserva su timestamp original, así que con el tiempo se vuelve la más vieja y no pasa hambre para siempre.
EsquemaLa más vieja pide un bloqueo de la más jovenLa más joven pide un bloqueo de la más vieja
Wait-dieLa más vieja esperaLa más joven muere (es abortada)
Wound-waitLa más vieja hiere a la más joven (la aborta)La más joven espera
↑ Volver al índice

Logging y recuperación: el write-ahead log

¿Cómo puede una base prometer atomicidad y durabilidad si la energía puede fallar en cualquier momento? La respuesta es un cuaderno llamado log. Antes de cambiar algo importante, la base anota lo que piensa hacer.

La regla del write-ahead log (WAL)

Write-ahead logging: el registro de log de un cambio debe llegar al almacenamiento estable (el disco) antes de que se escriba la página de datos modificada. Y en el COMMIT, el registro de commit debe estar en el disco antes de que el servidor le diga al cliente 'COMMIT hecho'. El punto de commit de una transacción es el momento en que su registro de commit está a salvo. Desde ahí el resultado está garantizado, aunque todavía no se haya escrito ninguna página de datos.

¿Por qué escribir el log y no las páginas?

Una transacción puede cambiar unos pocos bytes en muchas páginas distintas. Escribir todas esas páginas al confirmar significa muchas escrituras aleatorias de páginas completas. El log lo convierte en una escritura secuencial con solo los cambios, que es rápida. Las páginas van al disco después, en segundo plano.

Checkpoints

Sin ayuda, después de una caída la base tendría que releer todo el log. Un checkpoint escribe en el disco las páginas que se modificaron en memoria y marca ese punto en el log. Después de una caída, solo se reaplica el log posterior al último checkpoint. El log más viejo se puede reciclar.

Qué hace la recuperación después de una caída

checkpoint        caída
    |                |
----+----------------+------> tiempo
    T1 confirmó      T2 seguía activa

    páginas de T1 pueden NO estar en disco -> REDO desde el log
    páginas de T2 PUEDEN estar en disco    -> UNDO desde el log
Dos problemas opuestos después de una caída, un log para resolver ambos.

Las transacciones se dividen en ganadoras (tienen un registro de commit en el log) y perdedoras (no lo tienen). El redo reaplica las ganadoras que pueden no haber llegado a las páginas. El undo quita los efectos de las perdedoras que sí llegaron. Una transacción que había cambiado tres filas y no había hecho COMMIT se deshace por completo. Así la atomicidad sobrevive a una caída.

Caída del sistema frente a falla de medio

El log debe guardarse en un dispositivo distinto al de los datos. El roll forward es la base de la recuperación a un punto en el tiempo (point-in-time recovery).
FallaQué se pierdeCómo recuperar
Caída del sistema (energía, proceso)Solo la memoria. El disco está bienReaplicar el log desde el último checkpoint
Falla de medio (disco destruido)Los archivos de datosRestaurar el último backup completo y reaplicar el log archivado desde entonces (roll forward)

ARIES en pocas líneas

  • Tres fases: análisis (averiguar qué pasaba en la caída), redo, undo.
  • Repetir la historia: el redo reaplica todo el log, de ganadoras y perdedoras, hasta que la base queda idéntica al instante de la caída. Solo entonces el undo recorre el log hacia atrás, deshaciendo las perdedoras y escribiendo cada paso como un registro de compensación (CLR). Así la recuperación sigue siendo correcta aunque se interrumpa y reinicie.
  • LSN: cada registro de log tiene un número de secuencia (log sequence number), y cada página guarda el LSN del último cambio que recibió. En el redo, un registro solo se aplica si su LSN es mayor que el de la página. Ejecutar la recuperación dos veces no aplica nada dos veces: el redo es idempotente.

Páginas rotas y full-page writes

Un registro de log común dice 'cambia este trozo respecto al contenido viejo', así que supone que la página en el disco está intacta. Si la caída ocurre a mitad de la escritura de una página, esta queda en parte nueva y en parte vieja (una torn page, página rota) y el redo incremental no funciona. En PostgreSQL, con full_page_writes = on, el primer cambio a una página después de un checkpoint escribe la imagen completa de la página en el WAL. Esto le da a la recuperación un punto de partida intacto.

↑ Volver al índice

Transacciones distribuidas y commit en dos fases

Dentro de una base, el log local da la atomicidad. ¿Pero qué pasa si una transacción cambia datos en dos bases distintas? Cada una podría confirmar o fallar por su cuenta, y terminarías con un débito en un lugar y ningún crédito en el otro. El two-phase commit (2PC, commit en dos fases) es un protocolo que hace que todos se pongan de acuerdo en un solo resultado: todos confirman o todos abortan.

Las dos fases

Coordinador            Participantes A, B, C
    |  PREPARE?  ------>  
    |  <------ sí/no
    |      (fase 1: votación)
    |
    |  todos sí -> COMMIT  ---->
    |  algún no -> ABORT   ---->
    |      (fase 2: decisión)
El coordinador conduce el protocolo. Cada participante tiene derecho a veto.
  • Fase 1, prepare. El coordinador pregunta a cada participante si puede confirmar. Cada uno se prepara y vota sí o no.
  • Fase 2, decisión. Si todos votaron sí, el coordinador ordena a todos confirmar. Si alguno votó no (o no respondió a tiempo), ordena a todos abortar.
  • La decisión es unánime: un solo 'no' hace que todo aborte, porque solo cada participante sabe si su parte se puede confirmar (por ejemplo, puede haber fallado una restricción).

Qué significa un 'sí'

Antes de votar, el participante puede abortar cuando quiera. Después de votar sí, hizo una promesa: 'puedo confirmar, pase lo que pase'. Ya no puede confirmar ni abortar por sí solo, debe esperar al coordinador. Para mantener la promesa aunque se caiga justo después, el participante debe forzar la escritura de los cambios y de un registro de prepare en su log antes de responder sí. Cuando vuelve, encuentra la transacción preparada y le pregunta al coordinador qué se decidió.

El gran problema: el bloqueo

El 2PC es un protocolo bloqueante. Si todos los participantes votaron sí y el coordinador se cae antes de enviar la decisión, los participantes quedan en duda (in doubt). No pueden elegir solos, así que esperan a que el coordinador vuelva y lea la decisión de su log. Mientras tanto, sus datos siguen bloqueados. Esta ventana de incertidumbre, con bloqueos retenidos, es el principal costo del 2PC.

Piezas del 2PC en PostgreSQL

BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
PREPARE TRANSACTION 'tx42';   -- durable, la sesión queda libre
-- después, desde cualquier sesión:
COMMIT PREPARED 'tx42';       -- o: ROLLBACK PREPARED 'tx42';
PostgreSQL ofrece las piezas. Un coordinador externo ejecuta el protocolo.
  • PREPARE TRANSACTION deja la transacción lista para confirmar y durable. Requiere max_prepared_transactions mayor que cero (el valor por defecto es cero).
  • Si el coordinador nunca vuelve, la transacción preparada huérfana sigue reteniendo sus bloqueos de fila. Además sigue 'en curso', así que VACUUM no puede limpiar versiones viejas y las tablas se inflan.
  • Quien use esta función debe vigilar la vista pg_prepared_xacts.
↑ Volver al índice

Saga y outbox

El two-phase commit acopla a todos los participantes: si uno está caído, nadie confirma, y la mayoría de los brokers de mensajes ni siquiera participan en él. Los sistemas modernos con muchos servicios pequeños (microservicios) suelen renunciar a la transacción única y construir la seguridad con piezas más pequeñas. Las dos piezas famosas son la saga y el outbox.

El bug de la escritura doble (dual write)

Tu servicio debe guardar un pedido en su base y anunciar OrderCreated a un . Son dos sistemas distintos y ninguna transacción alrededor de ambos. Eso es una dual write.

await db.insert(orders).values(order);          // 1: COMMIT en la base
// <- caída aquí
await broker.publish("order.created", order);   // 2: publica en el broker
Si el proceso muere entre las líneas, el pedido existe y el evento no. El servicio de pagos nunca se entera.

Invertir el orden no lo arregla. Publica primero y cáete, y un evento anuncia un pedido que nunca existió, lo que suele ser peor. Ningún orden de dos escrituras arregla la falta de atomicidad entre dos sistemas.

El transactional outbox

El truco: convertir dos escrituras en dos sistemas en una escritura en un sistema. El evento se vuelve una fila en una tabla outbox, insertada en la misma transacción local que el pedido. La base ahora garantiza 'los dos o ninguno'.

BEGIN;
INSERT INTO orders (id, total) VALUES (1, 100);
INSERT INTO outbox (event_id, routing_key, payload) VALUES (...);
COMMIT;
El pedido y el evento se guardan juntos o no se guardan.

Un ciclo aparte, el relay, lee las filas pendientes, publica cada una en el broker, espera la confirmación y marca la fila como enviada. Una caída solo retrasa el evento, no puede perderlo. Pero el relay puede morir después de publicar y antes de marcar la fila, y entonces publica el mismo evento otra vez. Por eso el outbox da entrega at-least-once (al menos una vez), no exactly-once. El consumidor debe, entonces, ser idempotente (siguientes secciones).

La saga

Una saga divide una operación de negocio en transacciones locales, una por servicio, encadenadas por mensajes. Cada paso confirma de verdad en su propia base, así que no se puede deshacer con ROLLBACK. Si un paso posterior falla, se ejecutan transacciones compensatorias: nuevas operaciones de negocio inversas, como cancelar, reembolsar o devolver al stock. Deshacen el efecto por su significado y dejan el historial visible.

Si la tarjeta es rechazada, la falla viaja como mensaje y el servicio de pedidos cancela el pedido. Al final las dos bases coinciden.
PasoServicioTransacción localCompensación
1pedidosCrea el pedido como PENDING y emite OrderCreatedCancelar el pedido
2pagosCobra la tarjeta, emite PaymentCompleted o PaymentFailedReembolsar (si falla un paso posterior)
3pedidosEn PaymentCompleted marca PAID. En PaymentFailed marca CANCELLEDno hace falta

Coreografía y orquestación

EstiloQuién conoce el flujoBuenoMalo
CoreografíaNadie. Cada servicio escucha eventos y publica nuevosLos servicios quedan desacopladosEl flujo queda repartido, difícil de ver
OrquestaciónUn orquestador central envía comandos a cada servicioFácil de seguir y depurarUn punto central que mantener

A qué renuncia la saga: el aislamiento

Comparada con una transacción 2PC, la saga pierde el aislamiento de la operación como un todo. No hay bloqueo global, así que los efectos del paso 1 son visibles antes de que la saga termine, y alguien puede actuar sobre un estado que será compensado. Las defensas son decisiones de diseño: mostrar un estado intermedio explícito como PENDING (un bloqueo semántico), volver a leer antes de actuar y elegir con cuidado el orden de los pasos.

↑ Volver al índice

Idempotencia y efectos exactly-once

Aprietas el botón del ascensor. Parece que no pasa nada, así que lo aprietas otra vez, y otra. El ascensor llega una sola vez. Apretar el botón muchas veces tiene el mismo resultado que apretarlo una. Eso es idempotencia: una operación es idempotente si hacerla una, dos o diez veces deja el sistema en el mismo estado.

Por qué la necesitamos: los reintentos

En una red, cuando una respuesta no llega, no sabes si la petición se perdió o si funcionó y lo que se perdió fue la respuesta. Lo seguro es intentar de nuevo. Eso solo es seguro si la operación es idempotente. Hasta la recuperación de la base depende de esto: redo y undo deben poder repetirse.

Una operación no idempotente puede volverse idempotente atándola a un id único y rechazando una segunda ejecución con el mismo id.
Sentencia¿Idempotente?Por qué
UPDATE accounts SET balance = 100 WHERE id = 1SíUna asignación absoluta: 'el saldo es 100'
UPDATE accounts SET balance = balance - 10 WHERE id = 1NoRelativa: cada ejecución resta otra vez
INSERT INTO orders (...) VALUES (...)NoCada ejecución crea una fila más

Garantías de entrega

Un ack es el receptor diciéndole al broker 'lo recibí'. Si no llega, el broker envía el mensaje otra vez.
GarantíaQué puede pasarCuándo se envía el ack
At-most-once (como máximo una vez)Nunca hay duplicado, pero una falla puede perder el mensajeAntes de procesar
At-least-once (al menos una vez)No se pierde nada, pero un mensaje puede llegar dos vecesDespués de procesar
Exactly-once (exactamente una vez)Difícil o imposible en general (ver abajo)-

El momento del ack define la garantía. Si el consumidor confirma en cuanto recibe y luego se cae durante el procesamiento, el mensaje se perdió (at-most-once). Si confirma después de procesar, una caída entre el procesamiento y el ack provoca una reentrega (at-least-once).

Por qué la entrega exactly-once es imposible en general

Cuando la confirmación no llega, quien envió no puede saber si el mensaje se perdió o si se procesó y se perdió la respuesta. Reenviar da at-least-once. No reenviar da at-most-once. No hay una tercera opción en una red no confiable. Lo que sí es posible es un efecto exactly-once: entrega at-least-once combinada con procesamiento idempotente.

Dos herramientas

Clave de idempotencia en una API. El cliente genera un valor único, por ejemplo en el encabezado Idempotency-Key, y envía el mismo en cada intento de la misma operación. El servidor guarda la clave junto con el resultado. Una repetición recibe el resultado guardado y el cobro ocurre una sola vez.

Deduplicación en un consumidor. El consumidor guarda el id de cada mensaje en una tabla processed_messages con una clave única, en la misma transacción local que el efecto. Así 'lo procesé' y 'el efecto ocurrió' son una única decisión atómica.

INSERT INTO processed_messages (message_id) VALUES ($1)
ON CONFLICT (message_id) DO NOTHING;
-- 1 fila insertada: primera vez, haz el trabajo
-- 0 filas: ya vista, sáltala y solo da el ack
La clave única deja que la base decida quién llegó primero.
↑ Volver al índice

Teorema CAP y modelos de consistencia

Para ser rápidos y resistir fallas, los sistemas grandes guardan copias (réplicas) de los datos en varias computadoras llamadas nodos. Las copias traen una pregunta nueva: si escribo en un nodo, ¿cuándo muestran los otros nodos el valor nuevo? La respuesta depende del modelo de consistencia que ofrece el sistema.

El teorema CAP

LetraPropiedadEn palabras simples
Consistency (consistencia)Toda lectura ve la escritura más recienteLos clientes no notan que hay varias copias
Availability (disponibilidad)Toda petición a un nodo que funciona recibe respuestaSin error y sin espera infinita
Partition tolerance (tolerancia a particiones)El sistema sigue funcionando cuando la red separa grupos de nodosLos nodos no pueden hablarse durante un rato

El lema 'elige dos de tres' engaña. Las fallas de red ocurren quieras o no, así que no puedes renunciar a la tolerancia a particiones en un sistema distribuido real. El teorema dice solo esto: mientras dura una partición, hay que elegir entre responder con datos posiblemente viejos (disponibilidad) o rechazar peticiones (consistencia).

   [Nodo 1] ---- X ---- [Nodo 3]
       \                  /
        \                /
         +---- [Nodo 2] -+

partición de red: el Nodo 3 queda aislado

sistema CP: el Nodo 3 (minoría) responde con error
sistema AP: todos responden, los conflictos se arreglan después
Un sistema CP acepta operaciones solo del lado que tiene la mayoría (el quórum).

PACELC amplía la idea: si hay una Partición, elige Disponibilidad (Availability) o Consistencia; Si no (Else, sin partición), elige Latencia o Consistencia. Incluso en un buen día, ser más consistente cuesta tiempo de espera.

Quórums: R + W > N

Toma N copias. Una escritura espera W confirmaciones y una lectura consulta R nodos. Si R + W > N, cualquier conjunto de R nodos se solapa con cualquier conjunto de W nodos, así que toda lectura oye al menos a un nodo que tiene la última escritura confirmada. Si R + W es N o menos, una lectura puede caer solo en nodos que nunca vieron la escritura.

R + W para N = 5 nodos (debe ser mayor que 5 para que haya solapamiento)
R=1, W=1
2 R + W
R=2, W=2
4 R + W
R=2, W=3
5 R + W
R=3, W=3
6 R + W
R=1, W=5
6 R + W

Modelos de consistencia, del débil al fuerte

ModeloPromesaEjemplo
Consistencia eventualUna escritura va a una réplica y se esparce en segundo plano. Réplicas distintas pueden responder distinto por un tiempo. Si las escrituras paran, todas convergen al mismo estadoUn contador de red social que se actualiza unos segundos después
Garantías de sesiónValen para un cliente a la vez. Read-your-writes: nunca dejas de ver tus propios cambiosDespués de cambiar el nombre del perfil, ves el nombre nuevo
LinealizabilidadUna garantía de recencia: cada operación parece ocurrir en un instante entre su inicio y su fin, así que una lectura posterior a una escritura siempre la veUn servicio de locks o una elección de líder

El read-your-writes suele lograrse leyendo del primario justo después de escribir, o leyendo solo de réplicas que ya aplicaron la posición de tu última escritura.

Linealizabilidad frente a serializabilidad

Tener ambas suele llamarse serializabilidad estricta (strict serializability).
PropiedadSerializabilidadLinealizabilidad
Tipo de garantíaAislamiento: las transacciones concurrentes equivalen a algún orden serialRecencia: las operaciones respetan el tiempo real
Trata deGrupos de operaciones (transacciones)Una sola operación sobre un solo objeto
¿Respeta el reloj?No, el orden puede ser cualquieraSí
↑ Volver al índice

Juntando todo

Cada herramienta de este resumen existe para responder una pregunta: '¿qué puede salir mal y quién lo impide?'. Aquí está el mapa.

ProblemaHerramientaDónde funciona
Caída en medio de una transacciónWrite-ahead log, redo y undoUna base
Leer trabajo a medias de otrosNiveles de aislamientoUna base
Dos escritores en la misma filaBloqueos (pesimista) o chequeo de versión (optimista)Una base
Lectores y escritores bloqueándoseMVCCUna base
Espera circularDetección y reintento, o un orden global de bloqueosUna base
Varias bases deben ponerse de acuerdoTwo-phase commitPocos sistemas confiables, con riesgo de bloqueo
Servicios y brokers que no pueden unirse a una transacciónSaga y outboxEntre servicios
Peticiones repetidas o reentregadasClaves de idempotencia y deduplicaciónEntre servicios
Copias de los datosQuórums y un modelo de consistencia elegidoSistemas replicados

Errores comunes

  • Creer que solo BEGIN ... COMMIT evita el overselling. En READ COMMITTED una lectura común no reserva nada.
  • Creer que READ UNCOMMITTED muestra dirty reads en PostgreSQL. Se comporta como READ COMMITTED.
  • Ignorar el error 40001 o 40P01. Significan 'repite la transacción completa'.
  • Ignorar el número de filas afectadas en el control optimista.
  • Usar el cliente global en vez de tx dentro de una transacción de ORM.
  • Publicar en un broker y confirmar en una base como dos pasos separados.
  • Deduplicar mensajes con SELECT y luego INSERT, sin clave única.
  • Mezclar la C de ACID con la C de CAP.
↑ Volver al índice