Очереди, потоки, события
Transactional outbox: конец двойной записи между БД и брокером
Размещают заказ. Хендлер делает две вещи: INSERT строки заказа, потом kafka.publish("OrderPlaced"). В счастливом пути месяцами всё ок. Потом ночью у брокера 400ms сбой; вызов publish зависает, и под прибивает OOM прямо посреди вызова. Строка заказа закоммичена и видна — с клиента списали деньги — но OrderPlaced так и не доехал. Fulfillment ничего не узнаёт. Посылка не отправляется. В логах ни одной ошибки, потому что ничего не бросило исключение. Сверка находит это спустя неделю, руками.
Почему у двойной записи нет безопасного порядка
У тебя две системы без общей транзакции: база данных и брокер сообщений. Любой хендлер, который должен обновить одну и уведомить другую, делает двойную запись, и нет такого порядка этих двух вызовов, который переживёт крах в промежутке между ними.
Записать строку, потом опубликовать: крах после коммита, до publish — и ты потерял событие. Состояние изменилось, но downstream об этом не знает. Опубликовать, потом записать строку: крах после publish, до коммита — и у тебя фантомное событие: consumer’ы реагируют на заказ, который откатился и никогда не существовал. Обернуть оба в try/catch и «компенсировать» при сбое — значит написать крошечный багованный координатор распределённых транзакций, который сам может упасть между шагами. Двухфазный коммит (2PC) поверх БД и Kafka закрыл бы промежуток, но Kafka не даёт пригодного для прода XA-участия, а блокирующий координатор 2PC — проблема хуже исходной.
Двойная запись — не баг, который можно обойти кодом внутри хендлера. Это отсутствие атомарности между двумя хранилищами.
Одна транзакция, один источник истины
Паттерн outbox схлопывает двойную запись в одну запись. В той же локальной транзакции БД, которая меняет бизнес-состояние, ты также делаешь INSERT строки в таблицу outbox, описывающей событие. Поскольку обе записи в одной ACID-транзакции против одной базы, они коммитятся вместе или откатываются вместе — атомарно, бесплатно, без координатора.
BEGIN;
INSERT INTO orders (id, status, ...) VALUES (...);
INSERT INTO outbox (id, aggregate, type, payload, created_at)
VALUES (uuid, 'order', 'OrderPlaced', '{...}', now());
COMMIT;После коммита истина «событие OrderPlaced должно быть доставлено» теперь durable в той же базе, что и сам заказ. Публикация в брокер становится отдельным, ретраябельным шагом, который выполняет relay (он же message relay / dispatcher): читает неотправленные строки outbox и пушит их в Kafka. Работа хендлера закончена на COMMIT; он вообще не разговаривает с брокером. Ты обменял невозможную атомарную двойную запись на тривиально-атомарную одиночную запись плюс асинхронный, краш-безопасный насос.
Два вида relay: polling против чтения лога
Relay читает outbox одним из двух способов, и выбор между ними — это решение сеньора в этом паттерне.
Polling publisher — воркер по интервалу запускает SELECT ... WHERE sent = false ORDER BY created_at LIMIT N, публикует каждую строку, затем помечает её отправленной. Предельно просто, работает на любой базе, без лишней инфраструктуры. Цена: каждый poll — нагрузка на твою основную OLTP-базу, есть работа или нет, а end-to-end latency ограничена твоим интервалом. Интервалы 100ms–1s типичны; уход к 25–50ms ради свежести умножает нагрузку запросами, а при 10–30 репликах relay этот постоянный polling становится реальным бременем для primary.
Change Data Capture (CDC) — вместо запроса к таблице инструмент вроде Debezium читает write-ahead log базы (WAL в Postgres) и эмитит событие в момент, когда INSERT в outbox закоммичен. Latency падает до единиц миллисекунд, нагрузки polling’ом на таблицу нет вовсе, и ты получаешь естественный порядок коммитов из лога. Цена операционная: теперь ты держишь и мониторишь CDC-коннектор, управляешь replication slots и думаешь про удержание WAL. CDC — это то, что подхватывает следующий юнит.
| Измерение | Polling publisher | CDC / чтение лога |
|---|---|---|
| End-to-end latency | Ограничена интервалом (100ms–1s) | Единицы мс |
| Нагрузка на primary БД | Постоянный трафик запросов, даже на простое | Читает WAL, нагрузка на таблицу почти ноль |
| Порядок | По ORDER BY; теряется при параллельных relay | Естественный порядок коммитов из лога |
| Операционная цена | Cron-подобный воркер; тривиально | Коннектор, replication slots, удержание WAL |
| Когда лучше | Умеренный объём, терпит latency, без новой инфры | Высокий throughput, низкая latency, важен порядок |
Гарантия — at-least-once, поэтому consumer обязан быть идемпотентным
Outbox закрывает промежуток потери событий, но не даёт exactly-once доставку, и вера в обратное — тот самый failure mode, что кусается в проде. Relay делает две неатомарные вещи: публикует в брокер, затем помечает строку отправленной. Если он падает после того, как брокер принял сообщение, но до коммита UPDATE, строка всё ещё выглядит неотправленной — поэтому при рестарте relay публикует то же событие снова. Ты получаешь at-least-once: каждое событие доставляется один или больше раз.
Значит, контракт для всех downstream нерушим: consumer’ы обязаны быть идемпотентными. Помечай каждую строку outbox стабильным event id, проноси его через брокер, и пусть consumer’ы дедуплицируют по нему — обычно через таблицу inbox обработанных id (хранят от нескольких часов до пары дней) или через уникальный constraint на побочный эффект. Обработка OrderPlaced дважды должна списать с карты один раз. Без идемпотентности outbox не делает систему надёжной; он делает её надёжно дублирующей.
Почему это работает
А нельзя ли relay сначала пометить строку отправленной, потом публиковать? Нет — это просто двигает промежуток и превращает дубликат в потерю события: крах после UPDATE и до publish — и строка выглядит готовой, но ничего не отправлено. Доступны только два порядка: publish-затем-пометить (дубликаты, восстановимо) или пометить-затем-publish (потеря, невосстановимо). At-least-once — строго более безопасный выбор, поэтому идемпотентные consumer’ы — несущая половина паттерна, а не опциональная добавка.
Операционный налог: bloat, порядок и конкурирующие relay
Три вещи рано или поздно поднимут кого-то по тревоге:
- Раздувание таблицы (bloat). Outbox растёт с каждым событием. Отправленные строки надо вычищать, иначе таблица — и её индексы — раздуваются до сотен тысяч строк, где polling-запросы деградируют. Не делай
DELETEогромных диапазонов одним стейтментом (он конкурирует за локи со вставками); удаляй отправленные строки маленькими батчами или партиционируй outbox по дням, чтобы очистка была metadata-onlyDROP PARTITIONвместо построчных удалений, бьющих по индексам. - Порядок. Один relay сохраняет порядок
created_at. Как только ты масштабируешь до нескольких relay ради throughput, два воркера могут публиковать одновременно, и события приходят не по порядку. Если consumer’у нужен порядок в рамках агрегата, ключуй по aggregate id, чтобы все события одного заказа шли в одну partition и к одному воркеру. - Конкурирующие relay. Запусти две реплики relay наивно — и обе сделают
SELECTтех же неотправленных строк и опубликуют их дважды. Фикс —SELECT ... FOR UPDATE SKIP LOCKED(Postgres / MySQL 8.0+): каждый воркер берёт и лочит непересекающийся батч, пропуская строки, что уже держит другой воркер, поэтому реплики масштабируются, не наступая друг на друга.
Сервис заказов должен обновить БД и уведомить fulfillment через Kafka. Крах в промежутке теряет событие. Выбери дизайн.
Relay публикует сообщение в брокер, потом падает до того, как успел UPDATE строки в sent. Что произойдёт при рестарте?
Ты масштабируешь polling relay до трёх реплик ради throughput. Что должно измениться, чтобы они не публиковали те же строки дважды?
Расставь жизненный цикл одного события через паттерн outbox:
- 1 В одной локальной транзакции INSERT бизнес-строки И строки outbox, затем COMMIT
- 2 Relay читает неотправленные строки outbox (polling по интервалу или чтение WAL через CDC)
- 3 Relay публикует каждое событие в брокер
- 4 Relay помечает строку отправленной (промежуток здесь делает доставку at-least-once)
- 5 Идемпотентный consumer дедуплицирует по event id и применяет эффект один раз
- 01Объясни коллеге, почему запись в БД и публикация в Kafka в одном хендлере небезопасны, и как outbox это чинит.
- 02Почему outbox — это at-least-once, а не exactly-once, и что это навязывает остальной системе?
Хендлер, который обновляет твою базу и публикует в брокер, делает двойную запись через две системы без общей транзакции, и никакой порядок этих вызовов не переживает крах в промежутке — ты либо теряешь событие, либо эмитишь фантомное. Transactional outbox убирает двойную запись целиком: в той же локальной транзакции, что меняет бизнес-состояние, ты делаешь INSERT строки outbox, поэтому обе коммитятся атомарно против одной базы без координатора. Затем отдельный relay доставляет строки outbox в брокер — либо polling’ом по интервалу 100ms–1s (просто, но добавляет latency и постоянную нагрузку на БД), либо чтением WAL через CDC вроде Debezium (latency в единицы мс и естественный порядок ценой запуска коннектора). Поскольку relay публикует, а потом помечает отправленным в двух неатомарных шагах, доставка at-least-once, поэтому consumer’ы обязаны дедуплицировать по стабильному event id. Операционный налог реален — вычищай или партиционируй outbox против bloat, ключуй по агрегату ради порядка и используй FOR UPDATE SKIP LOCKED, чтобы конкурирующие relay не публиковали дважды — но взамен ты получаешь конвейер событий, который никогда тихо не теряет запись.