Перейти к содержанию
Бэкенд и системы

Паттерн Saga и Transactional Outbox: согласованность данных в микросервисах

Сервис оплаты списал деньги, а сервис заказов упал, не успев сохранить результат, — данные двух баз разошлись. Паттерн Saga отвечает, как довести такую бизнес-операцию до конца или откатить её компенсациями. Transactional Outbox решает соседнюю задачу: событие уходит в брокер тогда и только тогда, когда закоммичена локальная транзакция. Разберём оба паттерна, их связь и запустим минимальный outbox на PostgreSQL 17.9 с двумя конкурирующими relay через FOR UPDATE SKIP LOCKED.

Автор: Опубликовано

7 мин чтенияРедакционный разборОбновлено
  • Saga
  • Transactional Outbox
  • микросервисы
  • PostgreSQL
  • Kafka
Главная мысль

Saga — последовательность локальных транзакций с компенсациями вместо общего отката, outbox — способ атомарно связать изменение данных и событие одной локальной транзакцией. Доставка при этом at-least-once, поэтому каждый потребитель и каждая компенсация обязаны быть идемпотентными.

Почему не одна распределённая транзакция

При подходе database per service каждая служба владеет своей базой, и операция «оформить заказ» затрагивает несколько баз сразу. Классический ответ — двухфазный коммит: координатор просит участников подготовиться, затем фиксирует. На практике этот путь часто закрыт: брокер или чужой сервис может не поддерживать 2PC, а подготовленные участники держат блокировки, пока координатор не примет решение. Страницы microservices.io о saga и outbox прямо исходят из того, что 2PC — не вариант.

Документация PostgreSQL о PREPARE TRANSACTION подтверждает цену: команда предназначена не для приложений, а для внешнего менеджера транзакций; подготовленная транзакция сохраняет блокировки и, если висит долго, мешает VACUUM. По умолчанию max_prepared_transactions = 0, то есть функция выключена. Вместо глобальной атомарности микросервисы принимают итоговую согласованность, но делают её управляемой: явные шаги, явные компенсации и надёжная доставка событий.

Saga: локальные транзакции и компенсации

Термин появился в статье Гарсиа-Молины и Салем 1987 года о долгих транзакциях: долгую транзакцию можно считать сагой, если её удаётся записать как последовательность обычных транзакций, которые чередуются с другими. Система гарантирует, что либо выполнятся все шаги, либо для частично выполненной саги запустятся компенсирующие транзакции. В микросервисах каждый шаг — локальная транзакция одного сервиса, а при отказе выполняются компенсации уже завершённых шагов.

Пример: T1 — создать заказ в статусе PENDING, T2 — зарезервировать товар, T3 — списать оплату, T4 — подтвердить заказ. Если T3 отказала, выполняются C2 — снять резерв — и C1 — отменить заказ. Компенсация семантическая, а не физический откат: деньги возвращают отдельной операцией, отправленное письмо не отзовёшь. Компенсация не нужна шагу, за которым идут только шаги, не способные отказать, и последнему шагу — так разбирает пример Крис Ричардсон.

Схема саги заказа: сервис заказов пишет заказ и событие в outbox, relay публикует его в Kafka, сервис оплаты отвечает отказом, и сервис заказов отменяет заказ
Каждый переход между сервисами идёт через outbox и брокер, поэтому повторная доставка ожидаема. Красная ветка — компенсация: заказ не удаляют, а переводят в CANCELLED. Схема RecallDeck

Оркестрация или хореография

При хореографии каждый сервис публикует доменные события, а другие реагируют на них своими локальными транзакциями: заказы публикуют OrderCreated, оплата отвечает PaymentCompleted или PaymentFailed. При оркестрации центральный оркестратор говорит участникам, какую транзакцию выполнить, и хранит состояние саги. Оба варианта описаны на microservices.io; выбор зависит от числа шагов и от того, где вы хотите видеть процесс целиком.

Практическое правило: если для ответа на вопрос «на каком шаге застрял заказ 101» приходится собирать логи пяти сервисов, хореография уже переросла себя. Оркестратор не обязан быть отдельным продуктом: это может быть таблица состояний саги и обработчик, который реагирует на ответы участников и сам пишет команды через outbox.

Хореография и оркестрация
КритерийХореографияОркестрация
Кто выбирает следующий шагКаждый сервис по событиямОркестратор
Где состояние сагиРаспределено по сервисамВ одном месте
СвязностьСервисы знают чужие событияУчастники знают свои команды
Удобно, когдаДва-три шага без ветвленийМного шагов, таймауты, ветвления
Типичный рискЦиклы событий, трудная отладкаОркестратор обрастает логикой

Изоляции нет: семантическая блокировка и идемпотентность

Saga теряет букву I из ACID: промежуточные результаты шагов видны другим транзакциям. Пока заказ ждёт оплату, другая операция может прочитать резерв товара, который через секунду снимут. Ричардсон называет защитные приёмы countermeasures. Самый частый — семантическая блокировка: заказ создаётся в статусе PENDING, и другие операции по этому статусу понимают, что запись участвует в незавершённой саге, — ждут, отказываются или действуют осторожно.

Второе обязательное свойство — идемпотентность. Relay и брокер доставляют сообщения как минимум один раз, поэтому любой шаг и любая компенсация могут прийти повторно, а возврат денег, выполненный дважды, — уже инцидент. Потребитель сохраняет идентификатор обработанного события в той же локальной транзакции, что и бизнес-эффект, например в таблице processed_events с первичным ключом event_id: повтор упирается в конфликт ключа и ничего не меняет.

  • Статус PENDING — сигнал для других операций, а не просто метка для интерфейса.
  • Компенсация — тоже шаг саги: её повторяют до успеха и делают идемпотентной.
  • Порядок важен: отмена заказа не должна обогнать его создание у потребителя.

Dual write и transactional outbox на PostgreSQL

Наивный код делает COMMIT в базе, а затем producer.send(). Если процесс упадёт между ними, заказ есть, а события нет. Если поменять порядок, событие уйдёт, а транзакция может откатиться — потребители узнают о заказе, которого нет. Outbox убирает вторую систему из транзакции: событие записывается в таблицу outbox той же локальной транзакцией, что и заказ, а отдельный relay публикует его позже. По формулировке microservices.io, сообщение отправляется тогда и только тогда, когда транзакция закоммичена.

Скрипт выполнен в PostgreSQL 17.9: заказ 101 и событие OrderCreated появились вместе. Затем я повторил транзакцию для заказа 102 с payload = NULL: вторая вставка нарушила NOT NULL, COMMIT превратился в ROLLBACK, и подсчёт показал 1 заказ и 1 событие — ни заказа 102, ни его события. Неудачная вставка всё же израсходовала значение bigserial 2, и следующие события получили id 3 и 4. Документация предупреждает, что nextval не откатывается, поэтому relay не должен полагаться на непрерывность id.

CREATE TABLE orders (
  id     bigint  PRIMARY KEY,
  status text    NOT NULL,
  total  numeric NOT NULL
);
CREATE TABLE outbox (
  id           bigserial   PRIMARY KEY,
  aggregate_id bigint      NOT NULL,
  event_type   text        NOT NULL,
  payload      jsonb       NOT NULL,
  published_at timestamptz
);

-- бизнес-изменение и событие: одна локальная транзакция
BEGIN;
INSERT INTO orders VALUES (101, 'PENDING', 2500);
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES (101, 'OrderCreated', '{"orderId": 101, "total": 2500}');
COMMIT;

Relay: SKIP LOCKED, дубликаты и порядок

Relay — цикл опроса. В эксперименте в outbox ждали три события: id 1, 3 и 4. Первый relay выполнил запрос ниже и вместо отправки в Kafka держал транзакцию через pg_sleep(3). Второй relay с тем же запросом, запущенный через секунду, сразу получил только id 4: строки 1 и 3 были заблокированы и пропущены. После COMMIT первого relay события 1 и 3 отмечены опубликованными, а 4 осталось ждать — второй в эксперименте ничего не публиковал. Документация предупреждает, что SKIP LOCKED даёт несогласованную картину данных и не годится для запросов общего назначения, но подходит для таблицы-очереди с несколькими потребителями.

Если relay упадёт после подтверждения Kafka, но до COMMIT, у строк останется published_at IS NULL, и следующая итерация отправит их повторно — отсюда at-least-once и дедупликация у потребителя. Параллельные relay ускоряют выдачу, но могут переставить события одного заказа. Если порядок по агрегату важен, раздавайте relay непересекающиеся наборы aggregate_id и публикуйте с ключом order_id: Kafka сохраняет порядок внутри партиции. Альтернатива опросу — CDC: Outbox Event Router в Debezium по умолчанию делает aggregateid ключом сообщения и кладёт id события в заголовок, чтобы потребители отбрасывали дубликаты. Строки outbox при этом только вставляют: обновлять их документация Debezium не разрешает, так что колонка published_at не нужна.

-- relay: одна итерация опроса
BEGIN;
SELECT id, aggregate_id FROM outbox
WHERE published_at IS NULL
ORDER BY id LIMIT 2
FOR UPDATE SKIP LOCKED;
-- ... отправить в Kafka и дождаться подтверждения брокера ...
UPDATE outbox SET published_at = now() WHERE id IN (1, 3);
COMMIT;

Как отвечать на собеседовании и что повторить

Сильный ответ идёт по шагам: почему не 2PC; saga как цепочка локальных транзакций с компенсациями; оркестрация или хореография и почему; как событие надёжно покидает сервис — outbox и relay или CDC; какая гарантия доставки получается и как потребитель обеспечивает идемпотентность; чем закрыть отсутствие изоляции. Частая ошибка — сказать «отправим событие после коммита» или «Kafka даёт exactly-once», не назвав границы этой гарантии.

Для практики возьмите бронирование отеля и билета: выпишите шаги, компенсации и шаг, после которого отменять уже нельзя. Затем проверьте, что каждое сообщение в схеме можно безопасно обработать дважды.

Коротко

Частые вопросы

Transactional outbox заменяет saga?

Нет, они решают разные задачи. Saga описывает бизнес-процесс из нескольких локальных транзакций и компенсаций, outbox — механизм надёжной публикации события из одной транзакции. В типичной хореографической саге каждый шаг использует outbox, чтобы переход к следующему шагу не потерялся.

Нужно ли удалять строки из outbox?

Да, иначе таблица растёт бесконечно. Опубликованные строки удаляют фоновой задачей по published_at; при больших объёмах outbox удобно партиционировать по времени и удалять старые партиции целиком. При CDC удаление не порождает лишних событий: Outbox Event Router в Debezium отфильтровывает операции DELETE.

Можно ли публиковать из приложения сразу после коммита, без relay?

Как оптимизацию — да, вместо relay — нет: если процесс упадёт после коммита, событие останется только в outbox. Обычно приложение пробует отправить сразу, а relay подбирает всё неопубликованное. Дубликаты при этом возможны, поэтому идемпотентность потребителя обязательна в любом варианте.

Чем компенсация отличается от отката?

Откат отменяет незафиксированные изменения одной транзакции. Компенсация — новая транзакция, которая семантически отменяет уже закоммиченный шаг: возврат денег, снятие резерва, статус CANCELLED. Следы исходного шага остаются в истории, а сама компенсация может не удаться и требует повторов.

Источники

Источники и редакционная политика

Сведения проверены 7 ноября 2026 г. Ссылки рядом с разделами указывают источники фактов и технических объяснений. Выводы, учебные сценарии и рекомендации по подготовке — редакционная работа RecallDeck.

От чтения к воспроизведению

Отрепетируйте полный цикл интервью.

RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.

Начать подготовку

Продолжить подготовку