Outbox pattern на собеседовании системного аналитика

Проверь себя · 1/3разбор после ответа
Нужно получить 10 последних событий конкретного пользователя (user_id = 42) из таблицы events(user_id, event_time, event_name). Какой запрос корректнее всего решает задачу?

Зачем спрашивают про outbox

В микросервисах системному аналитику постоянно приходится проектировать один и тот же сценарий: сервис сохраняет данные в свою базу и должен сообщить об этом остальным — публикует событие в Kafka или RabbitMQ. Звучит просто, но здесь прячется классическая ловушка распределённых систем: нельзя атомарно записать сразу в два независимых хранилища. Именно проверку этого понимания интервьюер и закладывает в вопрос про outbox.

Тема всплывает почти на любом собесе SA, где есть слова «event-driven», «интеграция сервисов», «Kafka» или «согласованность данных». На junior-уровне спрашивают «что не так с тем, чтобы после коммита в БД просто отправить сообщение». На middle+ — «спроектируй надёжную доставку событий, сравни polling и CDC, объясни, что делать с дублями». Хороший ответ показывает, что вы думаете про отказы, а не только про happy path.

Проблема двойной записи

Сервису нужно сделать две вещи в рамках одного бизнес-действия:

  1. Сохранить заказ в свою базу данных.
  2. Опубликовать событие OrderCreated, чтобы другие сервисы (склад, оплата, аналитика) о нём узнали.

Наивная реализация выглядит так:

BEGIN;
INSERT INTO orders ...;
COMMIT;                          -- данные уже в БД
publish_event("OrderCreated");   -- а вот это может упасть

Транзакция в БД закоммитилась, а публикация события упала: брокер недоступен, случился таймаут или под перезапустился. В итоге заказ в базе есть, но остальные сервисы о нём не знают — данные разъехались (inconsistency).

Поменять шаги местами — «сначала опубликуй, потом сохрани» — не помогает: событие уйдёт подписчикам, а коммит в БД упадёт, и получится событие про заказ, которого не существует. Это та же проблема двойной записи, просто с другого конца. Корень в том, что БД и брокер — два независимых хранилища без общей транзакции, а распределённой транзакции (2PC) между ними в норме нет.

Outbox pattern

Идея outbox: не публиковать событие напрямую, а записать его в ту же базу и в той же транзакции, что и бизнес-данные. Для событий заводят отдельную таблицу — outbox.

BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (event_type, payload, created_at)
       VALUES ('OrderCreated', '{"order_id": 42, ...}', now());
COMMIT;

Теперь запись заказа и запись события атомарны: либо коммитятся вместе, либо вместе откатываются. Двойной записи в два хранилища больше нет — запись одна, в одну БД.

Дальше отдельный процесс (воркер или CDC-коннектор) читает таблицу outbox и публикует события в Kafka. Даже если брокер лежит, события копятся в outbox и уедут, как только он поднимется. Ничего не теряется.

Нюанс, который любят уточнять на собесе: outbox гарантирует доставку не «ровно один раз» (exactly-once), а «хотя бы один раз» (at-least-once). Событие может уехать в Kafka, а отметка «опубликовано» — не успеть записаться, и тогда при перезапуске воркера оно уйдёт повторно. Поэтому потребители обязаны быть идемпотентными (см. раздел про inbox ниже).

Реализация через polling

Простейший способ вычитывать outbox — воркер, который в цикле опрашивает таблицу:

Цикл воркера:
  SELECT * FROM outbox WHERE published = false ORDER BY created_at LIMIT 100;
  Для каждого события:
    publish_to_kafka(event);
    UPDATE outbox SET published = true WHERE id = ...;

Плюс: просто и не требует дополнительной инфраструктуры — только БД и воркер.

Минус: постоянные опросы базы создают нагрузку, а задержка доставки зависит от интервала опроса. Если опрашивать раз в 5 секунд, событие в среднем ждёт пару секунд перед отправкой.

Что стоит упомянуть на собесе как оптимизации:

  • Индекс на (published, created_at) — чтобы выборка неопубликованных строк не сканировала всю таблицу.
  • Архивация или удаление отправленных строк, иначе таблица распухает и запросы деградируют.
  • Блокировка строк через SELECT ... FOR UPDATE SKIP LOCKED, если воркеров несколько, — чтобы два воркера не забрали одни и те же строки и не отправили события дважды.
Готовишься к собесу системного аналитика?
827 вопросов: REST, UML, OAuth, ERD, требования. Тренируйся в Telegram
Тренировать SA в Telegram

Реализация через CDC

Продвинутый вариант — не опрашивать таблицу, а читать журнал изменений базы (WAL в PostgreSQL) через CDC-инструмент, обычно Debezium. Debezium ловит каждый INSERT в outbox прямо из WAL и публикует событие в Kafka без единого запроса SELECT.

Плюсы: низкая задержка (события уходят почти сразу после коммита) и нулевая нагрузка на базу от опросов — читается лог, который БД и так пишет.

Минусы: нужна инфраструктура — Debezium, Kafka Connect, настройка репликационного слота. Для маленького сервиса это оверкилл, для зрелой платформы — фактический стандарт.

У Debezium под этот паттерн есть готовый Outbox Event Router — это SMT (Single Message Transform). Он берёт строку outbox, достаёт из неё поле payload и раскладывает событие по топикам на основе поля event_type или aggregate_type. То есть писать код маршрутизации не нужно — коннектор делает это по конфигурации.

Inbox pattern и идемпотентность

Outbox решает надёжную отправку, но получатель всё равно может получить дубль (из-за at-least-once). Зеркальный паттерн на стороне потребителя — inbox:

Получили событие из Kafka.
INSERT INTO inbox (event_id, payload) ON CONFLICT (event_id) DO NOTHING;  -- дубль просто игнорируется
COMMIT.
Отдельный обработчик берёт новые записи inbox и применяет их к бизнес-логике.

Ключевой элемент — уникальный event_id: если сообщение пришло второй раз, вставка по этому ключу ничего не делает, и повторной обработки не будет. Так потребитель становится идемпотентным и защищён от дублей, которые неизбежны при at-least-once-доставке. На собесе связка «outbox на отправителе + inbox или идемпотентный ключ на получателе» — сильный, законченный ответ, потому что вы закрываете всю цепочку доставки, а не только её половину.

Частые ошибки

  • Считать, что outbox даёт exactly-once. Он даёт at-least-once. «Ровно один раз» получается только в связке с идемпотентным потребителем.
  • Забыть про рост таблицы outbox. Без архивации отправленных событий таблица распухает и тормозит выборку. Нужна регулярная чистка или партиционирование.
  • Публиковать прямо из бизнес-транзакции. «INSERT в orders, а потом в той же транзакции publish в Kafka» — это снова двойная запись: коммит БД и отправка в брокер не атомарны.
  • Несколько воркеров без блокировки. Без FOR UPDATE SKIP LOCKED или другого механизма два воркера прочитают одни и те же строки и отправят события дважды.
  • Терять порядок событий. Если порядок важен (например, события по одному заказу), нужно вычитывать и публиковать с сохранением порядка — раскладывать по ключу партиции, а не как попало.

Связанные темы

FAQ

Чем outbox отличается от «просто опубликовать после коммита»?

При публикации после коммита есть окно, в котором данные уже в БД, а событие ещё не отправлено — если отправка упадёт, событие потеряется навсегда. Outbox убирает это окно: событие пишется в ту же транзакцию, что и данные, поэтому либо есть и то и другое, либо ничего. Отправку в брокер потом гарантирует отдельный процесс с ретраями.

Outbox даёт exactly-once доставку?

Нет. Outbox гарантирует, что событие не потеряется (at-least-once), но не защищает от дублей: одно и то же событие может уехать в Kafka повторно после сбоя воркера. Exactly-once достигается только вместе с идемпотентным потребителем — через inbox или UPSERT по бизнес-ключу.

Polling или CDC — что выбрать?

Polling проще: нужен только воркер и индекс, подходит для небольших нагрузок и команд без готовой CDC-инфраструктуры. CDC (Debezium) даёт меньшую задержку и не нагружает базу опросами, но требует Kafka Connect и настройки репликации. На зрелых платформах чаще берут CDC, на старте — polling.

Зачем нужен inbox, если уже есть outbox?

Outbox работает на стороне отправителя и гарантирует, что событие уедет. Inbox работает на стороне получателя и гарантирует, что дубль не будет обработан дважды. Это разные концы одной цепочки: outbox защищает от потери, inbox — от повторной обработки.

Это официальная информация?

Нет. Статья основана на работах Chris Richardson (microservices.io) и документации Debezium. Конкретные формулировки вопросов зависят от компании, команды и уровня позиции.


Тренируйте системный анализ — откройте тренажёр с 1500+ вопросами для собесов.