Представь ситуацию: ты разрабатываешь , который должен одновременно создавать заказ в базе данных И отправлять уведомление в Kafka о том, что заказ создан. Звучит просто, но на практике это все гораздо сложнее.Order Service
Вот что может пойти не так:
- Заказ записался в БД, но сообщение не отправилось в брокер (ошибка сети) → система аналитики никогда не узнает о заказе.
- Сообщение отправилось в брокер, но транзакция в БД откатилась (не хватило места) → другие сервисы получили уведомление о несуществующем заказе.
- Сервис упал ровно между записью в БД и отправкой сообщения → система навсегда осталась в рассинхроне.
Может заиспользуем 2PC (Two-Phase Commit)?
Теоретически 2PC решает проблему атомарности между базой и брокером, но на практике:
- Не все брокеры поддерживают 2PC (например, Kafka).
- Сильно связывает бэкенд-сервис с инфраструктурой.
- Убивает производительность распределенными блокировками.
- Один недоступный участник = вся транзакция висит.
Решение: Transactional Outbox
Паттерн Transactional Outbox решает проблему атомарности без использования 2PC. Идея простая, но гениальная: вместо прямой отправки сообщений в брокер, мы используем базу данных как буфер.
- В рамках ОДНОЙ транзакции записываем изменения в бизнес-таблицы
и складываем событие в специальную таблицуorders.outbox - Отдельный фоновый процесс (Message Relay или CDC-инструмент) читает таблицу
и отправляет сообщения в брокер.outbox - После успешной отправки помечаем сообщения в таблице как обработанные (или удаляем).
Ключевая фишка: И бизнес-данные, и события лежат в одной реляционной БД, поэтому используется классическая локальная транзакция. Атомарность гарантирована на 100%.
Пример структуры таблиц (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 для надежной отправки событий о своих локальных успехах или ошибках. Без него Сага просто развалится.
Тонкости реализации: Идемпотентность
Сообщения могут дублироваться! может отправить событие в Kafka, но упасть до обновления статуса Message Relay в таблице. При перезапуске он отправит это событие снова. Поэтому каждое событие должно иметь уникальный ID.processed_at
// В событии обязательно указываем уникальный ID (на стороне Продюсера)
OutboxEvent event = OutboxEvent.builder()
.id(UUID.randomUUID()) // Важно для дедупликации!
.eventType("OrderCreated")
.eventData(orderData)
.build();
А потребители (Консьюмеры) обязаны проверять эти дубликаты у себя:
@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).
@Scheduled(cron = "0 0 2 * * *") // Каждый день в 2:00 ночи
public void cleanupOldEvents() {
// Удаляем успешно обработанные события старше 30 дней
outboxRepository.deleteProcessedEventsBefore(
LocalDateTime.now().minusDays(30)
);
}
Итого: Стоит ли использовать?
| Плюсы | Минусы |
|---|---|
| Атомарность: Либо ВСЁ записалось, либо ничего. | Задержка: События отправляются не мгновенно (Eventual Consistency). |
| Надежность: События не теряются даже при падении сервиса. | Сложность: Нужно думать об идемпотентности и дубликатах. |
| Простота: Не нужен тяжелый 2PC. | Оверхед: Дополнительная нагрузка на БД (запись в Outbox) и процесс Relay. |
Вердикт
- Обязательно используй Outbox, когда критично не потерять сообщения (платежи, заказы) в микросервисной архитектуре.
- Не используй для простых логов или метрик, где потеря одного сообщения не критична.
Но есть системы, где outbox не подойдет. Подробнее можешь прочитать в этом посте