Обработка заказов через саги: согласованность оплаты, склада, доставки
Продвинутое3 минРедакция Innovialabs
Коротко
Сага разбивает заказ на последовательность локальных шагов с компенсирующими действиями, а outbox и машина состояний гарантируют, что ни одно событие не потеряется и заказ не застрянет в противоречивом состоянии.
Как саги решают проблему согласованности
Когда оплата, склад и доставка — разные сервисы или внешние системы, одной транзакции на всё нет. Сага — это цепочка локальных транзакций, где у каждого шага есть компенсирующее действие: если шаг 3 провалился, шаги 2 и 1 отменяются в обратном порядке.
Типичная сага заказа:
| Шаг | Действие | Компенсация |
|---|---|---|
| 1 | Создать заказ в статусе pending | Отменить заказ |
| 2 | Зарезервировать товар | Снять резерв |
| 3 | Провести оплату | Вернуть деньги |
| 4 | Создать отправку | Отменить отправку |
| 5 | Подтвердить заказ | — |
Итог — не мгновенная, а итоговая согласованность (eventual consistency): система может быть короткое время в промежуточном состоянии, но всегда приходит к корректному финалу.
Почему распределённый заказ ломается
- Частичный успех. Деньги списаны, а резерв не создан из-за таймаута склада.
- Двойная запись. Сервис записал заказ в базу, но упал до отправки события — остальные о заказе не узнали.
- Повторы. Сеть повторила запрос, и оплата прошла дважды.
- Неизвестный исход. Таймаут не означает отказ: платёж мог пройти.
- Порядок событий. Отмена пришла раньше подтверждения.
Двухфазный коммит (2PC) между внешними API, платёжными провайдерами и службами доставки обычно недоступен, поэтому нужны саги.
Оркестрация или хореография
Оркестрация — центральный координатор (orchestrator) хранит состояние саги и по очереди вызывает сервисы.
Хореография — сервисы реагируют на события друг друга: OrderCreated → склад резервирует → StockReserved → оплата списывает и так далее.
| Критерий | Оркестрация | Хореография |
|---|---|---|
| Где логика процесса | В одном месте | Распределена по сервисам |
| Наблюдаемость | Проще: видно состояние саги | Нужна трассировка событий |
| Связанность | Сервисы зависят от оркестратора | Сервисы зависят от событий |
| Подходит | Длинные процессы с ветвлениями | Короткие цепочки из 2-3 шагов |
Для заказа с оплатой, складом и доставкой оркестрация обычно понятнее в поддержке.
Машина состояний заказа
Заказ должен иметь явные статусы и разрешённые переходы. Запрещённый переход — ошибка, а не тихое обновление.
pending -> stock_reserved -> paid -> shipping_created -> confirmed
pending | stock_reserved | paid -> compensating -> cancelled
Это защищает от гонок: если пришло событие PaymentSucceeded для уже отменённого заказа, система не «воскресит» его, а запустит возврат.
Transactional outbox
Чтобы не терять события при сбое, изменение состояния и событие пишутся в одной локальной транзакции: в таблицу заказов и в таблицу outbox. Отдельный процесс читает outbox и публикует сообщения в брокер.
BEGIN;
UPDATE orders SET status = 'stock_reserved' WHERE id = $1;
INSERT INTO outbox (aggregate_id, type, payload)
VALUES ($1, 'StockReserved', $2);
COMMIT;
Outbox даёт доставку at-least-once, поэтому каждый потребитель должен быть идемпотентным: хранить обработанные id сообщений или использовать idempotency key при вызове платёжного API.
Сценарии сбоев
- Оплата отклонена. Компенсация: снять резерв, заказ в
cancelled, уведомить клиента. - Таймаут оплаты. Не отменять сразу — запросить статус у провайдера или дождаться вебхука, затем решить.
- Служба доставки недоступна. Повторять с backoff; после лимита — ручная обработка, а не автоматический возврат денег.
- Компенсация сама упала. Повторять до успеха и выводить в алерты; компенсации должны быть идемпотентными.
- Сообщение пришло дважды. Идемпотентный обработчик игнорирует повтор.
Частые ошибки
- Вызывать внешние API внутри транзакции базы данных.
- Считать таймаут отказом.
- Не предусмотреть ручное вмешательство для «застрявших» саг.
- Не логировать шаги саги с общим correlation id.