Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 13.03 · 30 мин
Продвинутый
Transactional OutboxDual WriteDebeziumCDCOutbox PatternReliable Messaging

Transactional Outbox Pattern

Каждый разработчик микросервисов рано или поздно сталкивается с одной и той же задачей: нужно одновременно обновить базу данных и опубликовать событие в Kafka. Казалось бы, просто: сначала база, потом Kafka. Но “просто” здесь ломает систему.

Transactional Outbox — это паттерн, который делает эту операцию надёжной. Если вы усвоите один паттерн из всего модуля — пусть это будет outbox.


Dual-Write проблема: почему наивный подход не работает

Рассмотрим типичный сценарий: Order Service обрабатывает оплату заказа.

// Наивный подход — НИКОГДА не делайте так
BEGIN TRANSACTION
  UPDATE orders SET status = 'PAID', paid_at = NOW()
      WHERE id = 'order-123';
COMMIT  -- успешно

producer.send("payments.events", OrderPaidEvent{...});  -- может упасть!

Сценарий 1: Kafka publish упал после DB commit.

База данных: заказ переведён в PAID. Kafka: событие OrderPaid не опубликовано. Inventory Service не получил сигнала зарезервировать товары. Notification Service не отправил подтверждение. Заказ застрял в несогласованном состоянии.

Сценарий 2: DB commit упал после Kafka publish.

Kafka: событие OrderPaid опубликовано. База данных: откат транзакции, заказ остался в статусе PENDING. Inventory Service зарезервировал товары для заказа, которого не существует. Деньги списаны (внешний платёжный сервис уже обработал), но заказ в PENDING.

Обратный порядок не помогает. Если сначала Kafka, потом база — те же два сценария, только зеркально.

Kafka producer не участвует в DB-транзакции. Нет способа атомарно выполнить DB-операцию и Kafka-операцию в одной транзакции (если только не использовать XA-транзакции, которые практически неприменимы при Kafka из-за производительности и совместимости).

WARNING

Dual-write — это не редкий edge case. Это происходит при каждом сетевом сбое, перезапуске приложения, OOM-kill процесса. В production-системе с тысячами транзакций в секунду и SLA 99.9% — это гарантированная проблема без outbox pattern.


Решение: Outbox Pattern

Идея: Записать событие в специальную таблицу outbox в той же БД в рамках той же транзакции, что и бизнес-данные. Затем — отдельный процесс читает outbox и публикует в Kafka.

-- Бизнес-операция: атомарно обновляем заказ И пишем в outbox
BEGIN TRANSACTION;

UPDATE orders
  SET status = 'PAID', paid_at = NOW()
  WHERE id = 'order-123';

INSERT INTO outbox (
    id,
    aggregate_type,
    aggregate_id,
    event_type,
    payload,
    created_at
) VALUES (
    'evt-uuid-001',
    'Order',
    'order-123',
    'OrderPaid',
    '{"orderId":"order-123","amount":99.98,"currency":"RUB","paidAt":"2026-04-16T10:00:00Z"}',
    NOW()
);

COMMIT;  -- обе операции атомарны в одной транзакции

Теперь: если транзакция провалилась — ни заказ не обновлён, ни outbox запись не создана. Если транзакция успешна — гарантированно есть запись в outbox. Kafka-публикация из outbox — это уже отдельная, повторяемая операция. Если она провалилась — процесс повторит её при следующей итерации.


Архитектура Outbox с Debezium CDC

Transactional Outbox: атомарная запись и CDC-публикация
Одна транзакция: orders + outbox. Debezium читает WAL и публикует в Kafka. Нет dual-write риска.

Order Service

Order Service: HTTP request на оплату заказа. Открывает одну DB-транзакцию. Выполняет UPDATE orders + INSERT outbox в одном atomic commit. Никакого Kafka producer в бизнес-коде.

orders table

PostgreSQL: таблица orders (основные данные заказа). Обновляется в той же транзакции. Если транзакция откатится — orders не изменится.

outbox table

PostgreSQL: таблица outbox. Получает INSERT в той же транзакции. Строго атомарна с обновлением orders — не может быть выполнена одна без другой.
WAL CDC

Debezium Connector

Debezium PostgreSQL Connector. Читает PostgreSQL WAL (Write-Ahead Log) через logical replication. Каждый INSERT в outbox таблицу -> CDC event в Debezium. Порядок событий = порядок WAL записей. Debezium отслеживает LSN (Log Sequence Number) для exactly-once обработки.
SMT Transform

payments.events (Kafka)

Kafka topic: payments.events (или любой другой, определённый aggregate_type полем outbox записи). Ключ = aggregate_id. Value = payload поле. EventRouter SMT автоматически маршрутизирует записи в нужный топик по aggregate_type.

Debezium Outbox Event Router SMT

Debezium включает специальный Single Message Transform (SMT): io.debezium.transforms.outbox.EventRouter. Этот transform преобразует CDC-события из outbox таблицы в правильно отформатированные Kafka записи.

Конфигурация Debezium Outbox Connector:

{
  "name": "outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "secret",
    "database.dbname": "orders_db",
    "database.server.name": "orders-service",
    "plugin.name": "pgoutput",
    "slot.name": "debezium_outbox_slot",
    "table.include.list": "public.outbox",
    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.table.field.event.type": "event_type",
    "transforms.outbox.table.field.event.key": "aggregate_id",
    "transforms.outbox.route.by.field": "aggregate_type",
    "transforms.outbox.table.fields.additional.placement": "created_at:header:eventTimestamp",
    "transforms.outbox.route.topic.replacement": "outbox.event.${routedByValue}"
  }
}

Как EventRouter SMT работает:

  1. Debezium фиксирует INSERT в public.outbox через PostgreSQL WAL.
  2. EventRouter извлекает поля outbox записи:
    • Topic routing: значение aggregate_type поля (Order) → топик outbox.event.order
    • Kafka key: значение aggregate_id поля (order-123)
    • Kafka value: значение payload поля (JSON события)
  3. CDC envelope (before/after структура) заменяется чистым бизнес-событием.
  4. Дополнительные поля из created_at помещаются в заголовки (header) Kafka записи.

Это критически важно: downstream consumers получают чистое бизнес-событие, не CDC-артефакт с before/after структурой.


Схема Outbox таблицы

CREATE TABLE outbox (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type  VARCHAR(255)  NOT NULL,   -- 'Order', 'Payment', 'Inventory'
    aggregate_id    VARCHAR(255)  NOT NULL,   -- 'order-123', 'payment-456'
    event_type      VARCHAR(255)  NOT NULL,   -- 'OrderCreated', 'OrderPaid', 'OrderCancelled'
    payload         JSONB         NOT NULL,   -- фактическое содержимое события
    created_at      TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
    processed_at    TIMESTAMPTZ,              -- для polling publisher (nullable)
    version         INTEGER       NOT NULL DEFAULT 1  -- версия схемы события
);

-- Индекс для polling publisher (если не Debezium)
CREATE INDEX idx_outbox_unprocessed
    ON outbox (created_at)
    WHERE processed_at IS NULL;

-- Индекс для очистки
CREATE INDEX idx_outbox_created_at ON outbox (created_at);

Проектные решения:

  • aggregate_type — Debezium EventRouter использует для routing в топик.
  • aggregate_id — становится Kafka key, обеспечивает partition affinity (все события одного агрегата в одну партицию).
  • payload — JSONB или BYTEA (для Avro бинарника). JSONB рекомендуется для отладки.
  • version — версия схемы для upcasting при эволюции.
  • processed_at — нужен только для polling publisher подхода.

CrossCourseRef: Debezium глубже

Debezium Deep-Dive: Outbox Pattern и CDC механика

Полная конфигурация Debezium connector, WAL/binlog механика, SMT трансформации, LAG-метрики, мониторинг Debezium — в курсе Debezium. Здесь мы сфокусированы на outbox pattern как решении dual-write, а не на деталях CDC инфраструктуры.


Альтернатива: Polling Publisher

Если развёртывание Debezium недопустимо (ограничения инфраструктуры, нет доступа к WAL), используется Polling Publisher — простой scheduled job:

@Scheduled(fixedDelay = 500)  // каждые 500мс
@Transactional
void publishOutboxEvents() {
    List<OutboxEvent> events = outboxRepository
        .findUnprocessedEvents(100);  // SELECT ... WHERE processed_at IS NULL ORDER BY created_at LIMIT 100

    for (OutboxEvent event : events) {
        try {
            String topic = resolveTopicName(event.getAggregateType());
            producer.send(new ProducerRecord<>(topic, event.getAggregateId(), event.getPayload()))
                    .get(5, TimeUnit.SECONDS);  // sync для at-least-once
            event.setProcessedAt(LocalDateTime.now());
            outboxRepository.save(event);
        } catch (Exception e) {
            log.error("Failed to publish outbox event {}", event.getId(), e);
            // Следующая итерация повторит
        }
    }
}

Polling vs Debezium CDC: сравнение подходов:

Polling Publisher vs Debezium CDC
Polling PublisherПростота: нет CDC инфраструктуры. SELECT-UPDATE-DELETE цикл. Недостатки: задержка = polling interval (100ms-5s). Два DB-запроса на событие (SELECT + UPDATE). Масштабирование: один publisher, иначе дублирование (нужен distributed lock). Порядок: ORDER BY created_at — возможны проблемы при одинаковых timestamp.
Debezium CDCLatency: почти реального времени (WAL → Kafka за миллисекунды). Порядок: WAL LSN — строго монотонный, надёжный порядок. Масштабирование: Debezium connector — один активный task на таблицу. Сложность: требует Debezium Connect cluster, WAL конфигурация PostgreSQL (wal_level=logical). Нет дополнительного DB-запроса для чтения outbox — WAL прямо.

Когда выбрать Polling Publisher:

  • Latency 500ms-5s допустима для вашего сценария.
  • Нет возможности установить Debezium (ограниченная инфраструктура).
  • Небольшой объём событий (до нескольких сотен в секунду).
  • PostgreSQL WAL level не может быть logical.

Когда выбрать Debezium CDC:

  • Требуется near-realtime latency (менее 100ms).
  • Высокий объём событий (тысячи в секунду).
  • Критична надёжность порядка событий.
  • Уже используется Kafka Connect (добавление Debezium — ещё один connector).

Idempotency и обработка дублей

Outbox pattern гарантирует at-least-once доставку: событие будет опубликовано минимум один раз. В случае сбоя publisher может опубликовать одно и то же событие дважды.

Решение: все consumers outbox событий должны быть идемпотентны. Используйте eventId (UUID из outbox) как ключ дедупликации:

@KafkaListener(topics = "outbox.event.order")
void onOrderEvent(ConsumerRecord<String, String> record) {
    String eventId = record.headers()
        .lastHeader("eventId")
        .map(h -> new String(h.value()))
        .orElseGet(() -> extractFromPayload(record.value()));

    if (processedEventRepository.exists(eventId)) {
        log.info("Duplicate event {}, skipping", eventId);
        return;
    }

    processEvent(record.value());
    processedEventRepository.markProcessed(eventId);
}

Храните processed eventId в базе данных с TTL или bloom filter для эффективной дедупликации.


Очистка Outbox таблицы

Outbox — временное хранилище. После успешной публикации записи можно удалять:

При Debezium: Debezium отслеживает позицию WAL (LSN) через replication slot. После того как INSERT обработан, строку можно безопасно удалить. Периодически:

DELETE FROM outbox
WHERE created_at < NOW() - INTERVAL '1 hour';

Debezium не потеряет необработанные события: при сбое он возобновляет с того же LSN (replication slot сохраняет позицию). Удалять можно только уже обработанные строки.

При Polling Publisher: DELETE после processed_at IS NOT NULL или TTL-очистка.

TIP

Transactional Outbox Pattern — золотой стандарт надёжной публикации событий в микросервисах. Если вы строите event-driven систему на Kafka, outbox pattern — ваш первый выбор для решения dual-write. Debezium CDC + EventRouter SMT делает его операционно простым.


Ключевые выводы

  1. Dual-write проблема — наивная последовательность “сначала DB, потом Kafka” ненадёжна при сбоях. Атомарности нет.
  2. Outbox решение: событие пишется в outbox таблицу в той же DB-транзакции. Atomicity гарантирована базой данных.
  3. Debezium EventRouter SMT читает outbox через CDC (WAL), маршрутизирует в Kafka topic по aggregate_type.
  4. Polling Publisher — проще, но выше latency и два DB-запроса на событие.
  5. Все consumers outbox событий должны быть идемпотентны — at-least-once delivery гарантирует дубли.
  6. Outbox очищается после обработки. Debezium replication slot гарантирует сохранение позиции при сбоях.
Проверка знанийKnowledge check
Разработчик говорит: 'Я добавил try-catch вокруг Kafka producer.send(), если он упадёт — просто логирую ошибку и двигаюсь дальше. Это решает dual-write.' Почему это НЕ решает проблему, и что будет происходить в production при таком подходе?
ОтветAnswer
try-catch с игнорированием ошибки создаёт silent data loss. Если Kafka publish упал (сетевой сбой, брокер недоступен, timeout) — DB уже обновлена (committed), а событие потеряно навсегда. Downstream consumers (Inventory, Notification, Analytics) никогда не получат сигнал. Это не edge case: в production с SLA 99.9% на 1M транзакций в день — это тысячи потерянных событий. Outbox pattern решает это: событие записывается в БД атомарно с бизнес-данными. Если Kafka publish упал — outbox запись остаётся. Publisher повторит публикацию (retry loop). Потеря данных невозможна: или транзакция провалится (и outbox запись не создастся), или outbox запись гарантирует eventual Kafka публикацию.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7