In unseren bisherigen Artikeln über SAGA und CQRS haben wir eine Sache stillschweigend vorausgesetzt:
“Der Service speichert die Bestellung in der Datenbank UND sendet ein Event an den Message Broker.”
Klingt trivial, oder? Hier ist der typische Code, den man in hunderten Tutorials sieht:
@Transactional
public void createOrder(Order order) {
// 1. Speichern in der DB
orderRepository.save(order);
// 2. Event senden (z.B. RabbitMQ)
eventPublisher.publish(new OrderCreatedEvent(order.getId()));
}
Doch dieser Code ist in einem verteilten System eine tickende Zeitbombe. Er leidet unter dem sogenannten Dual Write Problem.
Das Problem: Das Dual Write Dilemma
Wir versuchen, zwei verschiedene Systeme (Datenbank und Message Broker) synchron zu halten, ohne eine langsame und blockierende “verteilte Transaktion” (wie 2-Phase-Commit / 2PC) zu nutzen. Das kann auf zwei Arten schiefgehen:
- Szenario A (DB Commit, Broker Fail): Die Bestellung wird gespeichert, die Datenbank-Transaktion committet erfolgreich. Aber genau in dem Moment, wo wir das Event senden wollen, ist der Broker nicht erreichbar oder das Netzwerk fällt aus.
- Ergebnis: Die Bestellung ist da, aber niemand weiß davon. Die SAGA läuft nicht weiter, das CQRS-Dashboard aktualisiert sich nicht. Das System ist inkonsistent.
- Szenario B (Event Sent, DB Rollback): Wir drehen es um und senden das Event zuerst. Das klappt. Aber dann wirft die Datenbank beim Speichern einen Fehler (z.B. Constraint Violation) und rollt die Transaktion zurück.
- Ergebnis: Das Event “Bestellung angelegt” geistert durch das System und löst Folgewirkungen aus, obwohl die Bestellung gar nicht existiert.
Wir brauchen eine Möglichkeit, Datenbank-Operation und Event-Versand atomar zu machen. Entweder beides passiert, oder keines von beidem.
Die Lösung: Transactional Outbox Pattern
Die Lösung liegt in der Nutzung dessen, was wir bereits haben: Die ACID-Transaktion unserer lokalen Datenbank.
Anstatt das Event direkt an RabbitMQ oder Kafka zu senden, speichern wir es in derselben Datenbank-Transaktion in einer speziellen Tabelle: der OUTBOX.
Der Ablauf sieht dann so aus:
- Transaktion START
INSERT INTO orders ...(Die Business-Daten)INSERT INTO outbox ...(Das Event als JSON-Payload)- Transaktion COMMIT
Da beide Schritte in derselben DB-Transaktion laufen, haben wir die Garantie: Wenn die Bestellung gespeichert wird, wird auch das Event gespeichert. Wenn die Bestellung fehlschlägt (Rollback), wird auch kein Event in die Outbox geschrieben.
Wie kommt das Event zum Broker? (Der Relay)
Jetzt liegt das Event sicher in unserer outbox-Tabelle. Ein separater Prozess (der Message Relay) muss es nun auslesen und an den Broker senden.
Dafür gibt es zwei gängige Ansätze:
1. Polling Publisher (Einfach & Robust)
Ein kleiner Hintergrund-Job (z.B. mit @Scheduled in Spring Boot) fragt die Tabelle regelmäßig ab:
SELECT * FROM outbox WHERE processed = false- Für jeden Eintrag: Sende an Broker.
- Wenn Senden erfolgreich:
DELETE FROM outbox(oder markiere alsprocessed).
Dieser Ansatz ist einfach zu implementieren und reicht für viele Anwendungsfälle völlig aus.
2. Transaction Log Tailing (High Performance)
Für Systeme mit extrem hohem Durchsatz nutzt man Tools wie Debezium. Diese Tools lesen direkt das Transaktions-Log der Datenbank (z.B. MySQL Binlog). Sobald ein Eintrag in die outbox-Tabelle geschrieben wird, erkennt Debezium das und pusht es automatisch an Kafka. Das ist performanter, aber komplexer im Setup.
Implementierung mit Spring Boot (Polling Ansatz)
Lass uns den einfachen Polling-Ansatz implementieren.
Schritt 1: Die Outbox Entity
Wir brauchen eine Tabelle, um unsere Events zwischenzuspeichern.
@Entity
@Table(name = "outbox")
public class OutboxEvent {
@Id
@GeneratedValue
private UUID id;
private String aggregateType; // z.B. "Order"
private String aggregateId; // z.B. "123"
private String eventType; // z.B. "OrderCreated"
@Lob
private String payload; // Das Event als JSON
private LocalDateTime createdAt = LocalDateTime.now();
// Getter, Setter...
}
Schritt 2: Der Service (Atomic Save)
Unser Business-Service speichert nun Event und Order zusammen in einer Transaktion.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper; // Für JSON Konvertierung
@Transactional // WICHTIG: Eine Transaktion für beides!
public void createOrder(Order order) {
// 1. Business State speichern
orderRepository.save(order);
// 2. Event in die Outbox speichern (statt direkt zu senden)
OrderCreatedEvent event = new OrderCreatedEvent(order.getId());
OutboxEvent outboxEvent = new OutboxEvent();
outboxEvent.setAggregateType("Order");
outboxEvent.setAggregateId(order.getId().toString());
outboxEvent.setEventType("OrderCreated");
try {
outboxEvent.setPayload(objectMapper.writeValueAsString(event));
} catch (JsonProcessingException e) {
throw new RuntimeException("Error serializing event", e);
}
outboxRepository.save(outboxEvent);
}
}
Schritt 3: Der Polling Publisher (Der Postbote)
Ein separater Job holt die Briefe ab und wirft sie ein.
@Component
public class OutboxRelay {
private final OutboxRepository outboxRepository;
private final RabbitTemplate rabbitTemplate;
@Scheduled(fixedDelay = 2000) // Alle 2 Sekunden prüfen
public void processOutbox() {
// Hole alle ältesten Events zuerst (FIFO)
List<OutboxEvent> events = outboxRepository.findAllByOrderByCreatedAtAsc();
for (OutboxEvent event : events) {
try {
// Sende an Broker
rabbitTemplate.convertAndSend("exchange", event.getEventType(), event.getPayload());
// Lösche aus Outbox nach Erfolg
outboxRepository.delete(event);
} catch (Exception e) {
// Fehlerbehandlung: Einfach stehen lassen, der nächste Lauf probiert es erneut.
// Achtung: Hier sollte man Dead-Letter-Logik einbauen, um Endlosschleifen zu vermeiden.
System.err.println("Failed to send event: " + event.getId());
}
}
}
}
Hinweis: In einem echten Produktions-System würde man hier noch Locking einbauen (z.B. mit ShedLock), damit bei mehreren Instanzen nicht alle denselben Eintrag senden.
Fazit
Das Outbox Pattern ist der Klebstoff, der Microservice-Architekturen zuverlässig macht. Es garantiert “At-Least-Once Delivery”: Jedes Event wird mindestens einmal gesendet. Das bedeutet im Umkehrschluss, dass der Empfänger idempotent sein muss (falls das Event doppelt gesendet wird, weil der DB-Delete nach dem Senden fehlschlägt), aber wir verlieren niemals Daten.
Ohne Outbox Pattern ist Event-Driven Architecture ein Glücksspiel. Mit Outbox Pattern wird sie zur soliden Ingenieurskunst.
