Как использовать паттерн outbox для обработки задержек записи в базу и обеспечения отказоустойчивости
Что проверяют
Понимаете ли вы проблему двойной записи: изменить базу и отправить сообщение в брокер одной транзакцией нельзя. Следом спрашивают, какую гарантию доставки даёт outbox и что из-за этого должен уметь потребитель.
Как отвечать
Проблема: сервис сохраняет заказ и публикует событие в Kafka. Если он упадёт между этими шагами, событие потеряется или уйдёт событие о заказе, которого нет. Outbox решает это так:
- одна транзакция: внутри
db.BeginTxпишутся бизнес-строка и строка в таблицуoutbox(id, тип события, payload вjsonb,created_at,published_at). Либо обе, либо ни одной; - релей: отдельная горутина или процесс берёт неотправленные строки пачкой, публикует, отмечает отправленными. Чтобы реплики не хватали одни и те же строки, выборка идёт через
SELECT ... FOR UPDATE SKIP LOCKED; - CDC вместо опроса: Debezium читает WAL и публикует записи outbox без поллинга таблицы;
- at-least-once: релей может отправить сообщение и упасть до пометки, значит дубли неизбежны. Потребитель идемпотентен: хранит id обработанных событий или делает upsert;
- порядок: ключ партиции в Kafka это id агрегата, а релей публикует строки в порядке вставки.
Про задержки записи: outbox не ускоряет базу, он отвязывает бизнес-операцию от доступности брокера. Kafka недоступна, а запросы проходят, события копятся в таблице и уйдут позже. Нужны ретраи с backoff, метрика возраста самой старой неотправленной записи и чистка отправленных строк.
В Go репозиторий outbox принимает *sql.Tx или pgx.Tx, а не *sql.DB, иначе вставка уйдёт мимо транзакции.
Где спотыкаются
Пишут в outbox отдельным запросом вне транзакции. Паттерна тогда нет, осталась та же двойная запись.
Обещают exactly-once. Outbox даёт доставку хотя бы один раз, дедупликация всегда на потребителе.
Запускают несколько релеев без блокировки строк, и каждое событие уходит по два раза. Или не думают о порядке и получают «заказ оплачен» раньше, чем «заказ создан».