Ответ
Transactional Outbox (Транзакционный исходящий ящик) — это паттерн, используемый в распределенных системах для надежной публикации событий или сообщений, гарантируя, что они будут доставлены только если соответствующее изменение данных в БД успешно сохранено.
Проблема, которую он решает: В микросервисной архитектуре часто нужно обновить локальную базу данных и отправить событие (например, в Kafka) в одной транзакции. Однако это невозможно в распределенной транзакции (2PC сложен и ненадежен). Паттерн Outbox решает это, разделяя операцию на две атомарные фазы.
Принцип работы:
- Фаза 1 (Атомарная запись): Вместо прямой отправки в брокер, событие записывается в специальную таблицу
outboxв той же транзакции, что и бизнес-данные. - Фаза 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) и сам, блядь, выцепляет все изменения и шлёт их в топики. Красота, аутбокс вообще не нужен. Но это уже, понимаешь, отдельная история, со своими граблями и настройками.
Короче, паттерн — рабочий, проверенный, как грабли. Используй, не ошибёшься. Главное — помни про идемпотентность потребителей, а то будет тебе, как Герасиму с Муму, только в мире микросервисов.