Gymterview
middle

Что такое паттерн 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.