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

Проверь себя · 1/3разбор после ответа
В таблице пользователей есть колонка middle_name, в которой часто хранится NULL. Что вернёт выражение COUNT(middle_name)?

Зачем нужен inbox

Inbox pattern — зеркальное отражение outbox: если outbox живёт на стороне отправителя и решает надёжную доставку, то inbox живёт на стороне получателя и решает надёжную обработку. Это один из тех паттернов, который на собесе системного аналитика отделяет тех, кто «читал про Kafka», от тех, кто понимает, что происходит с сообщением после доставки.

Проблема, которую он закрывает, — дубликаты. Практически все брокеры (Kafka, RabbitMQ, SQS) дают гарантию at-least-once: сообщение доставится хотя бы один раз, но может прийти и дважды. Так бывает, когда потребитель обработал событие, но упал до того, как подтвердил offset, — после перезапуска брокер пришлёт то же сообщение снова. Если обработчик не готов к этому, он спишет деньги дважды, отправит два письма или создаст два заказа.

Решение — сохранять входящее событие в таблицу inbox и обрабатывать каждое ровно один раз. Первая доставка записывается и обрабатывается, все последующие с тем же идентификатором просто игнорируются. Получатель становится идемпотентным: сколько бы раз ни пришло одно и то же событие, эффект будет как от одной обработки.

Реализация

Таблица inbox хранит входящие события, а фоновый обработчик разбирает их порциями:

CREATE TABLE inbox (
  event_id     TEXT PRIMARY KEY,        -- уникальный ID события от отправителя
  payload      JSON,                    -- тело события
  received_at  TIMESTAMP DEFAULT NOW(),
  processed    BOOLEAN DEFAULT FALSE,    -- флаг: обработано или нет
  processed_at TIMESTAMP
);

-- при получении события: вставляем, дубликаты молча игнорируем
INSERT INTO inbox (event_id, payload) VALUES (...) ON CONFLICT DO NOTHING;
COMMIT;

-- обработчик забирает необработанные строки, не мешая другим воркерам
SELECT * FROM inbox WHERE NOT processed
FOR UPDATE SKIP LOCKED LIMIT 100;
-- ...выполняем бизнес-логику...
UPDATE inbox SET processed = TRUE, processed_at = NOW() WHERE event_id = ...;

Ключевая деталь — FOR UPDATE SKIP LOCKED: она позволяет запустить несколько обработчиков параллельно, и каждый берёт свою пачку строк, не блокируя остальных и не хватая одну и ту же строку дважды.

Идемпотентность

Идемпотентность здесь достигается на двух уровнях.

  • Дедупликация на вставке. event_id объявлен первичным ключом, а ON CONFLICT DO NOTHING означает, что повторная вставка того же события просто ничего не делает. Дубликат отсекается ещё до бизнес-логики.
  • Обработка ровно один раз. Обработчик блокирует строку, выполняет логику и ставит processed = true. Пока строка залочена, другой воркер её не подхватит, поэтому дважды одно событие не обработается.

Важный нюанс — атомарность. Результат обработки (например, запись в основную таблицу или публикация нового события) и установку флага processed = true нужно коммитить в одной транзакции. Иначе возможна щель: обработали, но не пометили — и после перезапуска обработаем снова. Если же побочный эффект уходит во внешнюю систему, которую нельзя включить в ту же транзакцию, её тоже делают идемпотентной (UPSERT по бизнес-ключу).

Порядок обработки

Если для домена важен порядок событий, inbox тоже это поддерживает, но не глобально, а по агрегату. Глобальный строгий порядок означал бы обработку строго по одному событию за раз — это убивает параллелизм. Поэтому на практике используют порядок в рамках одного агрегата: события по одному заказу обрабатываются последовательно, а события по разным заказам — параллельно.

-- берём самое старое необработанное событие для конкретного агрегата
SELECT * FROM inbox
WHERE NOT processed AND aggregate_id = $X
ORDER BY received_at LIMIT 1;

Так один обработчик (или одна очередь) закрепляется за агрегатом и разбирает его события по порядку, а разные агрегаты идут независимо. Это ровно та же идея, что и в Kafka, где порядок гарантирован внутри партиции: там события с одним ключом попадают в одну партицию, а inbox воспроизводит порядок через группировку по aggregate_id.

Готовишься к собесу системного аналитика?
827 вопросов: REST, UML, OAuth, ERD, требования. Тренируйся в Telegram
Тренировать SA в Telegram

Inbox vs outbox

Паттерны симметричны и часто идут в паре:

Outbox Inbox
Сторона Отправитель (producer) Получатель (consumer)
Что решает Надёжную доставку Надёжную обработку
Какую проблему Dual write (запись в БД + публикация) Дедупликацию повторных доставок
Механика Сохранить исходящее событие в БД, затем опубликовать Сохранить входящее событие в БД, обработать один раз

Часто их используют вместе: отправитель через outbox гарантирует, что событие точно опубликуется, а получатель через inbox гарантирует, что оно обработается ровно один раз. На выходе получается сквозная надёжность — at-least-once-доставка плюс идемпотентная обработка дают эффект exactly-once для бизнес-логики, хотя честного распределённого exactly-once под капотом нет.

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

  • Считать, что брокер сам защитит от дублей. At-least-once — это гарантия «хотя бы раз», а не «ровно раз». Без inbox или другой дедупликации получатель обязан быть готов к повторам.
  • Не делать обработку и флаг атомарными. Если побочный эффект и processed = true коммитятся раздельно, остаётся окно для повторной обработки после падения.
  • Требовать глобального порядка. Строгий глобальный порядок сериализует всю обработку. В реальных системах достаточно порядка внутри агрегата — он и параллелизм, и корректность.
  • Забыть про идемпотентность внешних эффектов. Если обработка пишет во внешнюю систему вне транзакции БД, эту запись тоже нужно делать идемпотентной, иначе inbox спасёт только от дублей внутри своей БД.

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

FAQ

Чем inbox отличается от outbox?

Outbox стоит на стороне отправителя и решает проблему двойной записи: как атомарно и в БД сохранить, и событие опубликовать. Inbox стоит на стороне получателя и решает дедупликацию: как обработать повторно доставленное событие ровно один раз. Их часто применяют вместе для сквозной надёжности.

Зачем нужен inbox, если у брокера есть подтверждения offset?

Подтверждение offset не спасает от дублей: обработчик может выполнить бизнес-логику, но упасть до коммита offset — и тогда после перезапуска событие придёт снова. Inbox отсекает такой повтор по event_id, поэтому эффект будет как от одной обработки.

Как inbox обеспечивает порядок обработки?

Через порядок в рамках агрегата: события с одним aggregate_id разбираются последовательно по времени получения, а разные агрегаты обрабатываются параллельно. Это аналог гарантии порядка внутри партиции в Kafka и позволяет не жертвовать параллелизмом ради глобального порядка.

Даёт ли связка inbox + outbox настоящий exactly-once?

Строго распределённого exactly-once она не даёт — под капотом это at-least-once-доставка плюс идемпотентная обработка. Но для бизнес-логики эффект эквивалентен exactly-once: сколько бы раз событие ни доставилось, применится оно ровно один раз.

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

Нет. Статья основана на работах Chris Richardson (microservices.io) и общепринятых практиках событийной интеграции. Конкретная реализация зависит от стека, брокера и требований проекта.


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