Aufbau robuster ereignisgesteuerter Microservices mit Outbox und Transaktionsprotokollen
Min-jun Kim
Dev Intern · Leapcell

Einleitung
In der Welt der Microservices ist die Erzielung nahtloser Kommunikation und Datenkonsistenz über verteilte Systeme hinweg eine ständige Herausforderung. Da Anwendungen in kleinere, unabhängige Dienste entkoppelt werden, wird die Notwendigkeit einer zuverlässigen Ereignisweitergabe von größter Bedeutung. Eine häufige Fallstrick entsteht, wenn ein Dienst versucht, eine lokale Zustandsänderung vorzunehmen und ein Ereignis zu veröffentlichen, das andere Dienste über diese Änderung informiert. Was passiert, wenn der Dienst zwischen der Bestätigung der lokalen Transaktion und der erfolgreichen Veröffentlichung des Ereignisses abstürzt? Dateninkonsistenz und verlorene Ereignisse können das gesamte System schnell lahmlegen. Dieser Artikel befasst sich damit, wie das "Outbox-Muster" in Verbindung mit Datenbank-Transaktionsprotokollen oder Polling diese Zuverlässigkeitslücke elegant schließen kann, um robuste und zuverlässige ereignisgesteuerte Microservices zu fördern. Wir werden die zugrunde liegenden Prinzipien, die praktische Implementierung und die unschätzbare Rolle untersuchen, die diese Techniken beim Aufbau hochverfügbarer und konsistenter verteilter Architekturen spielen.
Die Grundlage für zuverlässige Ereignisse
Bevor wir uns mit den Einzelheiten befassen, wollen wir ein gemeinsames Verständnis der Schlüsselbegriffe schaffen, die das Rückgrat dieser Diskussion bilden.
Microservices: Ein architektonischer Stil, der eine Anwendung als Sammlung lose gekoppelter, unabhängig bereitstellbarer Dienste strukturiert.
Ereignisgesteuerte Architektur (EDA): Ein Software-Design-Paradigma, bei dem lose gekoppelte Softwarekomponenten durch asynchrones Veröffentlichen und Abonnieren von Ereignissen interagieren.
Transaktionales Outbox-Muster: Ein Entwurfsmuster, das die atomare Ausführung einer lokalen Datenbanktransaktion und die Veröffentlichung eines entsprechenden Ereignisses sicherstellt. Anstatt ein Ereignis direkt zu veröffentlichen, wird das Ereignis zuerst in eine dedizierte "Outbox"-Tabelle innerhalb derselben Datenbanktransaktion wie die lokale Zustandsänderung gespeichert.
Datenbank-Transaktionsprotokoll (Change Data Capture - CDC): Eine sequentielle Aufzeichnung aller Änderungen, die an einer Datenbank vorgenommen wurden. Viele moderne Datenbanken (wie PostgreSQL, MySQL, SQL Server) verwalten diese Protokolle zu Wiederherstellungs- und Replikationszwecken. CDC-Tools nutzen diese Protokolle, um Änderungen an Daten in Echtzeit zu erfassen, ohne die Hauptanwendung zu beeinträchtigen.
Polling: Im Kontext des Outbox-Musters bezieht sich dies auf einen separaten Prozess, der die Outbox-Tabelle periodisch auf neue, unveröffentlichte Ereignisse abfragt und diese dann an einen Message Broker weiterleitet.
Das Problem des direkten Ereignisveröffentlichens
Betrachten wir einen UserService, der einen neuen Benutzer erstellen und dann ein UserCreatedEvent auslösen muss.
// Vereinfachtes Beispiel, nicht produktionsreif @Transactional public User createUser(User user) { userRepository.save(user); // Lokale Datenbanktransaktion eventPublisher.publish(new UserCreatedEvent(user.getId())); // Versuchen, Ereignis zu veröffentlichen return user; }
Wenn das System abstürzt, nachdem userRepository.save(user) bestätigt wurde, aber bevor eventPublisher.publish() die Nachricht erfolgreich gesendet hat, wird der Benutzer lokal erstellt, aber kein UserCreatedEvent wird jemals ausgelöst. Nachgelagerte Dienste, die auf dieses Ereignis angewiesen sind (z. B. ein EmailService zum Senden einer Willkommens-E-Mail), erhalten niemals die Benachrichtigung, was zu einem inkonsistenten Zustand führt.
Das Outbox-Muster zur Rettung
Das Outbox-Muster löst dieses Problem elegant, indem es Atomizität gewährleistet. Anstatt das Ereignis direkt zu veröffentlichen, werden die Ereignisdetails in einer Outbox-Tabelle innerhalb derselben Datenbanktransaktion wie die primäre Geschäftslogik gespeichert.
Implementierung mit einer Outbox-Tabelle
Lassen Sie uns unser UserService-Beispiel mit dem Outbox-Muster verfeinern.
Definieren Sie zuerst eine Outbox-Entität:
// Outbox.java @Entity @Table(name = "outbox") public class Outbox { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "aggregatetype", nullable = false) private String aggregateType; @Column(name = "aggregateid", nullable = false) private String aggregateId; @Column(name = "eventtype", nullable = false) private String eventType; @Column(columnDefinition = "jsonb", nullable = false) // Oder VARBINARY für andere DBs private String payload; // JSON-Darstellung des Ereignisses @Column(name = "createdat", nullable = false) private Instant createdAt; @Column(name = "processedat") private Instant processedAt; // Zum Markieren von Ereignissen als verarbeitet // Getter und Setter }
Nun integriert der UserService die Outbox:
// UserService.java @Service public class UserService { private final UserRepository userRepository; private final OutboxRepository outboxRepository; private final ObjectMapper objectMapper; // Für JSON-Serialisierung public UserService(UserRepository userRepository, OutboxRepository outboxRepository, ObjectMapper objectMapper) { this.userRepository = userRepository; this.outboxRepository = outboxRepository; this.objectMapper = objectMapper; } @Transactional public User createUser(User user) throws JsonProcessingException { // 1. Lokale Geschäftslogik ausführen userRepository.save(user); // 2. Das Ereignis erstellen UserCreatedEvent userCreatedEvent = new UserCreatedEvent(user.getId(), user.getEmail()); // 3. Das Ereignis innerhalb derselben Transaktion in die Outbox-Tabelle speichern Outbox outboxEntry = new Outbox(); outboxEntry.setAggregateType("User"); outboxEntry.setAggregateId(user.getId().toString()); outboxEntry.setEventType("UserCreatedEvent"); outboxEntry.setPayload(objectMapper.writeValueAsString(userCreatedEvent)); outboxEntry.setCreatedAt(Instant.now()); outboxEntry.setProcessedAt(null); // Noch nicht verarbeitet outboxRepository.save(outboxEntry); return user; } }
Mit diesem Ansatz werden, wenn die Transaktion erfolgreich abgeschlossen wird, sowohl der Benutzer als auch der Outbox-Eintrag gespeichert. Wenn die Transaktion fehlschlägt, schlagen beide fehl. Dies garantiert Atomizität.
Veröffentlichen der Ereignisse: Polling vs. Transaktionsprotokoll (CDC)
Sobald Ereignisse in der Outbox-Tabelle vorhanden sind, müssen sie zuverlässig an einen Message Broker (z. B. Kafka, RabbitMQ) gesendet werden. Dafür gibt es zwei primäre Strategien:
-
Polling: Ein separater, unabhängiger Prozess (oft ein geplanter Job oder ein dedizierter Microservice) fragt periodisch die
outbox-Tabelle auf neue, unverarbeitete Ereignisse ab.// Beispiel Poller Service (Pseudocode) @Service public class OutboxPollerService { private final OutboxRepository outboxRepository; private final MessageBrokerPublisher messageBrokerPublisher; private final ObjectMapper objectMapper; public OutboxPollerService(OutboxRepository outboxRepository, MessageBrokerPublisher messageBrokerPublisher, ObjectMapper objectMapper) { this.outboxRepository = outboxRepository; this.messageBrokerPublisher = messageBrokerPublisher; this.objectMapper = objectMapper; } @Scheduled(fixedRate = 5000) // Alle 5 Sekunden abfragen @Transactional public void processOutbox() { List<Outbox> unprocessedEvents = outboxRepository.findTop100ByProcessedAtIsNullOrderByCreatedAtAsc(); // Einen Stapel abrufen for (Outbox event : unprocessedEvents) { try { // An Message Broker veröffentlichen Object eventPayload = objectMapper.readValue(event.getPayload(), Class.forName(event.getEventType())); messageBrokerPublisher.publish(event.getEventType(), eventPayload); // Als verarbeitet markieren, wenn die Veröffentlichung erfolgreich war event.setProcessedAt(Instant.now()); outboxRepository.save(event); // Zur Sicherheit in derselben Transaktion aktualisieren } catch (Exception e) { // Fehler protokollieren, hier könnte ein Wiederholungsmechanismus implementiert werden // Wichtig: Als verarbeitet markieren, wenn die Veröffentlichung fehlschlägt. // Der Poller wird ihn beim nächsten Lauf erneut abrufen. } } } }Vorteile: Relativ einfach zu implementieren, keine speziellen Datenbankfunktionen erforderlich. Nachteile: Latenz kann höher sein (abhängig vom Abfrageintervall), kann die Datenbank belasten, wenn das Abfrageintervall zu kurz ist oder der Durchsatz sehr hoch ist, erfordert sorgfältige Behandlung von Nebenläufigkeit und Garantien für die Reihenfolge. Deduplizierung auf der Verbraucherseite ist oft notwendig.
-
Datenbank-Transaktionsprotokoll (Change Data Capture - CDC): Dies ist oft die bevorzugte und robusteste Methode. Anstatt abzufragen, überwacht ein CDC-Tool (z. B. Debezium für Kafka oder datenbankspezifische CDC-Lösungen) das Transaktionsprotokoll der Datenbank auf Änderungen an der
outbox-Tabelle. Wenn ein Eintrag in dieoutboxeingefügt wird, erfasst das CDC-Tool diese Änderung sofort und streamt sie als Nachricht an einen Message Broker.So funktioniert es:
- Die Anwendung erstellt und speichert
Outbox-Einträge wie zuvor. - Ein CDC-Connector (z. B. Debezium) stellt eine Verbindung zum Transaktionsprotokoll der Datenbank her (z. B. WAL von PostgreSQL, Binlog von MySQL).
- Der Connector liest kontinuierlich das Protokoll und erkennt Einfügungen in die
outbox-Tabelle. - Für jeden neuen
outbox-Eintrag erstellt der Connector eine Nachricht (oft mit den vollständigen Zeilendaten) und veröffentlicht sie in einem Kafka-Topic (oder einem anderen Message Broker). - Ein separater "Event Dispatcher"-Dienst (oder direkt der Verbraucher) abonniert dieses Kafka-Topic, liest die
outbox-Einträge, wandelt sie in Geschäftsereignisse um und veröffentlicht sie dann in das relevante anwendungsspezifische Topic. Dieoutbox-Einträge werden dann typischerweise von einem anderen leichten Prozess gelöscht oder als veröffentlicht markiert, um die Tabelle sauber zu halten.
Vorteile: Nahezu Echtzeit-Ereignisübermittlung, minimale Auswirkungen auf die Anwendungsdatenbank (CDC-Tools lesen aus Protokollen, nicht aus Live-Tabellen), zuverlässige Reihenfolge (Ereignisse werden in der Reihenfolge gestreamt, in der sie an das Protokoll übermittelt wurden), hoch skalierbar. Nachteile: Erfordert externe CDC-Tools, komplexerer Einrichtungs- und Infrastrukturmanagement, erfordert, dass die Datenbank CDC unterstützt.
- Die Anwendung erstellt und speichert
Anwendungsszenarien
Das Outbox-Muster in Kombination mit Polling oder CDC eignet sich ideal für Situationen, in denen:
- Atomares Ereignisveröffentlichen: Sie müssen sicherstellen, dass eine lokale Datenbanktransaktion und eine zugehörige Ereignisveröffentlichung entweder beide erfolgreich sind oder beide fehlschlagen.
- Inter-Service-Kommunikation: Dienste müssen Zustandsänderungen zuverlässig an andere Dienste kommunizieren, ohne direkte Kopplung.
- Event Sourcing (teilweise): Obwohl kein vollständiges Event Sourcing, ist es ein Schritt dazu, der sicherstellt, dass alle Zustandsänderungen auch durch Ereignisse repräsentiert werden.
- CQRS (Command-Query Responsibility Segregation) Updates: Zum zuverlässigen Aktualisieren von Lesemodellen in einer CQRS-Architektur.
Fazit
Das Outbox-Muster, ob durch periodisches Polling oder den raffinierteren Ansatz der Nutzung von Datenbank-Transaktionsprotokollen (CDC) implementiert, ist ein Eckpfeiler für den Aufbau zuverlässiger ereignisgesteuerter Microservices. Es überbrückt effektiv die Lücke zwischen lokaler Transaktionskonsistenz und verteilter eventualer Konsistenz und stellt sicher, dass kritische Geschäftsereignisse niemals verloren gehen und Ihr verteiltes System kohärent bleibt. Durch die Anwendung dieser Muster können Entwickler zuversichtlich hochgradig robuste und skalierbare Architekturen erstellen, bei denen jede bestätigte Schreiboperation zuverlässig ihre entsprechende externe Benachrichtigung auslöst. Dieses Muster ermöglicht es Microservices, effektiv zu kommunizieren und die Datenintegrität über die gesamte verteilte Landschaft hinweg aufrechtzuerhalten.

