Wenn mehrere Microservices dieselben Daten brauchen, ist der naheliegende Ansatz: ein REST-Call pro Service. Das funktioniert zwar, aber skaliert nur begrenzt. Bei einem meiner Kunden lief genau dieses Setup: Ein zentraler Service verteilte Daten per REST wiederholt an mehrere Microservices, die dieselben Daten benötigten. Mehrfach dasselbe, synchron, an jeden Service einzeln.
Das Problem war nicht die Technik, sondern das Muster. Dieselben Daten werden redundant und synchron an jeden Empfänger geschickt - das skaliert nicht optimal, wenn die Zahl der Empfänger steigt.
Die Ausgangslage
Ein zentraler Microservice verarbeitete eingehende Daten und schickte Ergebnisse per REST-Call an 3 weitere Services. Jeder Service brauchte dieselben Daten, aber für unterschiedliche Zwecke - Reporting, Auditing, Backoffice.
Drei konkrete Probleme ergaben sich:
- Redundanz: Dieselben Daten wurden an 3 Services geschickt - 3 (nahezu) identische Payloads, 3x Serialisierung, 3x Netzwerk-Traffic. Bei jedem neuen Empfänger käme ein weiterer identischer Call dazu. Besonders schlimm: Die Aufrufe fanden synchron nacheinander statt, was unnötig inperformant war.
- Enge Kopplung: Der Sender kannte jeden Empfänger, da er in diesem Fall der Rest-Client war. Neuer Service? Sender anpassen, Retry-Logik ergänzen, Deployment koordinieren.
- Umständliches Transaktionsmanagement: Um sicherzustellen, dass die Daten vollständig an jedem Client ankommen, musste aufwändiges Transaktionsmanagement betrieben werden (SAGA-Pattern)
Schritt 1: REST durch Kafka ersetzen
Der erste Schritt war konzeptionell einfach: Statt 3 REST-Calls schreibt der Sender - als Producer - eine Nachricht in ein Topic. Jeder Consumer liest selbständig. Ein weiterer Vorteil: Die Veranwortung der Kommunikation wandert dadurch vollständig zum Erzeuger der Nachricht.
// Vorher: Sender kennt jeden Empfänger
@Service
@RequiredArgsConstructor
public class DataDistributionService {
private final ReportingClient reportingClient;
private final AuditingClient auditingClient;
private final BackofficeClient backofficeClient;
public void distribute(DataEvent event) {
reportingClient.send(event);
auditingClient.send(event);
backofficeClient.send(event);
}
}
// Nachher: Producer kennt nur das Topic
@Service
@RequiredArgsConstructor
public class DataEventProducer {
private final KafkaTemplate<String, DataEvent> kafkaTemplate;
public void publish(DataEvent event) {
kafkaTemplate.send("data-events", event.getId(), event);
}
}
Jeder Consumer bekommt seine eigene Consumer Group - so liest jeder Service unabhängig und in seinem eigenen Tempo. Fällt ein Service aus, holen die anderen trotzdem weiter Nachrichten ab:
@KafkaListener(topics = "data-events", groupId = "reporting-service")
public void handleEvent(DataEvent event) {
reportingService.process(event);
}
Der Effekt: Der Producer muss nicht wissen, wer seine Nachrichten liest. Neuer Service? Einfach eine neue Consumer Group anlegen - kein Deployment des Producers nötig. Open-Closed Principle erfüllt: offen für neue Consumer, geschlossen für Änderungen am Producer.
Schritt 2: JSON durch Avro ersetzen
Mit JSON als Serialisierungsformat lief das System zuverlässig. Aber zwei Probleme wurden schnell sichtbar:
- Schema-Versionierung bei Rolling Deployments: In Kubernetes laufen bei kontinuierlichen Deployments alte und neue Service-Versionen parallel. Ohne Schema-Verwaltung musste manuell sichergestellt werden, dass beide Versionen dieselben Nachrichtenformate verstehen.
- Payload-Größe: JSON-Nachrichten sind textbasiert und entsprechend groß. Bei hohem Durchsatz macht das einen Unterschied.
Die Lösung: Apache Avro mit Schema Registry.
{
"type": "record",
"name": "DataEvent",
"namespace": "com.example.events",
"fields": [
{"name": "id", "type": "string"},
{"name": "timestamp", "type": "long", "logicalType": "timestamp-millis"},
{"name": "payload", "type": "string"},
{"name": "source", "type": "string", "default": "unknown"}
]
}
Die Schema Registry prüft bei jeder Nachricht, ob das Schema kompatibel ist. Ein neues Feld mit Default-Wert? Kein Problem - backward compatible. Ein Pflichtfeld entfernen? Die Registry blockt das Deployment, bevor fehlerhafte Nachrichten ins Topic gelangen.
// Avro-spezifische Kafka-Konfiguration
@Bean
public ProducerFactory<String, DataEvent> producerFactory() {
Map<String, Object> config = new HashMap<>();
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, KafkaAvroSerializer.class);
config.put("schema.registry.url", schemaRegistryUrl);
return new DefaultKafkaProducerFactory<>(config);
}
Vorteile
- Entkopplung: Producer und Consumer kennen sich nicht. Neue Consumer brauchen kein Redeployment des Producers.
- Fehlertoleranz: Wenn ein Consumer ausfällt, liest er nach dem Restart einfach ab seinem letzten Offset weiter. Kein Datenverlust.
- Schema-Sicherheit: Die Registry verhindert inkompatible Änderungen. Breaking Changes fallen beim Deployment auf, nicht in Produktion.
- Performance: Avro-Nachrichten sind kompakter als JSON. Die binäre Serialisierung ist schneller.
- Replay: Bei Bugs oder Datenverlust können Consumer das Topic nochmal von vorne lesen.
Herausforderungen
- Lokale Entwicklung: Kafka und Schema Registry lokal aufsetzen ist mehr Aufwand als ein REST-Endpoint. Docker Compose mit Zookeeper, Kafka Broker und Schema Registry - drei Container statt keinem.
- Debugging: Bei REST sieht man Request und Response im Log. Bei Kafka muss man Topics, Offsets und Consumer Lag verstehen.
- Schema-Evolution: Die Kompatibilitätsregeln der Registry sind streng. Das ist gewollt, erfordert aber Disziplin beim Schema-Design. Felder als optional mit Default-Werten definieren wird zur Gewohnheit.
Fazit
Die Migration von REST zu Kafka hat die Service-Architektur grundlegend verändert. Statt synchroner Punkt-zu-Punkt-Kommunikation gibt es jetzt ein zentrales Event Log, aus dem jeder Service unabhängig liest. Avro und die Schema Registry sorgen dafür, dass die Nachrichtenformate kontrolliert evolvieren.
Der größte Gewinn ist nicht die Performance, sondern die Entkopplung. Services können unabhängig deployed, skaliert und gewartet werden. Das System ist robuster, weil der Ausfall eines Consumers keine Auswirkung auf den Producer oder andere Consumer hat.
Voraussetzungen: Spring Boot 3.x, Spring Kafka, Confluent Schema Registry, Apache Avro. Wer mehrere Services hat, die dieselben Daten per REST verteilt bekommen, sollte prüfen, ob ein Event-Streaming-Ansatz die bessere Architektur ist.