Что такое паттерн Transactional Outbox?

Ответ

Transactional Outbox (Транзакционный исходящий ящик) — это паттерн, используемый в распределенных системах для надежной публикации событий или сообщений, гарантируя, что они будут доставлены только если соответствующее изменение данных в БД успешно сохранено.

Проблема, которую он решает: В микросервисной архитектуре часто нужно обновить локальную базу данных и отправить событие (например, в Kafka) в одной транзакции. Однако это невозможно в распределенной транзакции (2PC сложен и ненадежен). Паттерн Outbox решает это, разделяя операцию на две атомарные фазы.

Принцип работы:

  1. Фаза 1 (Атомарная запись): Вместо прямой отправки в брокер, событие записывается в специальную таблицу outbox в той же транзакции, что и бизнес-данные.
  2. Фаза 2 (Фоновая доставка): Отдельный процесс (Publisher) периодически опрашивает таблицу outbox, отправляет новые события в брокер сообщений и удаляет их из таблицы после успешной отправки.

Пример реализации с Spring Boot и JPA:

// 1. Сущность для таблицы Outbox
@Entity
@Table(name = "outbox_event")
public class OutboxEvent {
    @Id
    private String id = UUID.randomUUID().toString();
    private String aggregateId; // ID связанной сущности (например, заказа)
    private String aggregateType; // Тип сущности (например, "Order")
    private String eventType; // Тип события (например, "OrderCreated")
    @Lob
    private String payload; // Данные события в JSON
    private LocalDateTime createdAt = LocalDateTime.now();
    private boolean published = false;
    // Геттеры и сеттеры...
}

// 2. Сервис, который создает заказ и событие в одной транзакции
@Service
@Transactional
public class OrderService {
    @Autowired
    private OrderRepository orderRepo;
    @Autowired
    private OutboxEventRepository outboxRepo;

    public void createOrder(CreateOrderCommand command) {
        // 1. Сохраняем бизнес-сущность
        Order order = new Order(command.getCustomerId(), command.getItems());
        orderRepo.save(order);

        // 2. В ТОЙ ЖЕ ТРАНЗАКЦИИ сохраняем событие в Outbox
        OutboxEvent event = new OutboxEvent();
        event.setAggregateId(order.getId());
        event.setAggregateType("Order");
        event.setEventType("OrderCreated");
        event.setPayload(
            "{"orderId":"" + order.getId() + "","total":" + order.getTotal() + "}"
        );
        outboxRepo.save(event);
        // Транзакция коммитится. Если она откатится, событие тоже не сохранится.
    }
}

// 3. Фоновый процесс (Publisher) для отправки событий
@Component
public class OutboxPublisher {
    @Autowired
    private OutboxEventRepository outboxRepo;
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    @Scheduled(fixedDelay = 5000) // Опрашивает каждые 5 секунд
    @Transactional
    public void publishEvents() {
        List<OutboxEvent> events = outboxRepo.findByPublishedFalse();
        for (OutboxEvent event : events) {
            try {
                // Отправляем в брокер сообщений
                kafkaTemplate.send("order-events", event.getEventType(), event.getPayload());
                // Помечаем как отправленное
                event.setPublished(true);
                outboxRepo.save(event);
            } catch (Exception e) {
                // Логируем ошибку. При следующем запуске попытка повторится.
            }
        }
        // Можно добавить очистку старых отправленных событий
        outboxRepo.deleteByPublishedTrueAndCreatedAtBefore(...);
    }
}

Преимущества:

  • Гарантирует "at-least-once" доставку: Событие не потеряется, даже если сервис упадет после сохранения в БД, но до отправки в Kafka.
  • Решает проблему двойного списания (Dual Write): Исключает ситуацию, когда данные сохранены, а событие — нет (или наоборот).
  • Повышает отказоустойчивость системы.

Недостатки и соображения:

  • Задержка (Latency): События публикуются не мгновенно, а с задержкой опроса.
  • Дублирование событий: Фоновый процесс может отправить событие дважды (идемпотентность потребителя обязательна).
  • Требует дополнительной инфраструктуры: Таблица outbox и фоновый джоб.

Альтернативы и улучшения:

  • Использование CDC (Change Data Capture) инструментов, таких как Debezium, которые читают журнал транзакций БД и публикуют изменения напрямую, без необходимости в таблице outbox.
  • Использование UNIQUE ограничений в таблице outbox для предотвращения дублирования.

Ответ 18+ 🔞

А, вот эта штука, транзакционный аутбокс! Ну, классика же, блядь. Сидишь такой, пишешь свой микросервис, и тут тебе на голову сваливается эта, блядь, фундаментальная проблема: как одновременно в базу записать и в Кафку плюнуть, чтобы нихуя не потерялось?

Так вот, слушай сюда, в чём, сука, трагедия. Представь: у тебя есть заказ. Ты его в свою постгресочку сохранил — всё, красота, транзакция коммитится. И тут же, прям в этом же методе, пытаешься отправить событие «ЗаказСоздан» в очередь. А Кафка, зараза, лежит! Или сеть просела. И что получается? Данные-то в базе уже есть, а событие — нихуя. Другие сервисы про новый заказ не узнают. Система в консистентном состоянии, но консистентно-то она — в пизду! Это и есть тот самый «dual write problem», от которого все волосы на жопе встают дыбом.

И вот тут выходит на сцену наш спаситель — паттерн «Транзакционный исходящий ящик». Суть его проста, как три копейки, но гениальна, блядь.

Фаза первая, атомарная: Вместо того чтобы сразу лезть в брокер, ты это самое событие записываешь в специальную таблицу в БД. В ТУ ЖЕ САМУЮ, МАТЬ ЕГО, ТРАНЗАКЦИЮ, что и бизнес-данные. Получается одна атомарная операция: либо всё сохранилось (и заказ, и событие в аутбоксе), либо всё откатилось нахуй. Красота!

Фаза вторая, фоновая: Потом отдельный, ебученький фоновый воркер (джоб, шедулер, называй как хочешь) просыпается, смотрит в эту таблицу: «Ага, тут у нас новенькие, неотправленные событяшки лежат». Берёт их, аккуратно так, и начинает пихать в Кафку. Отправил успешно — помечает в таблице, что всё, мол, «опубликовано». Можно даже удалить, чтобы таблица не раздувалась, как жаба.

Вот, смотри, как это в коде выглядит, если не трогать сам код, как ты просил:

// 1. Это наша сущность для таблички outbox_event. Как конверт для письма, блядь.
@Entity
@Table(name = "outbox_event")
public class OutboxEvent {
    @Id
    private String id = UUID.randomUUID().toString();
    private String aggregateId; // ID заказа, например
    private String aggregateType; // "Order"
    private String eventType; // "OrderCreated"
    @Lob
    private String payload; // Само событие в JSON, тут вся соль
    private LocalDateTime createdAt = LocalDateTime.now();
    private boolean published = false; // Флажок "отправлено или нет"
    // Геттеры и сеттеры...
}

// 2. Сервис, который создаёт заказ. Всё в одной транзакции, ёпта!
@Service
@Transactional
public class OrderService {
    @Autowired
    private OrderRepository orderRepo;
    @Autowired
    private OutboxEventRepository outboxRepo;

    public void createOrder(CreateOrderCommand command) {
        // Сохраняем сам заказ
        Order order = new Order(command.getCustomerId(), command.getItems());
        orderRepo.save(order);

        // А ТЕПЕРЬ ВНИМАНИЕ, БЛЯДЬ! Не лезем в Кафку, а пишем в свою же базу.
        OutboxEvent event = new OutboxEvent();
        event.setAggregateId(order.getId());
        event.setAggregateType("Order");
        event.setEventType("OrderCreated");
        event.setPayload(
            "{"orderId":"" + order.getId() + "","total":" + order.getTotal() + "}"
        );
        outboxRepo.save(event);
        // Всё. Транзакция коммитится разом. Или не коммитится вовсе.
    }
}

// 3. А это наш фоновый труженик, который будет выгребать из ящика и рассылать.
@Component
public class OutboxPublisher {
    @Autowired
    private OutboxEventRepository outboxRepo;
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    @Scheduled(fixedDelay = 5000) // Каждые 5 секунд просыпается и чекает
    @Transactional
    public void publishEvents() {
        List<OutboxEvent> events = outboxRepo.findByPublishedFalse();
        for (OutboxEvent event : events) {
            try {
                // Вот теперь, наконец, плюём в брокер
                kafkaTemplate.send("order-events", event.getEventType(), event.getPayload());
                // Помечаем, что отправили, чтобы второй раз не тырить
                event.setPublished(true);
                outboxRepo.save(event);
            } catch (Exception e) {
                // Ой, всё. Ну, в следующий раз попробуем, авось Кафка очухается.
            }
        }
        // Тут можно подчищать старые, отправленные события, чтобы таблица не росла до овердохуища
    }
}

Что хорошего?

  • Надёжность, блядь, на уровне! Даже если весь твой сервис накрылся медным тазом сразу после сохранения заказа, событие не потеряется. Оно тихонько лежит в табличке и ждёт своего часа. Гарантия «at-least-once» — это про него.
  • Решает ту самую проблему двойной записи. Нет ситуации «в базе есть, а в очереди — нет». Либо всё, либо нихуя.
  • Прост как валенок. Никакой двухфазной коммит, эта, её мать, распределённой транзакции.

А где подводные ебли?

  • Задержка, сука. Событие улетит не мгновенно, а когда воркер проснётся (через 5 сек, как в примере). Для многих сценариев — похуй, но если нужна мгновенная синхронность — не катит.
  • Может отправить дважды. Воркер мог пометить событие как отправленное, но упасть до сохранения этого факта. При следующем запуске он его снова увидит и отправит опять. Поэтому потребители должны быть идемпотентными — это святое правило, ёпта!
  • Ещё одна таблица в базе. Надо за ней следить, индексы настроить, чистить старьё.

А что, есть варианты покруче? Ага, есть, хитрая жопа! Можно вообще не париться с этой таблицей, а использовать CDC (Change Data Capture). Берёшь, например, Debezium, который, как хищный зверь, подключается к логу транзакций твоей базы (WAL в Постгресе, binlog в MySQL) и сам, блядь, выцепляет все изменения и шлёт их в топики. Красота, аутбокс вообще не нужен. Но это уже, понимаешь, отдельная история, со своими граблями и настройками.

Короче, паттерн — рабочий, проверенный, как грабли. Используй, не ошибёшься. Главное — помни про идемпотентность потребителей, а то будет тебе, как Герасиму с Муму, только в мире микросервисов.