Sagas: распределённые транзакции
Orchestration vs Choreography. Compensating actions. Как декомпозировать долгую бизнес-операцию на цепочку локальных транзакций.
TL;DR
- Saga — длинная бизнес-операция, разбитая на локальные транзакции + компенсации при откате.
- Orchestration: центральный координатор запускает шаги. Проще отлаживать, риск SPOF.
- Choreography: сервисы реагируют на события друг друга. Слабее связаны, сложнее визуализировать.
- Compensating action ≠ rollback. Она делает семантически обратное, не восстанавливает состояние.
Зачем эта глава
Классическая проблема микросервисов: одна бизнес-операция затрагивает несколько сервисов. PlaceOrder резервирует товар (inventory), списывает деньги (billing), назначает shipping. Что если после резерва billing упал?
Раньше — 2PC (two-phase commit). Работало в монолите, плохо масштабируется в микросервисах. В современных системах — Sagas.
Идея не нова: Garcia-Molina & Salem описали Sagas в 1987 году для длинных транзакций в БД. В микросервисах паттерн переоткрыт и адаптирован.
Что такое Saga
Последовательность локальных транзакций. Каждая транзакция обновляет один сервис и публикует событие/команду. При ошибке — цепочка компенсирующих транзакций отменяет предыдущие.
Пример PlaceOrder:
1. Order Service: создать Order(status=PENDING)
2. Inventory Service: зарезервировать товар
3. Billing Service: списать оплату
4. Shipping Service: создать shipment
5. Order Service: Order.confirm()
Если на шаге 3 (Billing) ошибка:
Compensate 2. Inventory: отменить резерв
Compensate 1. Order: Order.cancel(reason='billing failed')
Каждый шаг — обычная локальная транзакция в своём сервисе. Никакого 2PC.
Orchestration vs Choreography
Два способа реализовать Sagas.
Orchestration
Центральный координатор (Saga orchestrator) явно управляет шагами: посылает команды сервисам, ждёт результата, при ошибке инициирует компенсацию.
class PlaceOrderSaga:
async def execute(self, order_id: OrderId) -> None:
try:
await self._commands.send(ReserveInventory(order_id))
reserved = await self._events.wait_for(InventoryReserved, order_id)
await self._commands.send(ChargePayment(order_id))
charged = await self._events.wait_for(PaymentCharged, order_id)
await self._commands.send(CreateShipment(order_id))
shipped = await self._events.wait_for(ShipmentCreated, order_id)
await self._commands.send(ConfirmOrder(order_id))
except SagaStepFailed as e:
await self._compensate(order_id, failed_step=e.step)
Плюсы:
- Явный контроль. Видно, что происходит.
- Легко отлаживать — весь flow в одном месте.
- Легко добавлять новые шаги.
Минусы:
- Coordinator — потенциальная точка отказа.
- Централизация — против духа микросервисов.
- Логика бизнес-процесса собирается в одном сервисе.
Choreography
Нет координатора. Каждый сервис подписан на события других, реагирует на них своими локальными транзакциями и публикует свои события.
async def on_order_placed(event: OrderPlaced):
try:
await reserve_stock(event.order_id, event.items)
await publish(InventoryReserved(event.order_id))
except OutOfStock:
await publish(InventoryReservationFailed(event.order_id, reason='out of stock'))
async def on_inventory_reserved(event: InventoryReserved):
try:
await charge(event.order_id)
await publish(PaymentCharged(event.order_id))
except CardDeclined:
await publish(PaymentFailed(event.order_id, reason='card declined'))
Каждый сервис — независимый актор. Обмен через events.
Плюсы:
- Максимально decoupled — сервисы не знают друг о друге.
- Нет SPOF.
- Соответствует духу event-driven.
Минусы:
- Business flow не виден в одном месте. Чтобы понять saga, нужно читать код 5 сервисов.
- Сложно добавлять шаги — нужно менять несколько сервисов согласованно.
- Легко потерять контроль над цепочкой.
Compensating Actions ≠ Rollback
Это ключевое понятие Sagas.
Пример: ChargePayment списал 5000 ₽. Компенсация — RefundPayment(5000). Не «списание отменяется», а новая транзакция возврата.
Почему важно:
- В аудите видны обе операции (списание + возврат).
- Возврат может быть частичным (списали 5000, возврат 4000 из-за комиссии).
- Compensating action может занять время (возврат на карту — до 30 дней).
Проектирование компенсаций
Каждый шаг saga имеет обратную операцию. Проектируем парами.
| Прямое действие | Компенсация |
|---|---|
ReserveInventory(order) | ReleaseInventory(order) |
ChargePayment(order) | RefundPayment(order) |
CreateShipment(order) | CancelShipment(order) |
ConfirmOrder(order) | CancelOrder(order) |
SendEmail(...) | ❌ — email нельзя «отозвать» |
Последнее — важный урок: не всё компенсируется. Некоторые side effects необратимы. Проектировать saga надо так, чтобы необратимые операции были последними — до них можно откатить всё, после них — уже нельзя.
Как правильно: полный сценарий
Пример: PlaceOrder через orchestration.
class PlaceOrderSaga:
STEPS = [
('reserve_inventory', 'release_inventory'),
('charge_payment', 'refund_payment'),
('create_shipment', 'cancel_shipment'),
('confirm_order', None), # necessary conclusion — не компенсируется
]
async def execute(self, order_id: OrderId) -> None:
completed_steps: list[str] = []
try:
for step_name, _ in self.STEPS:
await self._run_step(step_name, order_id)
completed_steps.append(step_name)
except SagaStepFailed:
await self._compensate(completed_steps, order_id)
async def _compensate(self, completed_steps: list[str], order_id: OrderId) -> None:
# компенсируем в обратном порядке
for step_name in reversed(completed_steps):
compensation = dict(self.STEPS)[step_name]
if compensation:
await self._run_step(compensation, order_id)
Ключевое: компенсации выполняются в обратном порядке. Если шаги были 1 → 2 → 3, компенсация — comp(3) → comp(2) → comp(1).
Состояние saga
Долгие saga переживают перезапуск сервиса. Значит, состояние надо где-то хранить.
CREATE TABLE saga_state (
saga_id UUID PRIMARY KEY,
saga_type TEXT NOT NULL,
current_step TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL, -- 'running', 'completed', 'compensating', 'failed'
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
После каждого шага — commit нового состояния. Перезапуск — восстанавливаем saga с последнего сохранённого шага.
Как не надо
1. Sync-цепочка вместо Saga
async def place_order(cmd):
order = await orders.save(Order.from_cmd(cmd))
await inventory_api.reserve(order.items) # sync HTTP
await billing_api.charge(order.customer, order.total) # sync HTTP
await shipping_api.create(order.address, order.items) # sync HTTP
order.confirm()
await orders.save(order)
Все шаги в одной функции, sync. При падении на шаге 3 — как откатить 1 и 2? Ручной catch → API-вызовы к откату → ошибка в откате → плохо.
Правильно: события + автоматизированная saga через orchestrator.
2. Отсутствие компенсаций
class PlaceOrderSaga:
async def execute(self, order_id):
try:
await self._reserve_inventory(order_id)
await self._charge_payment(order_id)
await self._create_shipment(order_id)
except:
logger.error('saga failed') # !!
# inventory зарезервирован, payment списан, shipping нет — inconsistent
Ошибка → просто логируем. Ресурсы утекают, деньги списаны, товар зарезервирован.
Правильно: явные compensating actions для каждого выполненного шага.
3. Компенсация как физический rollback
async def _release_inventory(self, order_id):
await self._db.execute("UPDATE inventory SET reserved = reserved - ...") # !! прямая правка чужого сервиса
Один сервис лезет в БД другого. Микросервисы разрушены. Distributed monolith.
Правильно: ReleaseInventoryCommand → Inventory service сам компенсирует.
4. Sagas без идемпотентности
Retry-механизм повторил ChargePayment. Клиенту списано дважды.
Правильно: каждый шаг saga идемпотентен. Payment service ловит duplicate saga_id — не списывает второй раз.
5. Choreography без визуализации
30 сервисов, у каждого 5 подписок. Никто не знает, как проходит PlaceOrder.
Правильно: явная документация цепочки. Диаграмма. Логирование корреляции. Distributed tracing.
Trade-offs
| Аспект | Orchestration | Choreography |
|---|---|---|
| Явный flow | ✓ | ✗ |
| Легко отлаживать | ✓ | Сложно |
| Легко изменить/добавить шаг | ✓ | Нужно менять N сервисов |
| Тестируемость | Проще | Сложнее |
| Coupling | Средний | Низкий |
| SPOF (координатор) | Риск | Нет |
| Соответствие духу микросервисов | Меньше | Больше |
| Complex flow (условия, циклы) | Лучше | Хуже |
Правило большого пальца:
- Orchestration — для сложных бизнес-процессов с ветвлениями и условиями.
- Choreography — для простых линейных цепочек между loosely coupled сервисами.
Часто комбинируют: коллекция choreography’й для простых цепочек + orchestration для сложных сценариев.
В твоём же коде
Анонимизированный микросервис — не saga. Один сервис, одна бизнес-операция, один aggregate. Saga тут не нужна.
Saga понадобилась бы, если бы Standardize был частью большего процесса:
1. Item Import (import service)
2. Standardize (наш сервис)
3. Match candidates (matcher service)
4. Notify user (notification service)
При ошибке на шаге 2 (после успешного 1) — нужно откатить import? Или пометить его как «pending»? Это уже дизайн saga.
Пока сервис изолирован, focus — на надёжной доставке события (Outbox + идемпотентность).
Дальнейшее чтение
- Hector Garcia-Molina, Kenneth Salem. Sagas. 1987. Оригинальная статья.
- Chris Richardson. Microservices Patterns. Chapter 4 — Sagas детально, оба вида.
- microservices.io/patterns/data/saga.html. Каталог с примерами.
- Udi Dahan. Race Conditions Don’t Exist. Одна из ранних работ по saga в микросервисах.
- Bernd Rücker. Practical Process Automation. Обзор saga через workflow engines (Camunda, Temporal).
Проверьте себя
Мини-quiz · закрепить
Проверьте себя
Q1. Saga — это...
Q2. Compensating action отличается от rollback тем, что...
Q3. Orchestration vs Choreography — когда что выбрать?
Q4. Порядок выполнения компенсирующих действий:
Q5. Что не компенсируется?