lenec ru

← все посты

Outbox pattern: как не потерять сообщения при записи в БД

12K

Сценарий, который я разбирал в трёх разных компаниях: сервис принимает заказ, пишет его в 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. Поменять реализацию доставки внутри паттерна гораздо дешевле, чем выкорчёвывать события из бизнес-кода после первого инцидента с потерей.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.