Patrón Saga para transacciones distribuidas en microservicios
1. Descripción general
En una aplicación monolítica, pones @Transactional en un método y la base de datos se encarga del resto: o todo se confirma, o todo se revierte. Cuando pasas a microservicios, cada servicio tiene su propia base de datos, así que una única transacción ACID ya no puede cubrir toda la operación de negocio.
Este artículo introduce el patrón Saga, la forma más común de resolver ese problema. Después de leerlo, entenderás los dos tipos de saga (choreography u orchestration), serás capaz de escribir un pequeño orquestador de saga en Java 21, probarás el flujo de compensación y conocerás los errores comunes al llevar saga a producción.
2. El problema: un negocio, múltiples bases de datos
Considera un flujo de pedidos que consta de tres servicios:
- InventoryService gestiona el inventario.
- PaymentService cobra al cliente.
- ShippingService crea el envío.
Supongamos que el paso 1 es exitoso pero el paso 2 falla porque el cliente no tiene fondos suficientes. En este momento, el producto está reservado pero no hay pago. No puedes hacer “rollback” de la base de datos de InventoryService desde PaymentService.
La solución clásica es el two-phase commit (2PC). Sin embargo, 2PC se usa poco en microservicios porque:
- Bloquea recursos durante todo el tiempo que espera a que todas las partes respondan, lo que reduce drásticamente el rendimiento.
- El coordinador es un único punto de fallo.
- Muchos sistemas como Kafka, MongoDB o APIs REST de terceros no soportan transacciones XA.
3. ¿Qué es una Saga?
Una Saga divide una transacción grande en una secuencia de transacciones locales. Cada transacción local se ejecuta dentro de un servicio y se confirma inmediatamente en su base de datos. Cada paso va acompañado de una transacción compensadora, es decir, una operación de negocio que “deshace” ese paso.
| Paso | Acción | Compensación |
|---|---|---|
| 1 | Reservar producto (reserve) |
Liberar producto (release) |
| 2 | Cobrar (charge) |
Reembolsar (refund) |
| 3 | Crear envío (ship) |
Cancelar envío (cancel) |
Si el paso n falla, la saga ejecuta la compensación de los pasos n-1, n-2, …, 1 en orden inverso.
Dos diferencias importantes en comparación con un rollback real:
- La compensación no borra el historial. Crea un cambio nuevo, por ejemplo una transacción de reembolso. Durante un corto período de tiempo, otros servicios pueden haber visto el estado intermedio.
- Una Saga garantiza consistencia eventual, no garantiza aislamiento como ACID.
Hay dos formas de orquestar una saga:
- Choreography: cada servicio escucha eventos y decide por sí solo el siguiente paso. No hay un control centralizado.
- Orchestration: un orquestador invoca cada servicio en orden y es responsable de ejecutar la compensación si hay un error.
4. Saga de orquestación en Java 21
Esta sección construye un orquestador Java puro, independiente de frameworks, para que veas claramente el mecanismo interno.
4.1. Definir un paso de Saga
import java.util.function.Consumer;
public interface SagaStep<C> {
String name();
void execute(C context);
void compensate(C context);
static <C> SagaStep<C> of(String name, Consumer<C> action, Consumer<C> compensation) {
return new SagaStep<>() {
public String name() { return name; }
public void execute(C ctx) { action.accept(ctx); }
public void compensate(C ctx) { compensation.accept(ctx); }
};
}
}
Cada paso tiene dos comportamientos: execute para el flujo hacia adelante y compensate para el flujo de deshacimiento. El genérico C es el contexto compartido entre los pasos, por ejemplo, la información del pedido. El método de fábrica of ayuda a declarar un paso usando lambda para mayor concisión.
4.2. Orchestrator
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
public record SagaResult(boolean success, String failedStep, String reason) {
static SagaResult ok() { return new SagaResult(true, null, null); }
static SagaResult failed(String step, String reason) { return new SagaResult(false, step, reason); }
}
public class SagaOrchestrator<C> {
private final List<SagaStep<C>> steps;
public SagaOrchestrator(List<SagaStep<C>> steps) {
this.steps = List.copyOf(steps);
}
public SagaResult run(C context) {
Deque<SagaStep<C>> completed = new ArrayDeque<>();
for (SagaStep<C> step : steps) {
try {
step.execute(context);
completed.push(step);
} catch (RuntimeException ex) {
compensate(completed, context);
return SagaResult.failed(step.name(), ex.getMessage());
}
}
return SagaResult.ok();
}
private void compensate(Deque<SagaStep<C>> completed, C context) {
while (!completed.isEmpty()) {
SagaStep<C> step = completed.pop();
try {
step.compensate(context);
} catch (RuntimeException ex) {
// Producción: guarda para reintentar, no lo ignores
System.err.println("Compensation failed at " + step.name() + ": " + ex.getMessage());
}
}
}
}
Los puntos clave:
completedes un stack (ArrayDequeconpush/pop). Cualquier paso que tenga éxito se coloca en la pila, así que cuando sacas elementos tienes el orden inverso exacto.- El paso que falla no está en la pila. Ese paso no ha confirmado nada, así que no necesita compensación.
- Una compensación fallida no debe detener las compensaciones restantes. Por eso cada llamada a
compensatese envuelve en su propiotry/catch.
4.3. Servicios simulados
import java.math.BigDecimal;
import java.util.*;
public record Order(String id, String customerId, String sku, int quantity, BigDecimal amount) {}
public class InventoryService {
private final Map<String, Integer> stock = new HashMap<>(Map.of("BOOK-01", 10));
public void reserve(Order o) {
int left = stock.get(o.sku()) - o.quantity();
if (left < 0) throw new IllegalStateException("Sin existencias");
stock.put(o.sku(), left);
}
public void release(Order o) { stock.merge(o.sku(), o.quantity(), Integer::sum); }
public int available(String sku) { return stock.get(sku); }
}
public class PaymentService {
private final Map<String, BigDecimal> balances = new HashMap<>(Map.of("alice", new BigDecimal("100")));
public void charge(Order o) {
BigDecimal balance = balances.get(o.customerId());
if (balance.compareTo(o.amount()) < 0) throw new IllegalStateException("Fondos insuficientes");
balances.put(o.customerId(), balance.subtract(o.amount()));
}
public void refund(Order o) { balances.merge(o.customerId(), o.amount(), BigDecimal::add); }
public BigDecimal balance(String customerId) { return balances.get(customerId); }
}
public class ShippingService {
private final Set<String> shipments = new HashSet<>();
public void ship(Order o) { shipments.add(o.id()); }
public void cancel(Order o) { shipments.remove(o.id()); }
public boolean hasShipment(String orderId) { return shipments.contains(orderId); }
}
En la práctica, cada método de arriba es una llamada REST/gRPC o un mensaje enviado a otro servicio. Esta simulación se usa solo para ilustración y pruebas.
4.4. Ensamblar una saga de pedidos
import java.math.BigDecimal;
import java.util.List;
public class OrderSagaDemo {
public static void main(String[] args) {
var inventory = new InventoryService();
var payment = new PaymentService();
var shipping = new ShippingService();
var saga = new SagaOrchestrator<Order>(List.of(
SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
SagaStep.of("charge-payment", payment::charge, payment::refund),
SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
));
var cheap = new Order("ORD-1", "alice", "BOOK-01", 2, new BigDecimal("40"));
var pricey = new Order("ORD-2", "alice", "BOOK-01", 3, new BigDecimal("500"));
System.out.println(saga.run(cheap));
System.out.println(saga.run(pricey));
System.out.println("Stock: " + inventory.available("BOOK-01"));
System.out.println("Balance: " + payment.balance("alice"));
}
}
Resultado:
SagaResult[success=true, failedStep=null, reason=null]
SagaResult[success=false, failedStep=charge-payment, reason=Fondos insuficientes]
Stock: 8
Balance: 60
El pedido ORD-1 ejecuta los tres pasos. El pedido ORD-2 reserva 3 libros pero falla en el paso de cobro. El orquestador luego llama a inventory.release, por lo que el inventario vuelve a 8 (10 − 2 del pedido anterior) en lugar de 5.
5. Saga de coreografía con eventos
Con choreography, ningún servicio “conoce” el flujo completo. Cada servicio emite un evento después de completar su trabajo, y otro servicio reacciona a ese evento. El ejemplo a continuación usa ApplicationEventPublisher de Spring dentro de un proceso para ilustrar. En un sistema real, la reemplazarías con Kafka o RabbitMQ.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
public record StockReserved(Order order) {}
public record PaymentFailed(Order order, String reason) {}
@Component
public class PaymentHandler {
private final PaymentService payment;
private final ApplicationEventPublisher events;
public PaymentHandler(PaymentService payment, ApplicationEventPublisher events) {
this.payment = payment;
this.events = events;
}
@EventListener
public void on(StockReserved event) {
try {
payment.charge(event.order());
} catch (IllegalStateException ex) {
events.publishEvent(new PaymentFailed(event.order(), ex.getMessage()));
}
}
}
@Component
public class InventoryCompensationHandler {
private final InventoryService inventory;
public InventoryCompensationHandler(InventoryService inventory) {
this.inventory = inventory;
}
@EventListener
public void on(PaymentFailed event) {
inventory.release(event.order());
}
}
PaymentHandler escucha StockReserved. Si el cobro falla, emite PaymentFailed. Luego InventoryCompensationHandler recibe este evento y libera el producto de vuelta al inventario. Para que el código funcione, InventoryService y PaymentService también necesitan ser declarados como beans.
Comparación rápida de los dos tipos:
- Choreography es simple y tiene bajo acoplamiento cuando el flujo tiene solo 2–3 pasos. Cuando el flujo es más largo, la lógica se dispersa entre muchos servicios, lo que dificulta su seguimiento y puede crear bucles de eventos.
- Orchestration centraliza el flujo en un lugar, es fácil de leer, probar y guardar estado. A cambio, el orquestador se convierte en un componente que debes operar con cuidado.
6. Probar el comportamiento de compensación
Añade la dependencia de test:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderSagaTest {
private final InventoryService inventory = new InventoryService();
private final PaymentService payment = new PaymentService();
private final ShippingService shipping = new ShippingService();
private final SagaOrchestrator<Order> saga = new SagaOrchestrator<>(List.of(
SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
SagaStep.of("charge-payment", payment::charge, payment::refund),
SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
));
@Test
void givenInsufficientFunds_whenRunSaga_thenStockIsReleased() {
var order = new Order("ORD-9", "alice", "BOOK-01", 3, new BigDecimal("500"));
SagaResult result = saga.run(order);
assertThat(result.success()).isFalse();
assertThat(result.failedStep()).isEqualTo("charge-payment");
assertThat(inventory.available("BOOK-01")).isEqualTo(10);
assertThat(payment.balance("alice")).isEqualByComparingTo("100");
assertThat(shipping.hasShipment("ORD-9")).isFalse();
}
@Test
void givenValidOrder_whenRunSaga_thenAllStepsCommitted() {
var order = new Order("ORD-10", "alice", "BOOK-01", 1, new BigDecimal("30"));
assertThat(saga.run(order).success()).isTrue();
assertThat(inventory.available("BOOK-01")).isEqualTo(9);
assertThat(payment.balance("alice")).isEqualByComparingTo("70");
assertThat(shipping.hasShipment("ORD-10")).isTrue();
}
}
La primera prueba verifica lo más importante de una saga: después de fallar, todos los efectos secundarios han sido deshacidos. El inventario vuelve a 10, el saldo permanece igual y no se crea ningún envío. La segunda prueba confirma que el flujo exitoso confirma los tres pasos.
7. Errores comunes y cómo evitarlos
Nota: El orquestador en este artículo mantiene el estado en memoria. Si el proceso muere en medio, no sabes en qué paso estás y tampoco puedes compensar. En producción, guarda el estado de la saga (paso actual, pasos completados) en la base de datos después de cada paso, y permite reanudar al reiniciar.
La compensación debe ser idempotente. El mensaje puede reenviarse varias veces, así que ejecutar refund dos veces no debe reembolsar dos veces. Guarda el orderId de las transacciones ya procesadas e ignora las solicitudes duplicadas.
La compensación no debe “rendirse”. El bloque catch que imprime un log en la sección 4.2 es solo para ilustración. En la práctica, la compensación fallida debe reintentarse con backoff. Si sigue fallando después de varios intentos, envíalo a una dead-letter queue para que lo maneje una persona.
Dual write (Escritura doble). Si un servicio confirma la base de datos y envía un mensaje a Kafka al mismo tiempo, uno de los dos puede fallar. Usa Transactional Outbox: escribe el evento en una tabla outbox en la misma transacción local, luego otro proceso lee esa tabla y publica.
Falta de aislamiento. Entre el cobro y el reembolso, el usuario puede ver que el saldo se reduce temporalmente. Puedes mitigar esto con semantic lock, por ejemplo, establecer el estado del pedido en PENDING hasta que la saga finalice. Otra forma es reordenar los pasos, moviendo el paso más probable de fallar hacia el inicio.
Hay pasos que no se pueden compensar. Un correo electrónico ya enviado no se puede recuperar. Coloca estos pasos (llamados pivot o retriable step) al final de la saga, después de que todos los pasos que podrían fallar hayan tenido éxito.
Cuándo NO debes usar saga:
- Todos los datos están en una base de datos. En ese caso, simplemente usa una transacción local.
- El negocio requiere consistencia fuerte absoluta y no acepta estados intermedios, por ejemplo, ciertos registros contables principales. En este caso, debes reconsiderar cómo dividir los límites del servicio.
- El flujo tiene un solo servicio escribiendo datos, mientras que los otros servicios solo leen.
8. Conclusión
Saga reemplaza una transacción distribuida con una secuencia de transacciones locales acompañadas de transacciones compensadoras, permitiendo que el sistema logre consistencia eventual sin necesidad de 2PC. Has construido un pequeño orquestador que usa una pila para compensar en orden inverso, viste cómo la coreografía se coordina a través de eventos y escribiste pruebas que confirman que todos los efectos secundarios se deshacen cuando hay un error.
Cuando implementes saga en producción, recuerda cuatro cosas: guarda el estado de la saga de forma durable, escribe compensaciones idempotentes con reintentos, usa Transactional Outbox para evitar dual write, y diseña el orden de pasos de modo que las operaciones no compensables estén al final del flujo.