Transactional Outbox

Представь ситуацию: ты разрабатываешь Order Service, который должен одновременно создавать заказ в базе данных И отправлять уведомление в Kafka о том, что заказ создан. Звучит просто, но на практике это все гораздо сложнее.

Вот что может пойти не так:

  • Заказ записался в БД, но сообщение не отправилось в брокер (ошибка сети) → система аналитики никогда не узнает о заказе.
  • Сообщение отправилось в брокер, но транзакция в БД откатилась (не хватило места) → другие сервисы получили уведомление о несуществующем заказе.
  • Сервис упал ровно между записью в БД и отправкой сообщения → система навсегда осталась в рассинхроне.
Может заиспользуем 2PC (Two-Phase Commit)?

Теоретически 2PC решает проблему атомарности между базой и брокером, но на практике:

  • Не все брокеры поддерживают 2PC (например, Kafka).
  • Сильно связывает бэкенд-сервис с инфраструктурой.
  • Убивает производительность распределенными блокировками.
  • Один недоступный участник = вся транзакция висит.
Решение: Transactional Outbox

Паттерн Transactional Outbox решает проблему атомарности без использования 2PC. Идея простая, но гениальная: вместо прямой отправки сообщений в брокер, мы используем базу данных как буфер.

  1. В рамках ОДНОЙ транзакции записываем изменения в бизнес-таблицы orders и складываем событие в специальную таблицу outbox.
  2. Отдельный фоновый процесс (Message Relay или CDC-инструмент) читает таблицу outbox и отправляет сообщения в брокер.
  3. После успешной отправки помечаем сообщения в таблице как обработанные (или удаляем).

Ключевая фишка: И бизнес-данные, и события лежат в одной реляционной БД, поэтому используется классическая локальная транзакция. Атомарность гарантирована на 100%.

Пример структуры таблиц (SQL)
SQL
-- Основная бизнес-таблица
CREATE TABLE orders (
    id UUID PRIMARY KEY,
    customer_id UUID,
    total_amount DECIMAL,
    status VARCHAR(20),
    created_at TIMESTAMP
);

-- Таблица для исходящих событий (Outbox)
CREATE TABLE outbox (
    id UUID PRIMARY KEY,
    aggregate_type VARCHAR(50),  -- 'Order'
    aggregate_id UUID,           -- ID заказа
    event_type VARCHAR(100),     -- 'OrderCreated'
    event_data JSONB,            -- Данные события (Payload)
    created_at TIMESTAMP,
    processed_at TIMESTAMP   
);
Где это применяется?

1. Debezium + Kafka (CDC): Netflix, Uber и Airbnb используют связку Debezium (инструмент Change Data Capture) + Transactional Outbox. Debezium читает внутренний журнал транзакций базы (WAL в PostgreSQL) и автоматически стримит события из outbox-таблицы прямо в Kafka.

2. Saga Pattern: В распределенных транзакциях (Saga) outbox критически важен. Каждый сервис (Склад, Платежи, Доставка) использует Outbox для надежной отправки событий о своих локальных успехах или ошибках. Без него Сага просто развалится.

Тонкости реализации: Идемпотентность

Сообщения могут дублироваться! Message Relay может отправить событие в Kafka, но упасть до обновления статуса processed_at в таблице. При перезапуске он отправит это событие снова. Поэтому каждое событие должно иметь уникальный ID.

Java
// В событии обязательно указываем уникальный ID (на стороне Продюсера)
OutboxEvent event = OutboxEvent.builder()
    .id(UUID.randomUUID()) // Важно для дедупликации!
    .eventType("OrderCreated")
    .eventData(orderData)
    .build();

А потребители (Консьюмеры) обязаны проверять эти дубликаты у себя:

Java
@EventHandler
public void handle(OrderCreatedEvent event) {
    // Проверяем, не обрабатывали ли уже это событие
    if (processedEventsRepository.exists(event.getId())) {
        log.info("Event {} already processed, skipping", event.getId());
        return;
    }
    
    // Обрабатываем событие
    processOrder(event);
    
    // Сохраняем ID как обработанный в рамках той же транзакции!
    processedEventsRepository.save(event.getId());
}
Очистка старых событий

Таблица Outbox может разрастись до гигантских размеров и замедлить базу. Нужна стратегия очистки:

  • Удаление сразу после успешной отправки (экономит место, но лишает истории).
  • Удаление по расписанию (Cron Job).
Java
@Scheduled(cron = "0 0 2 * * *") // Каждый день в 2:00 ночи
public void cleanupOldEvents() {
    // Удаляем успешно обработанные события старше 30 дней
    outboxRepository.deleteProcessedEventsBefore(
        LocalDateTime.now().minusDays(30)
    );
}
Итого: Стоит ли использовать?
Плюсы Минусы
Атомарность: Либо ВСЁ записалось, либо ничего. Задержка: События отправляются не мгновенно (Eventual Consistency).
Надежность: События не теряются даже при падении сервиса. Сложность: Нужно думать об идемпотентности и дубликатах.
Простота: Не нужен тяжелый 2PC. Оверхед: Дополнительная нагрузка на БД (запись в Outbox) и процесс Relay.
Вердикт
  • Обязательно используй Outbox, когда критично не потерять сообщения (платежи, заказы) в микросервисной архитектуре.
  • Не используй для простых логов или метрик, где потеря одного сообщения не критична.

Но есть системы, где outbox не подойдет. Подробнее можешь прочитать в этом посте