
События разворачивались в нескольких реальностях. В первой заказ не пришёл. Во второй - произошло несколько списаний средств. И заказ пришёл. А может даже не один. В третьей система упала посередине процесса, оставив данные в не консистентном состоянии.
А четвертая оказалось самой интересной. Где даже с неполадками сети и падениями частей сервиса был сделан лишь один заказ. И произведена одна оплата. Благодаря чему пришла желаемая теплая пицца. Которую хотел наш дорогой покупатель.
Погрузимся в проблематику оформления заказа и схлопнем все реальности в нужную с помощью Outbox Pattern.
Outbox pattern за 30 секунд
Базовая архитектура

Раскроем сервис заказа пиццы:
Orders Service — содержит основную бизнес-логику по обработке заказа.
Orders DB — реляционная СУБД. Имеет таблицы: orders, outbox.
Outbox Relay/worker — компонент, который читает таблицу outbox и пишет в брокер, помечая записи как доставленные.
Message Broker — наш middleware с очередями/топиками для передачи событий.
Consumers — Billing/Inventory и др. с идемпотентной обработкой.
Базовая логика применения паттерна:
Сервис ордеров хранит заказы в своей БД. При создании заказа в той же транзакции кладёт событие в таблицу outbox. Отдельный процесс вычитывает outbox и публикует в брокер сообщений. Другие сервисы (биллинг, склад) подписываются и обрабатывают события.
Проблематика

Проблема в том, что order service взаимодействуя с окружением может пойти лишь по одному из двух путей:
Сначала сохранить ордер в БД и затем оповестить другой сервис/создать событие.
Сначала оповестить другой сервис, затем сохранить ордер в БД
И в каждом из сценариев где-то посередине сервис может упасть не сделав очередное действие.

Поэтому мы и расшиваем логику в отдельные сущности.
Семантика доставки в распределённых системах

События
Можно заметить, что мы ввели понятие “события“. При проектирование системы можно выбрать нужную архитектуру в зависимости от требований. К примеру, монолит - когда основная логика выполняется в одном процессе. Или же микросервисная архитектура с синхронным взаимодействием - все друг друга ждут.
Outbox pattern живёт в рамках Event Driven Architecture(EDA) - архитектуры, построенной на основе обмена событий.
Примеры:
Создан заказ
Списаны деньги
Зарезервирован товар
Семантика. Теория
Далее нам важно понимать семантику доставки. Она выражает гарантии, которые мы даём потребителям сообщений. На практике чаще всего обсуждают три варианта:
At-most-once — «не более одного раза». Событие может потеряться, но дубликатов не будет.
At-least-once — «как минимум один раз». Событие не потеряется, но возможны дубликаты.
Exactly-once — «ровно один раз». Достигается комбинацией at-least-once + идемпотентность/дедупликация на стороне обработчика.
Семантика. Практика
Мы не хотим терять сообщения. Конечно же, наши сервисы имеют хорошие показатели доступности. Это известные всем и бесполезные с практической точки зрения на интервью 9ки. И сеть почти никогда не рвется. Но именно доли процента отказов на высоких нагрузках будут портить жизнь нашим дорогим пользователям. Больше нагрузка → больше потерь.
Поэтому как и в других аспектах System Design для обеспечения лучших гарантий мы вводим избыточность. Выбираем at-least-once. Да, могут быть дубли. Но мы с ними справимся благодаря идемпотентной обработке.
Паттерн outbox подробней

1. Клиент вызывает POST /api/v1/orders.
2. В единой транзакции:
а) вставляется запись в orders;
б) вставляется запись в outbox (event_id, event_type, payload, occurred_at, status='NEW').
3. Транзакция коммитится → мы атомарно зафиксировали и состояние, и запись/событие. Cпасибо ACID'у(а конкретно какой букве? :) ). На этом наш order service кланяется и может падать сколько хочет. Главное сделано - зафиксирован ордер и создана запись. Что будет дальше - не его дело.
4. Outbox Relay:
Вычитывает новые записи
Публикует в брокер
Переводит запись в статус “обработано“
Он может упасть после публикации в брокер. Поднимется. И снова вычитает туже вроде бы не обработанную запись. Снова перешлёт. И, наконец, переведёт ей в статус “обработано“. Мы пошли на это сознательно. Допускаем такие дубликаты в брокер.

5. Потребители читают из брокера. Каждый обработчик проверяет, не обрабатывали ли уже событие.
Это даёт at-least-once между outbox и брокером, а вместе с идемпотентностью у потребителей — exactly-once effects на уровне выполнения бизнес логики.
Общая схема


Основная таблица для сервиса заказа:
CREATE TABLE orders (
id uuid PRIMARY KEY,
status text NOT NULL,
amount bigint NOT NULL,
created_at timestamptz NOT NULL default now()
);
CREATE TABLE outbox (
event_id uuid PRIMARY KEY,
event_type text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL CHECK (status IN ('NEW','SENT','ERROR')),
occurred_at timestamptz NOT NULL default now(),
sent_at timestamptz
);
Идемпотентность - что это и зачем бизнесу
Идемпотентность
Это свойство операции давать один и тот же наблюдаемый результат при повторном выполнении.
В контексте событий и очередей это означает: если одно и то же событие обработается два, три и больше раз, бизнес‑состояние не поменяется сверх первого применения.
Зачем бизнесу
Защита от двойного списания и двойных доставок, отсутствие «накрутки» бонусов/скидок, предсказуемость SLA при сбоях и ретраях, меньше тикетов в саппорт и выше доверие клиентов. Благодаря идемпотентности можно использовать семантику доставки at‑least‑once и всё равно получать ровно один бизнес‑эффект.
Идемпотентность потребителей: короткий рецепт
Таблица дедупликации в своей БД (а не в БД ордеров):
CREATE TABLE processed_events (
event_id uuid NOT NULL,
handler text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (event_id, handler)
);
Что важно:
event_id генерирует продюсер при записи в outbox и кладёт в сообщение.
handler чаще всего = billing, inventory-metrics, … (имя consumer group при использование кафки).
Ack/commit offset — только после коммита БД потребителя.
Выводы

Паттерн outbox позволяет развязать логику сохранения заказа и его обработки.
Между outbox и брокером - at-least-once. Поэтому дубликаты неизбежны.
Exactly-once достигается идемпотентностью у потребителя.
На System Design Интервью вы дойдёте до использования паттерна после описание основной схемы. В финальных минутах, на которых зайдёт речь про выявление bottlenecks и повышение отказоустойчивости системы.

Приветствую! Меня зовут Невзоров Владимир. Подробней о нашей исследовательнице сервиса заказа на моём канале System Design World, посвященному Архитектуре, System Design, Highload бэкэнду.
Комментарии (12)

Prepod21
01.11.2025 10:40Я правильно понял ,что это способ решить проблему, которая возможна лишь в микросервисной архитектуре, ведь в случае монолита мы все этапы пишем в рамках одной транзакции и описанных проблем нет в принципе?

Spryka
01.11.2025 10:40Неправильно. Проблема возникает всегда при отправке в очереди. Ты либо в бд транзакцию а потом пытаешься отправить в очередь сообщение (может не случится), либо сначала отправляешь сообщение а потом пытаешься закомитить транзакцию

avovana7 Автор
01.11.2025 10:40Prepod21, Дополню Spryka со своей стороны.
Здесь, скорее, случай больших распределенных систем. И этот кейс рассматривается в их контексте.
В случае монолита всё проще. Один процесс. Одна транзакция. И в монолите много логики по управлению складом, доставкой, ... . Нужно уметь хранить состояние такой многофакторной обработки. Считаем, что нет очередей.
В первом приближение, в монолите проще. По крайней мере, так можно обыграть на самом интервью. Глубже - трейдоффы и холивары в сообществах, на конференциях. На интервью не получится туда уйти с головой.
Поэтому, советую такой пайплайн прохождения:
Уточнение требований
Построение апи
-
Проектирование простейшей схемы с монолитом-first
а) И лишь затем переход к микросервисной архитектуре с обоснованием
б) И лишь затем накручивание таких паттернов, которые решают возникшие вызовы

Prepod21
01.11.2025 10:40Outbox relay постоянно опрашивает таблицу с определенной периодичностью. Верно?
Почему-то не могу избавится от ощущения костыльности этого решения.

avovana7 Автор
01.11.2025 10:40И это хорошо!
Потому что System Design - это всегда трейдофф.
Мы перешли с монолита в микросервисы для масштабирования нашей системы. Смогли обслуживать высокие нагрузки. Вместе с этим, у нас возникли проблемы/challenges/вызовы. К примеру, возможная неконсистентность данных. Появилась нужда в распределенных транзакциях. В продумывании особенностей коммуникации между сервисами.Придумали такое решение. Оно решило проблему. Как-то. У него есть свои преимущество и недостатки. Это нормально. И хорошо, что вы их чувствуете, замечаете.
Да, опрашивает таблицу с определенной периодичностью. Какие здесь есть недостатки?

RouR
01.11.2025 10:40Отлично! А то я наблюдаю аналитиков которые думают что добавят кафку и она сама по себе решит проблемы гарантированной доставки.
Осталось подумать над следующим шагом, а всегда ли нужен ли брокер в этой схеме?

avovana7 Автор
01.11.2025 10:40RouR, мне страшно, когда у аналитиков спрашивают размер топиков в кафке. Недавно видел в сборнике интересных вопросов на аналитическом канале. Думаю, это too much.
Отличный вопрос про брокер. В классике идут вместе. Я так понимаю, подразумевается отказ от брокера в сторону прокачки воркера в полноценный http сервис, который отсылает сообщение другим сервисам?
PaulIsh
Постоянно дрюкать огромную таблицу в базе на предмет наличия ключа идемпотентности будет сильно тормозить брокер. Если складывать в redis set или lru-кэш в памяти, то их память будет пухнуть под высокой нагрузкой. Нет ли каких-то готовых подходов к реализации идемпотентности?
Например, если у вас есть какое-то публичное api, то можно сделать метод генерации ключа и использовать завязанную на timestamp генерацию (uuid v7 или что-то свое). В этом случае за старым ключом можно сходить в базу, а за свежим в redis. Но это первое что пришло в голову, может кто-то уже погружался в тему и знает типовые промышленные подходы?
Jsty
С правильным retention policy и ttl - не будет.
Но если redis упадет с потерей данных за последнюю секунду (параметр appendfsync everysec), то и ключи идемпотентности отвалятся.
Отдельная партиционированная по времени (например, каждый час) таблица для ключей более чем достаточна для большинства юзкейсов, если нужен суррогатный ключ. Старые партиции просто удалять.
Либо ключ должен быть частью данных как в случае с платежами и храниться вместе с данными.
avovana7 Автор
Ещё интересный кейс, если поразмышлять дальше - когда потребитель проверил, что ключ не обработан. Далее у него выбор - отослать, к примеру, в платежный шлюз и потом сохранить в базу. Или наоборот. И снова у нас история с семантикой доставки) И обработкой идемпотентности уже у внешней системы.
farafonoff
Платежный шлюз (типа stripe) сам имеет ключи идемпотентности, их нужно сохранять в сообщение, тогда и ретрай не вызовет дубликатов