lenec ru

← все посты

Transactional messaging: как связать БД и брокер без боли

13K

Связка «изменили данные в БД и опубликовали событие в брокер» — самая частая точка отказа в распределённых системах. Я разбирал её в трёх компаниях, и каждый раз корень был один: команда пыталась решить задачу одним из двух тупиковых способов. Либо «сначала в БД, потом в брокер» — и теряли событие при падении между шагами. Либо «сначала в брокер, потом в БД» — и публиковали события про несуществующие сущности.

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 со всеми его трейдоффами — самый честный компромисс между сложностью реализации и гарантиями.

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

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

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