Семантика доставки в распределённых системах: на примере сервиса ордеров и паттерна Outbox
=> Отвечаем на интервью, получаем оффер!
Когда между сервисами передаются события (создан заказ, списаны деньги, зарезервирован товар), важно понимать семантику доставки — какие гарантии мы даём потребителям сообщений. На практике чаще всего обсуждают три варианта:
- At-most-once — «не более одного раза»: сообщение может потеряться, но дубликатов не будет.
- At-least-once — «как минимум один раз»: сообщение не потеряется, но возможны дубликаты.
- Exactly-once — «ровно один раз»: достигается комбинацией at-least-once + идемпотентность/дедупликация на стороне обработчика.
В реальных продакшен-системах доминирует at-least-once с хорошо продуманной идемпотентной обработкой. Может реализоваться с помощью паттерна transactional outbox.
Базовая архитектура
Сервис ордеров хранит заказы в своей БД. При создании заказа сервис в той же транзакции кладёт событие в таблицу outbox. Отдельный процесс вычитывает outbox и публикует в брокер сообщений. Другие сервисы (биллинг, склад) подписываются и обрабатывают события идемпотентно.
Ключевые элементы:
- Orders Service — REST/gRPC API, бизнес-логика.
- Orders DB — реляционная БД (например, Postgres). Таблицы: orders, outbox.
- Outbox Relay — компонент/воркер, который читает outbox и пишет в брокер (Kafka/Rabbit/NATS), помечая записи как доставленные.
- Message Broker — очереди/топики для событий домена.
- Consumers — Billing/Inventory и др. с идемпотентной обработкой.
Идемпотентность - что это и зачем бизнесу
Идемпотентность - это свойство операции давать один и тот же наблюдаемый результат при повторном выполнении.
В контексте событий и очередей это означает: если одно и то же событие обработается два, три и больше раз, бизнес‑состояние не поменяется сверх первого применения.
Зачем бизнесу - защита от двойного списания и двойных доставок, отсутствие «накрутки» бонусов/скидок, предсказуемость SLA при сбоях и ретраях, меньше тикетов в саппорт и выше доверие клиентов. Благодаря идемпотентности можно использовать семантику доставку at‑least‑once и всё равно получать ровно один бизнес‑эффект.
В контексте событий и очередей это означает: если одно и то же событие обработается два, три и больше раз, бизнес‑состояние не поменяется сверх первого применения.
Зачем бизнесу - защита от двойного списания и двойных доставок, отсутствие «накрутки» бонусов/скидок, предсказуемость SLA при сбоях и ретраях, меньше тикетов в саппорт и выше доверие клиентов. Благодаря идемпотентности можно использовать семантику доставку at‑least‑once и всё равно получать ровно один бизнес‑эффект.
Паттерн outbox
1. Клиент вызывает POST /api/v1/orders.
2. В единой транзакции:
2. В единой транзакции:
а) вставляется запись в orders;
б) вставляется запись в outbox (event_id, event_type, payload, occurred_at, status='NEW').
б) вставляется запись в outbox (event_id, event_type, payload, occurred_at, status='NEW').
3. Транзакция коммитится → мы атомарно зафиксировали и состояние, и событие (спасибо ACID'у)
4. Outbox Relay периодически вычитывает NEW, публикует в брокер и атомарно помечает запись как SENT.
5. Потребители читают из брокера. Каждый обработчик проверяет, не обрабатывали ли уже событие.
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 бэкэнду.
Зайти - System Design World.