Saltar al contenido
Volver al archivo
Ingeniería5 min de lecturaPaso 6 de 8

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.

· Gabriel Dias
sistemas-distribuidosmicroserviciosbackend

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
La saga cambia atomicidad por disponibilidad, y casi siempre es el cambio correcto.

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 (...);
COMMIT

Una 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

Háblame

¿Dudas sobre el artículo? Escríbeme por WhatsApp

Sin formulario y sin lista de correo. Si no estás de acuerdo con algo que escribí, o quieres contarme cómo lo resolviste, la conversación es directa conmigo.

Abrir conversación