Transactional messaging: как связать БД и брокер без боли
Связка «изменили данные в БД и опубликовали событие в брокер» — самая частая точка отказа в распределённых системах. Я разбирал её в трёх компаниях, и каждый раз корень был один: команда пыталась решить задачу одним из двух тупиковых способов. Либо «сначала в БД, потом в брокер» — и теряли событие при падении между шагами. Либо «сначала в брокер, потом в БД» — и публиковали события про несуществующие сущности.
Transactional messaging — это набор подходов, которые делают эту связку атомарной. Outbox pattern — самый известный, но не единственный. Ниже разберу четыре рабочих варианта: outbox, listen-to-yourself, dual writes с компенсациями и transactional producer'ы. У каждого свои условия применимости и трейдоффы.
Почему наивный подход ломается
Стандартный код выглядит так:
@Transactional
fun createOrder(cmd: CreateOrder) {
val order = orderRepo.save(Order.from(cmd))
kafkaProducer.send("orders", OrderCreated(order.id))
}Здесь четыре сценария отказа:
- Транзакция откатилась после
send. Событие ушло про несуществующий заказ. - Транзакция закоммитилась,
sendупал. Заказ есть, события нет. sendасинхронный, callback пришёл после коммита. Между коммитом и реальной отправкой — окно потерь.- Producer держит локальный буфер. Падение пода до flush — потеря.
Все четыре стреляют в продакшене. Решение — атомарность между записью в БД и публикацией.
Подход 1: Outbox pattern
Базовый и самый универсальный. Вместо прямой публикации сервис пишет событие в таблицу outbox в той же транзакции, что и доменное изменение. Отдельный воркер читает таблицу и публикует в брокер.
@Transactional
fun createOrder(cmd: CreateOrder) {
val order = orderRepo.save(Order.from(cmd))
outboxRepo.save(
OutboxRow(
aggregate = "order",
aggregateId = order.id.toString(),
type = "OrderCreated",
payload = mapper.writeValueAsBytes(OrderCreated(order))
)
)
}Что вы получаете: ACID локальной БД гарантирует атомарность. Либо обе записи есть, либо ни одной.
Что платите: вторая таблица, отдельный воркер, задержка между коммитом и публикацией.
Это дефолт для большинства случаев. Я ставлю outbox по умолчанию, если нет особых условий.
Подход 2: Listen-to-yourself
Сервис публикует событие в брокер, на это же событие он сам подписан. Реакция на собственное событие — изменение БД.
POST /orders ->
publish(OrderCreated) // только публикация, БД ещё не тронута
консьюмер этого же сервиса:
on OrderCreated ->
orderRepo.save(Order.from(event))
publish(OrderConfirmed) // если нужноЗвучит странно, но в некоторых сценариях работает. Главное условие — брокер с гарантированной доставкой и event log как источник истины. Например, Kafka с infinite retention.
Что вы получаете: единый источник правды (event log), естественную интеграцию с другими сервисами, автоматический rebuild через replay.
Что платите: eventual consistency — между POST и появлением заказа в БД проходит время. На клиентской стороне это требует optimistic UI или polling. Сложность отладки выше: «где сейчас заказ» — в брокере, в БД, в обоих.
Применяется в системах с event sourcing-семантикой. На обычном CRUD — оверкилл.
Подход 3: Dual writes с компенсацией
Сервис пишет в БД и публикует в брокер последовательно, но с механизмом отката, если второй шаг провалился.
fun createOrder(cmd: CreateOrder) {
val order = transaction {
orderRepo.save(Order.from(cmd))
}
try {
kafkaProducer.send("orders", OrderCreated(order.id)).get()
} catch (e: Exception) {
transaction {
orderRepo.delete(order.id)
}
throw e
}
}Я этот подход показываю, чтобы сказать: не делайте так. Он только усугубляет проблему. Между падением send'а и компенсирующей delete возможен новый отказ — и вы получите orphan-запись в БД.
На практике встречается как «временное решение, пока выкатим outbox». Временное обычно превращается в постоянное, и через год команда не помнит, почему теряются заказы.
Подход 4: Transactional producer (Kafka)
Kafka умеет транзакционный producer: серия publish'ов либо вся видна consumer'ам, либо ни одна. Это не атомарность с БД, но в специфичных сценариях помогает.
producer.initTransactions()
producer.beginTransaction()
try {
producer.send(ProducerRecord("orders", orderId, OrderCreated(...)))
producer.send(ProducerRecord("audit", orderId, AuditLog(...)))
producer.commitTransaction()
} catch (e: Exception) {
producer.abortTransaction()
throw e
}Это полезно, когда у вас несколько публикаций должны быть атомарными между собой. Но между БД и брокером — ничего не решает: транзакция в Postgres и транзакция Kafka не координируются.
Можно ли координировать? Технически да, через XA-транзакцию (двухфазный коммит). На практике XA с Kafka либо не поддерживается напрямую, либо требует специальных адаптеров и усложняет всю систему. Я бы не пошёл этим путём, если есть outbox.
Подход 5: Change Data Capture
Producer вообще ничего не знает про публикацию. Он пишет в свои таблицы как обычно. CDC-инструмент (Debezium) читает WAL Postgres и публикует изменения в брокер.
Postgres WAL --> Debezium --> Kafka topic
orders.public.orders (изменения таблицы orders)
orders.public.outbox (изменения таблицы outbox, если используется outbox routing)Что вы получаете: ноль кода в producer-сервисе. Вся интеграция вынесена в инфраструктуру.
Что платите: дополнительная инфраструктура (Kafka Connect, Debezium), сложность мониторинга, привязка к конкретной БД. Изменения схемы DDL могут ронять Debezium.
Хорошо подходит для микросервисов, которые уже стоят в проде, и которые надо «безболезненно» интегрировать. Изменение в коде не требуется, только в инфраструктуре.
Я обычно использую CDC в комбинации с outbox: сервис пишет в outbox-таблицу, Debezium публикует изменения этой таблицы в Kafka через Single Message Transform EventRouter. Получается «outbox без воркера».
Сравнение
Когда какой выбирать.
Outbox — дефолт. Простой, работает с любым брокером, минимум зависимостей. Минус — задержка polling и собственный воркер.
Outbox + CDC (Debezium) — когда задержка должна быть низкой, и есть готовая инфраструктура для Kafka Connect. Минус — больше операционной нагрузки.
Listen-to-yourself — когда event log уже первичный источник истины (event sourcing-сценарий). На обычном CRUD не нужно.
Transactional producer Kafka — для атомарности между несколькими топиками. С БД не помогает.
Dual writes с компенсацией — никогда. Это технический долг, не решение.
Подводные камни
Несколько вещей, которые я ловил.
Outbox: транзакция держит блокировку слишком долго. В одной транзакции с записью в outbox делается долгая операция (HTTP, computation), и polling-воркер не может прочитать строку из-за блокировки на уровне MVCC. Лекарство — короткие транзакции, внешние вызовы вне транзакции.
CDC: schema evolution. Меняете тип колонки в outbox-таблице, Debezium пробует прочитать как раньше — fail. Перед миграциями надо проверять конфигурацию Debezium и делать backward-compatible изменения.
Transactional Kafka producer: read_committed. Consumer должен быть с isolation.level=read_committed, иначе он будет читать незавершённые транзакции. Это не дефолт.
Listen-to-yourself: видимость своих событий. Сервис подписан на свой топик. Consumer лагает — сервис не видит свои собственные изменения. Появляется внутренний eventual consistency, который команда забывает учесть.
Гарантии и идемпотентность
Что бы вы ни выбрали, на стороне consumer'а нужна идемпотентность. Все эти подходы дают at-least-once. Single delivery — миф, в распределённке его не существует.
Это значит — inbox pattern или эквивалент на стороне получателя. Outbox + inbox дают effectively-once в смысле бизнес-эффекта, но не в смысле сетевой доставки. Это важное различие, которое часто путают.
Что не лечится transactional messaging
Список того, что не решается ни одним из подходов.
- Coordinated rollback нескольких сервисов. Заказ создан в orders, оплата прошла в payments, потом надо откатить — это саге, не транзакционка.
- Strong consistency между сервисами. Outbox даёт eventual consistency: между коммитом и видимостью события у consumer'а проходит время. Если бизнесу нужно «через 50 мс везде согласовано» — это не сюда.
- Идемпотентность бизнес-операций. Технический дедуп события не отменяет того, что сама операция должна быть идемпотентной (для других видов retry — ручных, на уровне UI и т.д.).
Что запомнить
Transactional messaging — это про закрытие зазора между БД и брокером. Базовый рецепт — outbox: одна транзакция, одна локальная гарантия, простой воркер. Альтернативы (CDC, transactional producer, listen-to-yourself) применяются под конкретные условия и не отменяют outbox-философию.
Главное правило — не пытайтесь решить задачу dual write'ами с компенсацией. Они выглядят простыми, но создают новые точки отказа. Outbox со всеми его трейдоффами — самый честный компромисс между сложностью реализации и гарантиями.