Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Saga и Outbox: транзакции между сервисами

Saga и Outbox: согласованность данных без распределённых транзакций

Почему 2PC не применяют в микросервисах: саги с компенсирующими шагами, outbox для надёжной публикации событий, идемпотентность обработчиков и мониторинг саг.

Прямой ответ

Бизнес-операция «оформить заказ» часто трогает несколько сервисов (заказы, склад, платежи), у каждого — своя БД; классическая распределённая транзакция (2PC/XA) в микросервисах не живёт (блокировки ресурсов на время всех сетевых вызовов, координатор — SPOF, ни один облачный сервис не даёт XA — границы сервисов); рабочая замена — сага: последовательность локальных транзакций, где у каждого шага есть компенсирующее действие (списали деньги → не удалось отгрузить → возврат денег — не откат, а новая обратная операция); согласованность — итоговая (eventual: состояние сойдётся за секунды-минуты, промежуточные состояния видны — проектировать UI и отчёты под это). Два оркестрационных стиля: choreography (сервисы обмениваются событиями, каждый реагирует на своё — просто для 2–3 участников, хаос на 5+ — «кто за что отвечает» не читается) и orchestration (центральный координатор диктует шаги — длиннее код, зато поток виден, логируем и отлаживаем — для сложных саг выбор де-факто). Второй обязательный элемент — outbox: «записать бизнес-данные и опубликовать событие» атомарно невозможно (две системы — БД и брокер); паттерн: событие пишется в ту же БД-транзакцию (таблица outbox), а отдельный процесс (poller/CDC — Debezium читает WAL) доставляет его в брокер — at-least-once гарантированно; получатели дедуплицируют по event_id (идемпотентность потребителей — часть паттерна, не опция). Без этих двух паттернов микросервисная «транзакция» живёт в виде «записали в БД, брокер недоступен — событие потеряно, заказ завис навсегда» — стандартной аварии самописных интеграций.

Saga: шаги, компенсации, стили

Модель: сага = упорядоченные шаги T1..Tn, у каждого компенсирующий C1..Cn-1; провал шага Tk — запуск компенсаций в обратном порядке (C(k-1)..C1) — состояние «отменено»; компенсации — не обратные транзакции, а бизнес-операции (не «удалить строку брони», а «снять бронь со статусом cancelled» — след остаётся для аудита); не у всего есть компенсация («отправить email» неотменяемо — такие шаги ставить последними/после точки невозврата — design-правило саг); провал самой компенсации — аварийная ветка (повтор компенсации с ретраями → эскалация человеку — сага «зависла в компенсации» — алерт и ручная разборка — честнее тихого расхождения данных). Choreography: сервис заказов публикует OrderCreated → склад публикует StockReserved → платежи ChargeSucceeded → заказы OrderConfirmed; провал — события отмены (StockReservationFailed → заказы отменяют); плюсы — нет центрального компонента, слабая связность; минусы — поток «размазан» по подписчикам (отладка «почему заказ не подтвердился» — обход всех сервисов), циклические события на сложных потоках, дублирование знания о процессе. Orchestration: координатор (таблица саг в БД/фреймворк — temporal-подобные движки, self-hosted координатор на Celery+БД) хранит состояние саги и шлёт команды шагам (ReserveStock → Charge → Ship); плюсы — процесс в одном месте (видно/логируемо/версионируемо — саги меняются — старые экземпляры доживают по старой версии — версионирование обязательно), проще тесты; минусы — координатор критичен (HA обязателен) и «знает всех». Практический порог: 2–3 линейных шага — хореография приемлема; 4+ шага/ветвления/ручные вмешательства — оркестрация.

Outbox: атомарная публикация событий

Проблема дуальной записи: INSERT заказ; publish(OrderCreated) — между ними процесс падает → заказ есть, события нет (или наоборот — publish раньше коммита — подписчики читают незакоммиченное); решения: транзакционный outbox — BEGIN; INSERT INTO orders...; INSERT INTO outbox (event_id, type, payload) ...; COMMIT; — событие и данные атомарны; доставка — (а) poller-процесс (SELECT ... WHERE published_at IS NULL FOR UPDATE SKIP LOCKED — батчами в брокер — SKIP LOCKED-паттерн — просто и достаточно для умеренных объёмов) или (б) CDC — Debezium читает WAL БД и транслирует строки outbox в Kafka (без опроса, порядок по транзакциям, инфраструктурно тяжелее — выбор по масштабу); at-least-once (poller повторит доставку при сбое — дубли возможны — потребители идемпотентны по event_id: таблица processed_events с UNIQUE(event_id) — «обработано ли уже» — первый INSERT-конфликт → пропуск); порядок событий одной сущности — в рамках партиции/очереди по aggregate_id (Kafka-партиции); очистка outbox-таблицы (публикованные строки удалять/TTL — таблица не бесконечна — cron-джоба); payload события — достаточный для реакции, без «выгребания всей БД» (event_id, тип, aggregate_id, версия схемы, ключевые поля — схема эволюционирует — контрактная дисциплина для событий тоже).

Практический сценарий: оформление заказа между тремя сервисами

Исходник: монолит разрезан (миграция) — заказы/склад/платежи с своими БД; первый же сбой платежей оставил 40 заказов «оплачено на складе, без денег» — ручная разборка день. Внедрение: (1) оркестратор-сага (таблица sagas: id, state, current_step, payload — координатор на Celery-задачах: шаг = вызов API участника, результат = переход; состояние в БД — рестарт координатора продолжает с текущего шага); шаги: ReserveStock → Charge → ConfirmOrder; компенсации: ReleaseStock, Refund; Charge с Idempotency-Key=saga_id (ретрай шага не спишет дважды); ReserveStock — идемпотентна по reservation_id (повтор команды — тот же результат — доставка at-least-once учтена с обеих сторон); (2) outbox во всех трёх сервисах (poller каждые 2 с, SKIP LOCKED; события — в Kafka по ключу order_id — порядок по заказу); потребители дедуплицируют (processed_events UNIQUE); (3) зависшие саги — под наблюдением: метрика «сага в промежуточном состоянии > 5 мин» — алерт (метрики), дашборд саг по состояниям (Grafana) — «12 саг в Compensating» видно сразу; (4) провал компенсации — эскалация: шаг Refund провалился 5 раз → сага в ManualIntervention + тикет (за месяц — 2 случая, оба — сбой провайдера возвратов, разобраны за минуты по дашборду). Проверка сценариями: kill платежей посередине (компенсация — сток отпущен, заказ cancelled — данные сошлись за 8 с), дубль доставки события (потребитель пропустил повтор — UNIQUE поймал), рестарт координатора на середине (сага продолжились с шага 2 — состояние из БД). Итог: расхождения данных — ноль за квартал, ручная разборка инцидентов сократилась до «посмотреть дашборд саг»; цена — координатор и outbox-инфраструктура в трёх сервисах (осознанная — ADR «итоговая согласованность через саги против синхронных распределённых вызовов»).

FAQ

Когда саги не нужны? Когда операция в пределах одной БД (модульный монолит с транзакцией — монорепо/монолит — обычная ACID-транзакция проще в разы); когда согласованность не важна продуктово (лайк + счётчик — сойдётся само); когда сервисов мало и операция линейна из двух шагов («создать + отправить письмо» — письмо в очереди с ретраями — сага-оркестратор избыточен); сага — инструмент цены: сложность координатора/компенсаций/мониторинга против честной распределённости — применять, когда цена расхождения данных выше цены паттерна.

Outbox против «просто публиковать в брокер с ретраями»? «Просто публиковать» рвётся ровно на границе транзакции (коммит прошёл — publish не успел — данные и события разошлись навсегда; ретраи вокруг publish не видят, был ли коммит); outbox переносит «доставку» в ту же транзакцию (событие не потеряется, пока жива БД) — цена: таблица, poller/CDC, дедупликация у потребителей; полумера «сначала publish, потом коммит» — потребители видят событие о незакоммиченном (при откате — событие-призрак) — хуже обоих вариантов.

Temporal/Kafka-стримы против самописного координатора? Фреймворки саг/оркестрации (Temporal, Restate, BPM-движки) дают готовое: состояния, ретраи, таймауты, версии, визуализацию — при 5+ сложных сагах оправданы (операционная цена кластера Temporal — реальна, но меньше самописного зоопарка); Kafka-стримы как «сага-события» (Kafka Streams/topology) — ближе к хореографии с общим журналом; самописный координатор (таблица+Celery) — для 1–3 простых линейных саг — полный контроль, ноль новой инфраструктуры; правило — начинать с простейшего честного и переходить на фреймворк, когда «самописное» стало болью.

Как тестировать саги? Юнит — шаги и компенсации по отдельности (моки участников — мок-паттерны); интеграция — сценарии провалов на стейдже с реальными участниками (kill сервиса на шаге k — проверка компенсаций — testcontainers поднимает БД/брокер); хаос-сценарии — дубли/потери сообщений/рестарты координатора (инжектируются на стейдже — game day); контрактные тесты событий (схема payload — контракты); главный критерий покрытия — «на каждый шаг провал проверен, на каждую компенсацию — её провал тоже».

Источники: microservices.io (saga, transactional outbox), документация Debezium — ссылки в шапке.