Saga y outbox: transacción sin transacción distribuida
Necesitas debitar en una cuenta y acreditar en otra, y están en bases de datos distintas. Los dos patrones que lo resuelven en la práctica, y qué evitar.
Dentro de una base de datos, la atomicidad es gratis: BEGIN, un puñado de comandos, COMMIT. O pasa
todo o no pasa nada.
En el instante en que la operación atraviesa dos bases, o dos servicios, esa garantía desaparece. Y lo que la mayoría de los equipos hace es fingir que sigue existiendo, lo que produce la categoría de bug más difícil de reproducir que existe.
Por qué 2PC no es la respuesta
La solución académica es el commit en dos fases. Un coordinador pregunta a todos los participantes "¿podéis?"; si todos dicen que sí, manda confirmar.
Funciona. Y tiene un problema que lo hace inviable en la práctica: si el coordinador se cae entre las dos fases, los participantes se quedan con el lock abierto, esperando indefinidamente, sin saber si deben confirmar o deshacer.
Es bloqueante, exige que todos los participantes soporten el protocolo, y crea un punto único de fallo justamente en el componente cuya función es garantizar la fiabilidad.
Existe, funciona en contextos controlados, y casi nadie lo quiere.
Commit en dos fases
- Bloquea: el participante espera con el lock abierto
- Coordinador caído deja a todos trabados
- Exige que todos hablen el protocolo
- Punto único de fallo en el componente de la fiabilidad
Saga
- Cada paso es una transacción local normal
- Nada de locks distribuidos
- ¿Falló? Ejecuta la compensación
- El precio: existe estado intermedio visible
Saga: partir en pasos compensables
La respuesta práctica es la saga. Partes la operación en una secuencia de transacciones locales, cada una con su compensación correspondiente.
- ¿Debitaste de la cuenta A y el crédito en la cuenta B falló? Ejecutas la devolución en A.
- ¿Reservaste el stock y el pago falló? Liberas la reserva.
- ¿Creaste el pedido y la emisión de la factura falló? Cancelas el pedido.
Lo que te da la saga: nada de locks distribuidos. Cada paso es una transacción local normal, en la base que ya es dueña de ese dato.
Lo que te quita: atomicidad de verdad. Existe un intervalo en el que el sistema está en un estado intermedio observable. Alguien puede consultar y ver el pedido creado sin la factura.
Y aquí está la parte que suele trabar la discusión: tu producto tiene que tolerar eso. Normalmente lo tolera, porque el mundo real funciona así de todos modos: el billete se reserva antes de que el pago se confirme, el pedido se crea antes de que se emita la factura.
Cuando no lo tolera, la respuesta no es 2PC. Es reconsiderar la frontera de los servicios: si dos cosas necesitan ser atómicas de verdad, quizá deberían estar en la misma base.
Dos estilos de saga
Coreografía: cada servicio reacciona a eventos y publica los suyos. Nadie coordina. Es simple para empezar y se vuelve difícil de entender cuando pasa de tres o cuatro pasos: nadie consigue responder "¿cuál es el flujo completo?" mirando un solo lugar.
Orquestación: existe un orquestador que conoce el flujo y llama a cada paso. Más fácil de entender, de monitorizar y de depurar. Introduce un componente central, que tiene que ser resiliente y tener estado persistido.
La recomendación práctica: coreografía para flujos de dos o tres pasos; orquestación a partir de ahí. Y si eliges orquestación, persiste el estado de la saga, porque un orquestador en memoria que pierde el estado al reiniciar deja sagas colgadas por la mitad.
El patrón outbox
Ahora el problema más común de todos, y el que más causa bugs silenciosos:
guardarPedido(pedido)
publicarEvento("PedidoCreado", pedido)No existe transacción que cubra los dos. Dos escenarios malos:
- Guardó y la publicación falló: el pedido existe y nadie fue notificado.
- Publicó y el commit falló: medio sistema reaccionó a un pedido que no existe.
El síntoma en la vida real es aquel "de vez en cuando un pedido no aparece en el otro servicio y no conseguimos reproducirlo".
El patrón outbox lo resuelve con una idea casi vergonzosa de simple:
En la misma transacción en la que grabas el pedido, graba también una fila en una tabla de salida.
BEGIN
INSERT INTO pedidos (...) VALUES (...);
INSERT INTO outbox (tipo, payload, creado_en) VALUES (...);
COMMITUna transacción local, atómica, sin coordinador, sin protocolo especial.
Después, un proceso separado lee la tabla outbox, publica en la cola y marca como publicado. Si se cae en el medio, retoma desde donde paró. Si publica dos veces (y va a publicar, tarde o temprano), el consumidor tiene que ser idempotente.
La garantía deja de ser "esperamos que salga bien" y pasa a ser demostrable: si está en la base, está en la outbox; si está en la outbox, será publicado.
La variante con CDC
Existe una versión que se ahorra hasta el proceso de lectura: usar captura de cambios de datos. Herramientas como Debezium leen el log de replicación de la propia base y publican los cambios.
La tabla outbox se convierte en apenas una tabla más siendo capturada, y no escribes ni el publicador.
El coste: una pieza más de infraestructura que operar, y un cuidado específico en Postgres: el slot de replicación. Si el consumidor se para, la base retiene el WAL para él, y el disco se llena. Monitoriza el retraso del slot; ya ha tumbado muchas bases de producción.
Idempotencia: la pieza obligatoria
Nada de esto funciona sin consumidores idempotentes.
La técnica estándar: todo evento lleva un identificador único. El consumidor mantiene una tabla de
eventos ya procesados e ignora la repetición. O, mejor todavía, la operación en sí es naturalmente
idempotente: un UPSERT en vez de un INSERT, una asignación en vez de un incremento.
Qué hacer mañana
Si tienes una operación que graba en la base y publica un evento, tienes ese bug. Solo que todavía no ha molestado lo suficiente.
El camino más corto: crea la tabla outbox, mueve la publicación dentro de la transacción, escribe el publicador en cincuenta líneas.
Lee esto después
- IngenieríaPaso 8Migración de esquema sin downtimePor qué renombrar una columna rompe producción, y el patrón de cuatro pasos que convierte una migración en una tarea de martes por la tarde.Leer artículo
- IngenieríaPaso 7Los cinco patrones de resiliencia que evitan la caída en cascadaCómo una dependencia secundaria lenta derriba el sistema entero en noventa segundos, y los cinco patrones que lo impiden.Leer artículo
- IngenieríaPaso 5CAP, PACELC y los modelos de consistenciaEl CAP es el teorema más citado y peor enunciado de la computación distribuida. Este artículo corrige el enunciado y muestra el vocabulario que de verdad usas en el día a día.Leer artículo