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

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

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

Погрузимся в проблематику оформления заказа и схлопнем все реальности в нужную с помощью 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 бэкэнду.

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


  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), то и ключи идемпотентности отвалятся.

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

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