Что такое паттерн Outbox?
Outbox (Transactional Outbox) — это паттерн, решающий проблему атомарности при необходимости одновременно обновить базу данных и опубликовать событие в брокер сообщений.
Проблема
Нельзя атомарно выполнить запись в БД и отправку в Kafka. Возможны ситуации:
- Запись в БД прошла, отправка в Kafka — нет (потеря события).
- Отправка в Kafka прошла, запись в БД — нет (фантомное событие).
Решение
Событие записывается в специальную таблицу outbox в той же транзакции, что и бизнес-данные. Отдельный процесс читает эту таблицу и публикует события в брокер.
Пример
┌─── Одна транзакция ───────────────┐
│ 1. UPDATE accounts SET balance... │
│ 2. INSERT INTO outbox (event...) │
└───────────────────────────────────┘
│
▼
┌─── Отдельный процесс ────────────┐
│ Читает outbox → Публикует в │
│ Kafka → Помечает как отправлено │
└───────────────────────────────────┘
Реализация Outbox паттерна
// Таблица outbox
// CREATE TABLE outbox (
// id UUID PRIMARY KEY,
// aggregate_type VARCHAR(255),
// aggregate_id VARCHAR(255),
// event_type VARCHAR(255),
// payload JSONB,
// created_at TIMESTAMP DEFAULT now(),
// sent BOOLEAN DEFAULT false
// );
@Entity
@Table(name = "outbox")
public class OutboxEvent {
@Id
private UUID id;
private String aggregateType;
private String aggregateId;
private String eventType;
@Column(columnDefinition = "jsonb")
private String payload;
private Instant createdAt;
private boolean sent;
}
// Сервис, который атомарно сохраняет данные и событие
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
private final OutboxRepository outboxRepository;
@Transactional // Одна транзакция!
public void processPayment(PaymentRequest request) {
// 1. Бизнес-логика
Payment payment = new Payment(request);
payment.setStatus(PaymentStatus.COMPLETED);
paymentRepository.save(payment);
// 2. Сохраняем событие в outbox
OutboxEvent event = new OutboxEvent();
event.setId(UUID.randomUUID());
event.setAggregateType("Payment");
event.setAggregateId(payment.getId().toString());
event.setEventType("PaymentCompleted");
event.setPayload(objectMapper.writeValueAsString(
new PaymentCompletedEvent(payment.getId(), payment.getAmount())));
event.setCreatedAt(Instant.now());
outboxRepository.save(event);
}
}
// Polling Publisher — читает outbox и отправляет в Kafka
@Scheduled(fixedDelay = 1000)
@Transactional
public void publishOutboxEvents() {
List<OutboxEvent> events = outboxRepository.findBySentFalseOrderByCreatedAt();
for (OutboxEvent event : events) {
kafkaTemplate.send("payment-events", event.getAggregateId(), event.getPayload());
event.setSent(true);
outboxRepository.save(event);
}
}
Альтернативный подход – Debezium CDC (Change Data Capture)
Вместо polling используется Debezium, который отслеживает WAL (Write-Ahead Log) PostgreSQL и автоматически публикует записи из таблицы outbox в Kafka. Это более надёжно и эффективно:
Пример
{
"name": "outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.dbname": "payments",
"table.include.list": "public.outbox",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter"
}
}
На собеседовании: объясните проблему dual write (БД + брокер) и как Outbox её решает через единую транзакцию. Упомяните два способа публикации: polling и CDC (Debezium). Частая ошибка — не знать про Debezium как production-ready альтернативу polling.