Статьи

В поисках exactly once. На примере order service

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

=> Отвечаем на интервью, получаем оффер!
Когда между сервисами передаются события (создан заказ, списаны деньги, зарезервирован товар), важно понимать семантику доставки — какие гарантии мы даём потребителям сообщений. На практике чаще всего обсуждают три варианта:
  • At-most-once — «не более одного раза»: сообщение может потеряться, но дубликатов не будет.
  • At-least-once — «как минимум один раз»: сообщение не потеряется, но возможны дубликаты.
  • Exactly-once — «ровно один раз»: достигается комбинацией at-least-once + идемпотентность/дедупликация на стороне обработчика.
В реальных продакшен-системах доминирует at-least-once с хорошо продуманной идемпотентной обработкой. Может реализоваться с помощью паттерна transactional outbox.

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

Сервис ордеров хранит заказы в своей БД. При создании заказа сервис в той же транзакции кладёт событие в таблицу outbox. Отдельный процесс вычитывает outbox и публикует в брокер сообщений. Другие сервисы (биллинг, склад) подписываются и обрабатывают события идемпотентно.
Ключевые элементы:
  1. Orders Service — REST/gRPC API, бизнес-логика.
  2. Orders DB — реляционная БД (например, Postgres). Таблицы: orders, outbox.
  3. Outbox Relay — компонент/воркер, который читает outbox и пишет в брокер (Kafka/Rabbit/NATS), помечая записи как доставленные.
  4. Message Broker — очереди/топики для событий домена.
  5. Consumers — Billing/Inventory и др. с идемпотентной обработкой.

Идемпотентность - что это и зачем бизнесу

Идемпотентность - это свойство операции давать один и тот же наблюдаемый результат при повторном выполнении.
В контексте событий и очередей это означает: если одно и то же событие обработается два, три и больше раз, бизнес‑состояние не поменяется сверх первого применения.
Зачем бизнесу - защита от двойного списания и двойных доставок, отсутствие «накрутки» бонусов/скидок, предсказуемость SLA при сбоях и ретраях, меньше тикетов в саппорт и выше доверие клиентов. Благодаря идемпотентности можно использовать семантику доставку at‑least‑once и всё равно получать ровно один бизнес‑эффект.

Паттерн outbox

1. Клиент вызывает POST /api/v1/orders.
2. В единой транзакции:
а) вставляется запись в orders;
б) вставляется запись в outbox (event_id, event_type, payload, occurred_at, status='NEW').
3. Транзакция коммитится → мы атомарно зафиксировали и состояние, и событие (спасибо ACID'у)
4. Outbox Relay периодически вычитывает NEW, публикует в брокер и атомарно помечает запись как SENT.
5. Потребители читают из брокера. Каждый обработчик проверяет, не обрабатывали ли уже событие.
Это даёт at-least-once между outbox и брокером, а вместе с идемпотентностью у потребителей — exactly-once effects на уровне выполнения бизнес логики.

Общая схема

Основная таблица для сервиса заказа:
CREATE TABLE orders (
 id uuid PRIMARY KEY,
 status text NOT NULL,
 total_cents 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')),
 retry_count int NOT NULL default 0,
 retry_at timestamptz,
 occurred_at timestamptz NOT NULL default now(),
 sent_at timestamptz
);

Где ломается атомарность: publish -> crash -> duplicate

Outbox обеспечивает атомарность между заказом и записью события в БД. Но между Relay и брокером единой транзакции нет. Если вычитывающий воркер опубликовал в брокер и упал до UPDATE outbox SET status='SENT', то после рестарта событие будет отправлено ещё раз. Это ожидаемая цена at-least-once.

Идемпотентность потребителей: короткий рецепт

Таблица дедупликации в своей БД (а не в БД ордеров):
CREATE TABLE processed_events (
  event_id     uuid NOT NULL,
  handler      text NOT NULL,                -- имя consumer-group/хендлера
  processed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (event_id, handler)
);
Что важно:
  • event_id генерирует продюсер при записи в outbox (не relay) и кладёт в сообщение.
  • handler чаще всего = имя consumer group (billing, inventory-metrics, …).
  • Ack/commit offset — только после коммита БД потребителя.

Выводы

  • Паттерн outbox позволяет развязать логику сохранения заказа и его обработки.
  • Между outbox и брокером - at-least-once. Поэтому дубликаты неизбежны.
  • Exactly-once достигается идемпотентностью у потребителя (таблица processed_events).

Больше информации о System Design, а также о прохождение интервью, разборе Клеппмана, проведению архитектурных кат, получение cheet sheets на моём канале, посвященному Архитектуре, System Design, Highload бэкэнду.