Опирается на правила:
R-DIST-SAGA-1…R-DIST-SAGA-5иR-DIST-SAGA-X1…R-DIST-SAGA-X4из Distributed Patterns Style Guide → раздел 2. Saga — оркестрация vs хореография.
Важно знать
- Saga — это серия локальных транзакций + compensation, не один большой ACID-транзакшен через JTA/XA.
- Orchestration (центральный координатор) — для complex sagas 4+ шагов или с branching. Видно весь flow в одном классе.
- Choreography (события без координатора) — для simple sagas 2-3 шагов без branching. Меньше центральной сложности, но логика рассредоточена.
- Saga state хранится в БД (
saga_<name>таблица) — это даёт recovery после рестарта orchestrator-а, видимость in-flight саг и audit.- SagaId UUID проходит через каждое сообщение и каждый шаг — единственный способ корректно трассировать сагу через сервисы.
- Запрет 2PC/XA через JTA — не работает с Kafka, не масштабируется, single point of failure.
- Saga — отдельный orchestrator-компонент, не часть use case handler-а.
Saga — главный паттерн UCP для управления cross-service бизнес-операцией. Когда «создать заказ» = три сервиса, каждый со своей БД и своими транзакциями — sata собирает их в одну согласованную бизнес-операцию через локальные транзакции и compensation при сбое.
Когда применять Saga
R-DIST-SAGA-1: Saga применяется когда выполнены все три условия:
- Операция охватывает 2+ сервиса.
- Каждый шаг должен быть transactional локально (commit в свой PG).
- Нужна возможность compensation (rollback) при сбое промежуточного шага.
Если третье условие отсутствует (можно «дотолкать» сообщение retry-ями без отката предыдущих) — достаточно outbox + idempotent consumer, saga избыточна.
Orchestration — для complex sagas
R-DIST-SAGA-2: orchestration рекомендуется для саг 4+ шагов или с branching. Центральный координатор (OrderSagaOrchestrator) знает все шаги, условия переходов и compensation-цепочки.
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderSagaOrchestrator {
private final OrderService orderService;
private final PaymentService paymentService;
private final InventoryService inventoryService;
private final SagaStateRepository sagaStateRepository;
public void run(OrderRequest request) {
var sagaId = UUID.randomUUID();
sagaStateRepository.create(sagaId, "ORDER_CREATION", request);
Long orderId = null;
Long paymentId = null;
try {
sagaStateRepository.updateStep(sagaId, "CREATE_ORDER");
orderId = orderService.create(sagaId, request);
sagaStateRepository.updateStep(sagaId, "CHARGE_PAYMENT");
paymentId = paymentService.charge(sagaId, orderId, request.amount());
sagaStateRepository.updateStep(sagaId, "RESERVE_INVENTORY");
inventoryService.reserve(sagaId, orderId, request.items());
sagaStateRepository.updateStep(sagaId, "CONFIRM_ORDER");
orderService.confirm(sagaId, orderId);
sagaStateRepository.complete(sagaId);
} catch (Exception e) {
log.error("Saga {} failed at step, compensating", sagaId, e);
compensate(sagaId, orderId, paymentId);
throw new SagaFailedException(sagaId, e);
}
}
private void compensate(UUID sagaId, Long orderId, Long paymentId) {
sagaStateRepository.updateStatus(sagaId, "COMPENSATING");
if (paymentId != null) {
paymentService.refund(sagaId, paymentId);
}
if (orderId != null) {
orderService.cancel(sagaId, orderId);
}
sagaStateRepository.updateStatus(sagaId, "FAILED");
}
}
Плюсы orchestration: весь flow читается в одном файле, легко добавить новый шаг, легко увидеть compensation-логику. Минусы: координатор — точка отказа (но recovery через saga_<name> таблицу).
Choreography — для simple sagas
R-DIST-SAGA-3: choreography — для 2-3 шагов без branching. Каждый сервис подписан на события других и реагирует. Центрального координатора нет.
order.created → payment-service charges → payment.charged → order-service confirms
↘ payment.failed → order-service cancels
@Component
@RequiredArgsConstructor
public class PaymentChargedListener {
private final OrderRepository orderRepository;
@KafkaListener(topics = "payment.events", groupId = "order-service")
@Transactional
public void onPaymentCharged(PaymentChargedEvent event) {
var order = orderRepository.findBySagaId(event.sagaId())
.orElseThrow();
order.confirm();
orderRepository.save(order);
}
}
@Component
@RequiredArgsConstructor
public class PaymentFailedListener {
private final OrderRepository orderRepository;
@KafkaListener(topics = "payment.events", groupId = "order-service")
@Transactional
public void onPaymentFailed(PaymentFailedEvent event) {
var order = orderRepository.findBySagaId(event.sagaId())
.orElseThrow();
order.cancel();
orderRepository.save(order);
}
}
Плюсы: нет центрального координатора, каждый сервис автономен. Минусы: при 4+ шагах flow становится невозможно прочитать — нужно открывать N сервисов и собирать картину в голове.
| Параметр | Orchestration | Choreography |
|---|---|---|
| Шагов | 4+ | 2-3 |
| Branching | да | нет |
| Видимость flow | один класс | N сервисов |
| Где state | у orchestrator-а | у каждого сервиса |
| Сложность реализации | средняя | низкая (на старте) |
| Сложность отладки | средняя | высокая (при росте) |
Saga state в БД
R-DIST-SAGA-4: state саги хранится в БД (saga_<name> таблица). Это даёт три критичных свойства:
CREATE TABLE saga_order_creation (
saga_id uuid PRIMARY KEY,
status text NOT NULL, -- IN_PROGRESS, COMPLETED, FAILED, COMPENSATING
current_step text NOT NULL,
payload jsonb NOT NULL,
started_at timestamptz NOT NULL,
completed_at timestamptz,
last_error text
);
CREATE INDEX ix_saga_order_creation_status ON saga_order_creation(status)
WHERE status IN ('IN_PROGRESS', 'COMPENSATING');
- Видимость.
SELECT * FROM saga_order_creation WHERE status = 'IN_PROGRESS'— какие саги сейчас в процессе. Без этого operations слепые. - Recovery. Если orchestrator упал на середине шага 3, после рестарта читаем все
IN_PROGRESSсаги и продолжаем. Без state-таблицы все in-flight саги теряются. - Audit. История каждой саги остаётся в БД — кто, когда, какой шаг сломал, на чём compensation-ил.
Partial index WHERE status IN ('IN_PROGRESS', 'COMPENSATING') — потому что 99% rows быстро становятся COMPLETED, искать нужно только активные.
SagaId сквозной
R-DIST-SAGA-5: sagaId (UUID) проходит через каждое сообщение, каждый HTTP-запрос между сервисами, каждое доменное событие. Это связывает все шаги в одну сагу для tracing, debugging и idempotency.
// HTTP header
POST /orders
X-Saga-Id: 0193a8f3-7c21-7e3f-9b4a-...
// Kafka message
{
"sagaId": "0193a8f3-7c21-7e3f-9b4a-...",
"eventId": "0193a8f3-8c11-7f1e-...",
"eventType": "OrderCreated.v1",
"payload": { ... }
}
В таблицах сервисов — колонка saga_id с индексом. Это даёт SELECT * FROM orders WHERE saga_id = ? — что произошло в этой саге в моём сервисе.
Что запрещено
2PC/XA вместо саги
R-DIST-SAGA-X1: distributed transaction через JTA/XA не подходит для нашего стека. Подробнее — Distributed transactions.
Saga без compensation
R-DIST-SAGA-X2: если шаг 3 упал, а шаги 1 и 2 уже committed — без compensation остаётся «полусделанная» операция в проде. Деньги списаны, товар не зарезервирован, заказ создан в статусе PROCESSING навсегда.
Каждый шаг саги обязан иметь парную compensation-команду. Подробнее — Compensation.
Saga state только in-memory
R-DIST-SAGA-X3: orchestrator хранит state в Map<UUID, SagaState> — при рестарте все in-flight саги теряются. Запросы клиента «что с моим заказом» висят навсегда, потому что никто не знает, что сага существовала.
State обязан быть в БД — другой способ переживания рестарта не работает.
Saga смешана с use case в одном handler-е
R-DIST-SAGA-X4: handler use case-а CreateOrderHandler не должен сам вызывать payment-service и inventory-service. Это размывает ответственность: handler пишет в БД и инициирует cross-service flow одновременно.
Корректно: handler пишет в БД свой локальный шаг → публикует событие/команду → отдельный OrderSagaOrchestrator (компонент) ведёт сагу.
// ПЛОХО — saga встроена в handler use case-а
@UseCase
public class CreateOrderHandler implements UseCaseHandler<CreateOrderCommand, Order> {
@Transactional
public Order handle(CreateOrderCommand command) {
var order = orderRepository.save(new Order(command));
var paymentId = paymentClient.charge(order.id(), command.amount()); // HTTP внутри @Transactional!
try {
inventoryClient.reserve(order.id(), command.items());
} catch (Exception e) {
paymentClient.refund(paymentId);
throw e;
}
return order;
}
}
// ХОРОШО — handler делает только локальный шаг, orchestrator — отдельно
@UseCase
public class CreateOrderHandler implements UseCaseHandler<CreateOrderCommand, Order> {
@Transactional
public Order handle(CreateOrderCommand command) {
var order = orderRepository.save(Order.start(command));
outboxEventPublisher.publish(new OrderStartedEvent(order.sagaId(), order.id()));
return order;
}
}
@Component
public class OrderSagaOrchestrator {
@KafkaListener(topics = "order.events")
public void onOrderStarted(OrderStartedEvent event) { ... }
}
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| 2PC/XA через JTA вместо саги | R-DIST-SAGA-X1 | saga с локальными транзакциями |
| Saga без compensation-команд | R-DIST-SAGA-X2 | каждый шаг имеет compensation |
| Saga state только in-memory | R-DIST-SAGA-X3 | saga_<name> таблица в PG |
| Saga смешана с use case | R-DIST-SAGA-X4 | отдельный orchestrator |
HTTP-вызовы внутри @Transactional write-handler-а | R-DIST-SAGA-X4 | outbox + событие → orchestrator |
| Choreography на 5+ шагов | R-DIST-SAGA-2 | orchestration с координатором |
Куда дальше
- Distributed Patterns → раздел 2. Saga — нормативные формулировки.
- Compensation — semantic state-change, не DELETE; идемпотентность compensation.
- Idempotency — каждый шаг саги обязан быть идемпотентным.
- Outbox + Inbox — публикация шагов и событий саги.
- Eventual consistency — read-your-writes для in-flight саги.
- Distributed transactions — почему 2PC/XA не вариант.
- Resilience → retry — retry только при идемпотентности шагов саги.