Я хотела не совсем этого. Точнее - совсем не этого!
Я хотела не совсем этого. Точнее - совсем не этого!

События разворачивались в нескольких реальностях. В первой заказ не пришёл. Во второй - произошло несколько списаний средств. И заказ пришёл. А может даже не один. В третьей система упала посередине процесса, оставив данные в не консистентном состоянии.

А четвертая оказалось самой интересной. Где даже с неполадками сети и падениями частей сервиса был сделан лишь один заказ. И произведена одна оплата. Благодаря чему пришла желаемая теплая пицца. Которую хотел наш дорогой покупатель.

Погрузимся в проблематику оформления заказа и схлопнем все реальности в нужную с помощью Outbox Pattern.

Outbox pattern за 30 секунд

Базовая архитектура

Клик для увеличения.
Клик для увеличения.

Раскроем сервис заказа пиццы:

  1. Orders Service — содержит основную бизнес-логику по обработке заказа.

  2. Orders DB — реляционная СУБД. Имеет таблицы: orders, outbox.

  3. Outbox Relay/worker — компонент, который читает таблицу outbox и пишет в брокер, помечая записи как доставленные.

  4. Message Broker — наш middleware с очередями/топиками для передачи событий.

  5. Consumers — Billing/Inventory и др. с идемпотентной обработкой.

Базовая логика применения паттерна:

Сервис ордеров хранит заказы в своей БД. При создании заказа в той же транзакции кладёт событие в таблицу outbox. Отдельный процесс вычитывает outbox и публикует в брокер сообщений. Другие сервисы (биллинг, склад) подписываются и обрабатывают события.

Проблематика

Зачем всё это нужно?
Зачем всё это нужно?

Проблема в том, что order service взаимодействуя с окружением может пойти лишь по одному из двух путей:

  1. Сначала сохранить ордер в БД и затем оповестить другой сервис/создать событие.

  2. Сначала оповестить другой сервис, затем сохранить ордер в БД

И в каждом из сценариев где-то посередине сервис может упасть не сделав очередное действие.

Всё везде и сразу не получится!
Всё везде и сразу не получится!

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

Семантика доставки в распределённых системах

А как оно работает внутри?
А как оно работает внутри?

События

Можно заметить, что мы ввели понятие “события“. При проектирование системы можно выбрать нужную архитектуру в зависимости от требований. К примеру, монолит - когда основная логика выполняется в одном процессе. Или же микросервисная архитектура с синхронным взаимодействием - все друг друга ждут.

Outbox pattern живёт в рамках Event Driven Architecture(EDA) - архитектуры, построенной на основе обмена событий.

Примеры:

  1. Создан заказ

  2. Списаны деньги

  3. Зарезервирован товар

Семантика. Теория

Далее нам важно понимать семантику доставки. Она выражает гарантии, которые мы даём потребителям сообщений. На практике чаще всего обсуждают три варианта:

  • At-most-once — «не более одного раза». Событие может потеряться, но дубликатов не будет.

  • At-least-once — «как минимум один раз». Событие не потеряется, но возможны дубликаты.

  • Exactly-once — «ровно один раз». Достигается комбинацией at-least-once + идемпотентность/дедупликация на стороне обработчика.

Семантика. Практика

Мы не хотим терять сообщения. Конечно же, наши сервисы имеют хорошие показатели доступности. Это известные всем и бесполезные с практической точки зрения на интервью 9ки. И сеть почти никогда не рвется. Но именно доли процента отказов на высоких нагрузках будут портить жизнь нашим дорогим пользователям. Больше нагрузка → больше потерь.

Поэтому как и в других аспектах System Design для обеспечения лучших гарантий мы вводим избыточность. Выбираем at-least-once. Да, могут быть дубли. Но мы с ними справимся благодаря идемпотентной обработке.

Паттерн outbox подробней

Классическое изображение паттерна с microservices.io
Классическое изображение паттерна с microservices.io

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:

  1. Вычитывает новые записи

  2. Публикует в брокер

  3. Переводит запись в статус “обработано“

Он может упасть после публикации в брокер. Поднимется. И снова вычитает туже вроде бы не обработанную запись. Снова перешлёт. И, наконец, переведёт ей в статус “обработано“. Мы пошли на это сознательно. Допускаем такие дубликаты в брокер.

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 и повышение отказоустойчивости системы.

Теперь вы знаете как правильно спроектировать распределенную систему с использованием outbox pattern!
Теперь вы знаете как правильно спроектировать распределенную систему с использованием outbox pattern!

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

Комментарии (12)


  1. PaulIsh
    01.11.2025 10:40

    Постоянно дрюкать огромную таблицу в базе на предмет наличия ключа идемпотентности будет сильно тормозить брокер. Если складывать в redis set или lru-кэш в памяти, то их память будет пухнуть под высокой нагрузкой. Нет ли каких-то готовых подходов к реализации идемпотентности?

    Например, если у вас есть какое-то публичное api, то можно сделать метод генерации ключа и использовать завязанную на timestamp генерацию (uuid v7 или что-то свое). В этом случае за старым ключом можно сходить в базу, а за свежим в redis. Но это первое что пришло в голову, может кто-то уже погружался в тему и знает типовые промышленные подходы?


    1. Jsty
      01.11.2025 10:40

      Если складывать в redis set или lru-кэш в памяти, то их память будет пухнуть под высокой нагрузкой

      С правильным retention policy и ttl - не будет.

      Но если redis упадет с потерей данных за последнюю секунду (параметр appendfsync everysec), то и ключи идемпотентности отвалятся.

      Отдельная партиционированная по времени (например, каждый час) таблица для ключей более чем достаточна для большинства юзкейсов, если нужен суррогатный ключ. Старые партиции просто удалять.

      Либо ключ должен быть частью данных как в случае с платежами и храниться вместе с данными.


    1. avovana7 Автор
      01.11.2025 10:40

      Ещё интересный кейс, если поразмышлять дальше - когда потребитель проверил, что ключ не обработан. Далее у него выбор - отослать, к примеру, в платежный шлюз и потом сохранить в базу. Или наоборот. И снова у нас история с семантикой доставки) И обработкой идемпотентности уже у внешней системы.


      1. farafonoff
        01.11.2025 10:40

        Платежный шлюз (типа stripe) сам имеет ключи идемпотентности, их нужно сохранять в сообщение, тогда и ретрай не вызовет дубликатов


  1. tkutru
    01.11.2025 10:40

    Вот это спрашивать на интервью? Кажется, мы где-то свернули не туда...


  1. Prepod21
    01.11.2025 10:40

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


    1. Spryka
      01.11.2025 10:40

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


    1. avovana7 Автор
      01.11.2025 10:40

      Prepod21, Дополню Spryka со своей стороны.

      Здесь, скорее, случай больших распределенных систем. И этот кейс рассматривается в их контексте.

      В случае монолита всё проще. Один процесс. Одна транзакция. И в монолите много логики по управлению складом, доставкой, ... . Нужно уметь хранить состояние такой многофакторной обработки. Считаем, что нет очередей.

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

      Поэтому, советую такой пайплайн прохождения:

      1. Уточнение требований

      2. Построение апи

      3. Проектирование простейшей схемы с монолитом-first

        а) И лишь затем переход к микросервисной архитектуре с обоснованием

        б) И лишь затем накручивание таких паттернов, которые решают возникшие вызовы


  1. Prepod21
    01.11.2025 10:40

    Outbox relay постоянно опрашивает таблицу с определенной периодичностью. Верно?

    Почему-то не могу избавится от ощущения костыльности этого решения.


    1. avovana7 Автор
      01.11.2025 10:40

      И это хорошо!

      Потому что System Design - это всегда трейдофф.
      Мы перешли с монолита в микросервисы для масштабирования нашей системы. Смогли обслуживать высокие нагрузки. Вместе с этим, у нас возникли проблемы/challenges/вызовы. К примеру, возможная неконсистентность данных. Появилась нужда в распределенных транзакциях. В продумывании особенностей коммуникации между сервисами.

      Придумали такое решение. Оно решило проблему. Как-то. У него есть свои преимущество и недостатки. Это нормально. И хорошо, что вы их чувствуете, замечаете.

      Да, опрашивает таблицу с определенной периодичностью. Какие здесь есть недостатки?


  1. RouR
    01.11.2025 10:40

    Отлично! А то я наблюдаю аналитиков которые думают что добавят кафку и она сама по себе решит проблемы гарантированной доставки.

    Осталось подумать над следующим шагом, а всегда ли нужен ли брокер в этой схеме?


    1. avovana7 Автор
      01.11.2025 10:40

      RouR, мне страшно, когда у аналитиков спрашивают размер топиков в кафке. Недавно видел в сборнике интересных вопросов на аналитическом канале. Думаю, это too much.

      Отличный вопрос про брокер. В классике идут вместе. Я так понимаю, подразумевается отказ от брокера в сторону прокачки воркера в полноценный http сервис, который отсылает сообщение другим сервисам?