Outbox pattern: как не потерять сообщения при записи в БД
Сценарий, который я разбирал в трёх разных компаниях: сервис принимает заказ, пишет его в Postgres и шлёт событие OrderCreated в Kafka. Логика понятная, код в одном методе, тесты зелёные. На проде раз в неделю всплывают заказы, которые есть в базе, но которых нет в аналитике, в биллинге и в очереди отгрузки. Сообщение потерялось между commit и producer.send.
Outbox pattern закрывает именно этот зазор. Я не считаю его серебряной пулей: у него есть цена и контекст, в котором он избыточен. Разберу, как он работает на уровне таблиц и кода, какие компромиссы вы примете, и где честнее обойтись без него.
Откуда берётся потеря
Классический наивный код выглядит так:
@Transactional
fun createOrder(cmd: CreateOrder): Order {
val order = orderRepo.save(Order.from(cmd))
kafkaProducer.send("orders", OrderCreated(order.id))
return order
}Проблем здесь как минимум три, и все они стреляют в проде, а не в тестах.
- Если
sendотправил, а транзакция откатилась (CHECK-нарушение, deadlock, retry на уровне выше) — событие ушло про несуществующий заказ. - Если транзакция закоммитилась, а
sendупал (брокер недоступен, таймаут) — заказ есть, события нет. - Если
sendасинхронный и его callback срабатывает после коммита, но перед ним под капотом висит локальный буфер producer'а — потеря возможна на падении пода.
В Java/Kotlin к этому добавляется типичная ошибка: kafkaTemplate.send возвращает CompletableFuture, и разработчик его не ждёт. Транзакция уезжает, а ошибка отправки приходит в логи через 200 мс — никто не реагирует.
Идея паттерна
Outbox pattern меняет правила: сервис не отправляет событие сам. Он пишет событие в ту же базу, в ту же транзакцию, в которой меняет бизнес-данные. Отдельный процесс читает таблицу outbox и доставляет события в брокер.
Так получается атомарность за счёт ACID локальной БД: либо есть и заказ, и запись в outbox, либо нет ни того, ни другого. Дальше вопрос только в том, как переложить запись из outbox в Kafka хотя бы один раз. Это уже решаемая задача с понятными гарантиями.
Минимальная схема таблицы
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
type TEXT NOT NULL,
payload JSONB NOT NULL,
headers JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ
);
CREATE INDEX outbox_unsent_idx ON outbox (id) WHERE sent_at IS NULL;Поле aggregate_id я держу всегда — оно нужно как ключ партиционирования в Kafka, чтобы события одного заказа шли по порядку. headers храню отдельно от payload, туда удобно класть traceparent и idempotency-key. Частичный индекс по неотправленным записям важен на больших объёмах: иначе scheduler начнёт читать всю таблицу.
Запись делается в той же транзакции, что и доменное изменение:
@Transactional
fun createOrder(cmd: CreateOrder): Order {
val order = orderRepo.save(Order.from(cmd))
outboxRepo.save(
OutboxRow(
aggregate = "order",
aggregateId = order.id.toString(),
type = "OrderCreated",
payload = mapper.writeValueAsBytes(OrderCreated(order))
)
)
return order
}Кто читает outbox
Здесь два рабочих подхода, и выбор между ними — это тот самый trade-off.
Polling publisher
Простой воркер раз в N миллисекунд читает пачку строк WHERE sent_at IS NULL, шлёт в Kafka, помечает отправленные. Я почти всегда стартую с него.
SELECT id, aggregate_id, type, payload, headers
FROM outbox
WHERE sent_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;FOR UPDATE SKIP LOCKED позволяет запускать несколько инстансов воркера, не страдая от блокировок. Каждый берёт свою пачку. После успешной отправки — UPDATE outbox SET sent_at = now() WHERE id IN (...).
Что вы выигрываете: простоту. Воркер — это обычный сервис, который понимает любой DBA и любой junior. Логи, метрики, retry — всё на знакомых инструментах.
Что теряете: задержку. Между коммитом и отправкой проходит interval опроса плюс время на пачку. На 100 мс это незаметно, на 2 секунды уже ощутимо для UX, где фронт ждёт обновлённого состояния.
CDC через Debezium
Альтернатива — читать WAL Postgres логической репликацией. Debezium ловит INSERT в outbox и сразу публикует в Kafka. Задержка измеряется десятками миллисекунд.
Что выигрываете: реальный near-real-time, ноль кода в воркере, готовый Single Message Transform EventRouter, который превращает строку outbox в событие нужного формата.
Что теряете: ещё один кусок инфраструктуры, который нужно мониторить. Debezium умеет падать на DDL, отставать на больших транзакциях, у него своя кривая обучения. Если у вас ещё нет Kafka Connect — это плюс целый кластер. На небольших нагрузках это перебор.
Мой эвристический выбор: меньше 1000 событий в секунду и нет требования к sub-second задержке — polling. Выше — Debezium, потому что polling начинает упираться в БД.
At-least-once и идемпотентность
Outbox даёт гарантию at-least-once. Воркер мог отправить в Kafka, упасть до UPDATE sent_at — и при перезапуске отправит ещё раз. Это нормально и встроено в дизайн.
Из этого следует одно простое правило: потребители обязаны быть идемпотентными. Никаких других вариантов нет. Способы стандартные: дедуп по event_id в таблице обработанных событий, версии агрегата, бизнес-проверки вида «уже ли отгрузили».
Если потребитель не ваш (внешний партнёр), отправляйте идемпотентный ключ в заголовках и фиксируйте это в контракте. Без этого вы не получите exactly-once на уровне эффекта, а only-once на уровне доставки — это иллюзия в распределёнке.
Очистка таблицы
Outbox растёт. На 1000 RPS за сутки — 86 миллионов строк. Через неделю scheduler начнёт читать индекс размером с диск.
Я веду две стратегии чистки в зависимости от требований аудита:
- Если события не нужны для аудита — удалять
WHERE sent_at < now() - interval '1 hour'пачками по cron. - Если нужны — переносить в архивную таблицу или в S3, в outbox держать только горячее окно. Партиционирование по дате тоже работает:
DROP PARTITIONвместоDELETE.
Не игнорируйте autovacuum: частые UPDATE и DELETE в outbox создают bloat. На моей практике приходилось тюнить autovacuum_vacuum_scale_factor для этой таблицы отдельно, иначе планировщик уходил в seq scan.
Когда outbox избыточен
Я не ставлю outbox по умолчанию. Есть три случая, когда он не нужен.
Первый — события не критичны. Метрики, аналитика, прогрев кешей. Потеря 0.1% событий приемлема, потому что аналитика всё равно сглаживает. Здесь хватит обычного producer'а с retry и метрикой потерь.
Второй — у вас уже есть event sourcing. Event store сам по себе является источником истины, отдельный outbox дублирует то же самое. Достаточно проектора, который читает event store и публикует наружу.
Третий — синхронный API без интеграций. Если сервис не публикует событий, то и outbox не нужен. Звучит банально, но я видел проекты, где outbox завели «на всякий случай». Этот «всякий случай» оборачивается ещё одной таблицей, которую все боятся трогать.
Подводные камни, которые мне стоили часов
Несколько вещей, которые в учебниках не пишут.
- Транзакции с long-running операциями. Если в той же транзакции, что и outbox, висит вызов внешнего HTTP, транзакция держит лок на строке outbox, и polling не видит её, пока транзакция не закроется. Внешние вызовы — после коммита.
- Размер payload. JSONB в Postgres до ~2 КБ хранится inline, дальше уезжает в TOAST. Outbox с большими payload'ами начинает тормозить на UPDATE. Если событие тяжёлое, кладите в payload только id и тип, а тело — отдельным запросом.
- Порядок событий. Polling с
ORDER BY idиSKIP LOCKEDна нескольких воркерах ломает порядок между разными агрегатами. Внутри одногоaggregate_idпорядок сохраняется только если вы его явно поддерживаете: партиционирование Kafka поaggregate_idи шардирование воркера по тому же ключу. - Schema migration. Добавили в Order поле, забыли обновить сериализацию payload — потребители ловят несовместимые события. Контракт на сериализацию событий — отдельная сущность, не
toStringдоменного класса.
Что запомнить
Outbox решает одну конкретную задачу: атомарность изменения бизнес-данных и публикации события. Он не даёт exactly-once, не отменяет идемпотентности на стороне потребителей, не работает бесплатно по производительности БД. Зато он простой, проверенный и не требует распределённых транзакций.
Стартуйте с polling-варианта, мониторьте задержку и размер таблицы. Когда упрётесь в один из этих параметров — это сигнал смотреть на Debezium или event sourcing. Поменять реализацию доставки внутри паттерна гораздо дешевле, чем выкорчёвывать события из бизнес-кода после первого инцидента с потерей.