Saga pattern en Spring Boot: coordinar pedidos e inventario sin two-phase commit
Cuando un pedido se crea en un sistema con microservicios, casi siempre hace falta tocar más de un servicio: el servicio de pedidos registra la orden, y el servicio de inventario tiene que reservar el stock correspondiente. Si ambos vivieran en la misma base de datos, esto sería una transacción común con commit o rollback. En cuanto son dos servicios con su propia base de datos, ya no existe una transacción ACID que cubra los dos pasos a la vez, y la pregunta que hay que resolver antes de escribir una sola línea es qué pasa cuando el primer paso tiene éxito y el segundo falla.
Por qué two-phase commit no es la respuesta
La primera idea que suele aparecer es extender la transacción a través de los dos servicios con un coordinador de two-phase commit (2PC): cada participante prepara su cambio y espera confirmación antes de aplicarlo. En papel resuelve el problema. En la práctica, 2PC exige que cada participante mantenga bloqueados sus recursos mientras espera la confirmación del coordinador, lo que en un sistema con microservicios significa mantener locks en la base de datos de inventario durante todo el tiempo que tarde la red en ir y volver hasta el servicio de pedidos. Si el servicio de pedidos se cae entre el prepare y el commit, el inventario queda bloqueado indefinidamente. Además, la mayoría de los brokers de mensajería y bases de datos gestionadas que usamos en Spring Boot no exponen soporte XA completo, o lo exponen de una forma que complica el pool de conexiones sin aportar la garantía que promete. El acoplamiento en tiempo de ejecución que exige 2PC —todos los participantes disponibles y respondiendo al mismo tiempo— es exactamente lo que separar los servicios buscaba evitar.
El patrón saga: pasos locales con su compensación
Una saga reemplaza la transacción distribuida por una secuencia de transacciones locales, cada una confirmada de inmediato en su propio servicio, y le asocia a cada paso una acción de compensación que deshace su efecto si un paso posterior de la secuencia falla. No hay bloqueo esperando confirmación ajena: cada paso se compromete solo, y si algo falla más adelante, la saga ejecuta las compensaciones de los pasos ya confirmados en orden inverso.
Para pedidos e inventario, la secuencia queda así: primero se crea el pedido en estado PENDIENTE, después se reserva el stock en inventario, y solo cuando la reserva se confirma se marca el pedido como CONFIRMADO. Si la reserva de inventario falla porque no hay stock suficiente, la compensación es cancelar el pedido que ya se había creado. No hace falta deshacer nada en inventario porque ese paso nunca llegó a comprometerse.
Optamos por una saga orquestada en vez de una coreografiada por eventos sueltos, porque queríamos que el estado de cada saga en curso fuera consultable en un solo lugar, no reconstruido a partir del historial de eventos de varios topics. Un orquestador explícito mantiene esa responsabilidad en una sola clase:
@Service
public class CrearPedidoSagaOrchestrator {
private final PedidoRepository pedidoRepository;
private final InventarioClient inventarioClient;
private final SagaLogRepository sagaLogRepository;
@Transactional
public Pedido ejecutar(CrearPedidoCommand comando) {
Pedido pedido = pedidoRepository.save(
Pedido.pendiente(comando.clienteId(), comando.items()));
sagaLogRepository.registrarPaso(pedido.getId(), "PEDIDO_CREADO");
try {
ReservaInventarioResponse reserva =
inventarioClient.reservar(pedido.getId(), comando.items());
pedido.confirmar(reserva.reservaId());
sagaLogRepository.registrarPaso(pedido.getId(), "INVENTARIO_RESERVADO");
} catch (StockInsuficienteException | InventarioNoDisponibleException ex) {
compensarCreacionPedido(pedido);
throw new SagaFallidaException("No se pudo reservar inventario para el pedido "
+ pedido.getId(), ex);
}
return pedidoRepository.save(pedido);
}
private void compensarCreacionPedido(Pedido pedido) {
pedido.cancelar();
pedidoRepository.save(pedido);
sagaLogRepository.registrarPaso(pedido.getId(), "PEDIDO_CANCELADO_COMPENSACION");
}
}
Cada paso queda registrado en sagaLogRepository antes de intentar el siguiente, no después. Esto importa para el caso en que el propio proceso se cae entre dos pasos: al reiniciar, un job de reconciliación puede leer el log, encontrar sagas que quedaron con el último paso registrado como PEDIDO_CREADO sin INVENTARIO_RESERVADO ni PEDIDO_CANCELADO_COMPENSACION después de un tiempo razonable, y forzar la compensación pendiente en vez de dejar el pedido colgado en estado PENDIENTE para siempre.
Idempotencia en la reserva de inventario
El cliente HTTP hacia el servicio de inventario necesita reintentos, porque una llamada que falla por timeout no dice si la reserva se aplicó del lado del servidor antes de que la respuesta se perdiera en la red. Sin una clave de idempotencia, un reintento automático puede terminar reservando el mismo stock dos veces para el mismo pedido.
@PostMapping("/reservas")
public ResponseEntity<ReservaInventarioResponse> reservar(
@RequestBody ReservaInventarioRequest request) {
Optional<Reserva> existente = reservaRepository
.findByClaveIdempotencia(request.pedidoId().toString());
if (existente.isPresent()) {
return ResponseEntity.ok(ReservaInventarioResponse.from(existente.get()));
}
Reserva reserva = inventarioService.reservarStock(
request.pedidoId(), request.items());
return ResponseEntity.ok(ReservaInventarioResponse.from(reserva));
}
La clave de idempotencia es el propio identificador del pedido, porque dentro de esta saga cada pedido intenta reservar inventario una sola vez por definición del flujo. Si el reintento llega después de que la primera llamada sí se aplicó, el servicio de inventario devuelve la reserva ya existente en vez de descontar stock otra vez, y el orquestador recibe una respuesta exitosa sin poder distinguir si fue la primera llamada o un reintento, que es exactamente el comportamiento que necesita.
Lo que no funcionó: coreografía pura basada en eventos
La primera versión que implementamos no tenía un orquestador: el servicio de pedidos publicaba un evento PedidoCreado, el servicio de inventario lo consumía y publicaba InventarioReservado o StockInsuficiente, y el servicio de pedidos escuchaba esos eventos para decidir si confirmar o cancelar. Funcionaba en el caso feliz, pero depurar un pedido que quedaba atascado se volvió lento en cuanto el número de pasos creció más allá de dos: para saber en qué punto de la saga estaba un pedido específico había que ir a revisar los logs de dos servicios distintos y reconstruir el orden de eventos a mano, porque no existía un lugar único donde consultar el estado actual de esa saga. Un evento perdido por un consumidor caído en el momento equivocado dejaba un pedido en estado PENDIENTE sin que ningún servicio tuviera la responsabilidad clara de notarlo, porque cada uno solo sabía reaccionar a los eventos que le llegaban, no detectar los que faltaban. Migrar a un orquestador explícito con su propio log de pasos no cambió el hecho de que la comunicación entre servicios sigue siendo asíncrona, pero sí concentró la responsabilidad de saber en qué estado está cada saga en una sola clase con una sola tabla, en vez de repartirla implícitamente entre los manejadores de eventos de cada servicio.
Cuándo vale la pena esto
Una saga con orquestador, log de pasos y compensaciones es más código que una simple llamada REST síncrona, y ese costo solo se justifica cuando la operación de negocio efectivamente cruza dos o más servicios con sus propias bases de datos y necesita mantenerse consistente entre ellos. Si un solo servicio puede resolver toda la operación dentro de su propia transacción local, agregar una saga encima no protege nada que no estuviera ya protegido, y solo suma superficie de código para mantener. La pregunta que usamos para decidir es si la operación puede fallar a medias de una forma visible para el usuario o para otro sistema aguas abajo; si la respuesta es sí, hace falta una compensación explícita, y una saga es la forma más clara que encontramos de hacerla visible en el código en lugar de dejarla implícita en la esperanza de que nada falle entre dos llamadas de red.