
Hallo zusammen. In diesem Artikel werde ich erzählen, warum wir bei Avito vor neun Monaten Kafka gewählt haben und was es genau ist. Ich werde einen der Anwendungsfälle vorstellen – den Nachrichtenbroker. Schließlich werden wir darüber sprechen, welche Vorteile wir aus dem Einsatz von Kafka als Dienst erhalten haben.
Problem

Zunächst ein wenig Kontext. Vor einiger Zeit begannen wir, von einer monolithischen Architektur abzuweichen, und heute gibt es bei Avito bereits mehrere Hundert verschiedene Dienste. Diese haben ihre eigenen Speicherlösungen, ihren eigenen Technologiestack und sind für ihre eigenen Teile der Geschäftslogik verantwortlich.
Ein Problem bei einer großen Anzahl von Diensten ist die Kommunikation. Dienst A möchte oft Informationen erhalten, die Dienst B hat. In diesem Fall kontaktiert Dienst A Dienst B über eine synchrone API. Dienst V möchte wissen, was bei den Diensten G und D passiert, und diese wiederum interessieren sich für die Dienste A und B. Wenn es viele solcher "neugierigen" Dienste gibt, werden die Verbindungen zwischen ihnen zu einem verworrenen Knäuel.
Dabei kann Dienst A jederzeit nicht verfügbar werden. Was sollen in diesem Fall Dienst B und alle anderen von ihm abhängigen Dienste tun? Und wenn für die Durchführung einer Geschäftsoperation eine Kette von aufeinanderfolgenden synchronen Aufrufen erforderlich ist, erhöht sich die Wahrscheinlichkeit des Ausfalls der gesamten Operation (je länger diese Kette, desto höher das Risiko).
Technologieauswahl

Okay, die Probleme sind klar. Man kann sie beheben, indem man ein zentrales Nachrichtenaustauschsystem zwischen den Diensten einrichtet. Nun müssen die einzelnen Dienste nur über dieses Nachrichtenaustauschsystem Bescheid wissen. Darüber hinaus muss das System hochverfügbar und horizontal skalierbar sein und im Falle eines Ausfalls einen Puffer für eingehende Anfragen zur späteren Verarbeitung speichern.
Lass uns jetzt die Technologie auswählen, auf der die Nachrichtenzustellung umgesetzt wird. Dafür sollten wir zunächst verstehen, was wir von ihr erwarten:
- Nachrichten zwischen den Diensten dürfen nicht verloren gehen;
- Nachrichten können dupliziert werden;
- Nachrichten können über mehrere Tage hinweg gespeichert und gelesen werden (persistenter Puffer);
- Dienste können sich für für sie interessante Daten anmelden;
- Mehrere Dienste können dieselben Daten lesen;
- Nachrichten können detaillierte, umfangreiche payloads enthalten (event-carried state transfer);
- Manchmal ist eine Garantie für die Reihenfolge der Nachrichten erforderlich.
Es war uns auch von entscheidender Bedeutung, ein möglichst skalierbares und zuverlässiges System mit hoher Durchsatzrate (mindestens 100.000 Nachrichten mit mehreren Kilobyte pro Sekunde) auszuwählen.
In dieser Phase trennten wir uns von RabbitMQ (schwer stabil bei hohen rps), PGQ von SkyTools (nicht schnell genug und schlecht skalierbar) und NSQ (nicht persistent). All diese Technologien werden in unserem Unternehmen genutzt, aber sie waren für die zu lösende Aufgabe nicht geeignet.
Dann begannen wir, neue Technologien zu betrachten — Apache Kafka, Apache Pulsar und NATS Streaming.
Zuerst schlossen wir Pulsar aus. Wir entschlossen uns, dass Kafka und Pulsar sehr ähnliche Lösungen sind. Und obwohl Pulsar von großen Unternehmen erprobt ist, neuer ist und theoretisch eine niedrigere Latenz bietet, beschlossen wir, von diesen beiden Kafka als de facto Standard für solche Aufgaben beizubehalten. Wahrscheinlich werden wir in Zukunft zu Apache Pulsar zurückkehren.
Und so blieben zwei Kandidaten: NATS Streaming und Apache Kafka. Wir haben beide Lösungen ziemlich ausführlich untersucht, und beide erfüllten die Anforderungen. Aber letztendlich hatten wir Bedenken wegen der relativen Neuheit von NATS Streaming (und dass einer der Hauptentwickler, Tyler Treat, das Projekt verlassen und sein eigenes — Liftbridge — gegründet hat). Zudem bot der Clustering-Modus von NATS Streaming keine starke horizontale Skalierbarkeit (möglicherweise ist das nach der Einführung des Partitionierungsmodus im Jahr 2017 bereits kein Problem mehr).
Dennoch ist NATS Streaming eine coole Technologie, die in Go geschrieben wurde und Unterstützung von der Cloud Native Computing Foundation hat. Im Gegensatz zu Apache Kafka benötigt sie kein Zookeeper für die Betriebsführung (möglicherweise ), da sie intern RAFT implementiert. Zudem ist NATS Streaming einfacher zu administrieren. Wir schließen nicht aus, dass wir in Zukunft zu dieser Technologie zurückkehren werden.
Dennoch wurde unser Gewinner heute Apache Kafka. In unseren Tests zeigte es sich als ziemlich schnell (über eine Million Nachrichten pro Sekunde beim Lesen und Schreiben bei einer Nachrichtenlänge von 1 Kilobyte), zuverlässig, gut skalierbar und durch die Praxis in großen Unternehmen erprobt. Darüber hinaus unterstützen mehrere große kommerzielle Unternehmen Kafka (wir nutzen zum Beispiel die Confluent-Version), und Kafka hat ein gut ausgebautes Ökosystem.
Überblick über Kafka
Bevor wir beginnen, kann ich sofort ein großartiges Buch empfehlen — «Kafka: The Definitive Guide» (es gibt auch eine russische Übersetzung, aber die Begriffe sind etwas verwirrend). Darin finden Sie Informationen, die für das grundlegende Verständnis von Kafka und sogar etwas mehr erforderlich sind. Die Dokumentation von Apache und der Blog von Confluent sind ebenfalls hervorragend geschrieben und leicht zu lesen.
Schauen wir uns also an, wie Kafka aus der Vogelperspektive aussieht. Die grundlegende Topologie von Kafka besteht aus Producer, Consumer, Broker und Zookeeper.
Broker

Der Broker ist verantwortlich für die Speicherung Ihrer Daten. Alle Daten werden in binärer Form gespeichert, und der Broker weiß kaum, was sie sind und wie ihre Struktur aussieht.
Jeder logische Ereignistyp befindet sich normalerweise in seinem eigenen Topic. Zum Beispiel kann ein Ereignis zur Erstellung eines Listings in das Topic item.created und ein Ereignis zur Änderung davon in item.changed gelangen. Topics können als Klassifizierer für Ereignisse betrachtet werden. Auf der Ebene des Topics können Konfigurationsparameter wie definiert werden:
- die Menge der gespeicherten Daten und/oder ihr Alter (retention.bytes, retention.ms);
- der Replikationsfaktor (replication factor);
- die maximale Größe einer Nachricht (max.message.bytes);
- die minimale Anzahl an übereinstimmenden Replikaten, bei der Daten in das Topic geschrieben werden können (min.insync.replicas);
- die Möglichkeit, einen Failover auf eine asynchrone, nachfolgende Replik mit potenziellem Datenverlust durchzuführen (unclean.leader.election.enable);
- und viele andere ().
Jedes Topic wird seinerseits in eine oder mehrere Partitionen unterteilt. In den Partitionen landen letztendlich die Ereignisse. Wenn im Cluster mehr als ein Broker vorhanden ist, werden die Partitionen gleichmäßig auf alle Broker verteilt (so weit wie möglich), was es ermöglicht, die Schreib- und Leselast für ein Topic gleichzeitig auf mehrere Broker zu verteilen.
Auf der Festplatte werden die Daten für jede Partition in Form von Segmentdateien gespeichert, die standardmäßig gleich einem Gigabyte sind (gesteuert über log.segment.bytes). Eine wichtige Eigenschaft ist, dass Daten aus Partitionen (bei Erreichen der Retention) genau in Segmenten gelöscht werden (man kann kein einzelnes Ereignis aus einer Partition löschen, sondern nur ein ganzes Segment, und zwar nur inaktives).
Zookeeper
Zookeeper fungiert als Speicher für Metadaten und Koordinator. Er kann sagen, ob die Broker noch aktiv sind (man kann dies über zookeeper-shell mit dem Befehl ls /brokers/ids), welcher Broker der Controller ist (get /controller), sind die Partitionen mit ihren Replikaten synchronisiert (get /brokers/topics/topic_name/partitions/partition_number/state). Zunächst werden sowohl Producer als auch Consumer zu Zookeeper gehen, um herauszufinden, auf welchem Broker welche Themen und Partitionen gespeichert sind. In Fällen, in denen der Replikationsfaktor für ein Thema größer als 1 ist, wird Zookeeper angeben, welche Partitionen die Führenden sind (dort werden Schreibvorgänge durchgeführt und von dort wird auch gelesen). Im Falle eines Broker-Ausfalls werden in Zookeeper die Informationen über die neuen Führungspartitionen aufgezeichnet (ab Version 1.1.0 asynchron, ).
In älteren Versionen von Kafka war Zookeeper auch für die Speicherung von Offsets verantwortlich, aber jetzt werden sie in einem speziellen Thema __consumer_offsets auf dem Broker gespeichert (obwohl Sie weiterhin Zookeeper für diese Zwecke verwenden können).
Der einfachste Weg, Ihre Daten in eine Katastrophe zu verwandeln, ist der Verlust von Informationen mit Zookeeper. In einem solchen Szenario wird es sehr schwierig sein, zu verstehen, was und wo gelesen werden muss.
Producer
Ein Producer ist meist ein Dienst, der direkt Daten in Apache Kafka schreibt. Der Producer wählt das Thema aus, in dem seine thematischen Nachrichten gespeichert werden, und beginnt, Informationen dort zu schreiben. Beispielsweise könnte ein Producer ein Anzeige-Service sein. In diesem Fall würde er Ereignisse in die thematischen Themen senden, wie 'Anzeige erstellt', 'Anzeige aktualisiert', 'Anzeige gelöscht' usw. Jedes Ereignis stellt dabei ein Schlüssel-Wert-Paar dar.
Standardmäßig werden alle Ereignisse rundenbasiert auf die Partitionen des Themas verteilt, sofern kein Schlüssel angegeben ist (was die Reihenfolge verliert), und über MurmurHash (Schlüssel), wenn ein Schlüssel vorhanden ist (Reihenfolge innerhalb einer Partition).
Hier sollte sofort angemerkt werden, dass Kafka die Reihenfolge von Ereignissen nur innerhalb einer Partition garantiert. Aber in der Realität ist das oft kein Problem. Zum Beispiel können Sie alle Änderungen einer einzigen Anzeige garantiert in eine Partition hinzufügen (so die Reihenfolge dieser Änderungen im Rahmen der Anzeige beibehaltend). Außerdem kann eine Reihenfolge-ID in eines der Felder des Ereignisses übergeben werden.
Consumer

Der Consumer ist verantwortlich für den Empfang von Daten aus Apache Kafka. Wenn wir das oben genannte Beispiel betrachten, könnte der Consumer ein Moderationsdienst sein. Dieser Dienst wird auf das Topic des Anzeigendienstes abonnieren und bei Erscheinen einer neuen Anzeige deren Inhalt empfangen und auf die Einhaltung bestimmter festgelegter Richtlinien analysieren.
Apache Kafka speichert, welche letzten Ereignisse der Consumer empfangen hat (dafür wird ein interner Topic verwendet, __consumer__offsets), wodurch sichergestellt wird, dass der Consumer bei erfolgreichem Lesen nicht dieselbe Nachricht zweimal erhält. Wenn jedoch die Option enable.auto.commit = true verwendet wird und die Arbeit zur Verfolgung des Consumer-Offsets im Topic komplett Kafka überlassen wird, könnte man . In Produktionscodes wird normalerweise die Position des Consumers manuell kontrolliert (der Entwickler steuert den Zeitpunkt, zu dem ein Commit der gelesenen Ereignisse zwingend erfolgen muss).
In Fällen, in denen ein Consumer nicht ausreicht (zum Beispiel wenn der Strom neuer Ereignisse sehr hoch ist), kann man mehrere Consumer hinzuzufügen, indem man sie zu einer Consumer-Gruppe zusammenfasst. Die Consumer-Gruppe stellt logisch einen einzelnen Consumer dar, jedoch mit einer Verteilung der Daten zwischen den Gruppenmitgliedern. Dies ermöglicht es jedem Teilnehmer, seinen Anteil an Nachrichten zu übernehmen, und skaliert so die Lesegeschwindigkeit.
Testergebnisse

Ich werde hier nicht viel erklärenden Text schreiben, sondern teile einfach die erhaltenen Ergebnisse. Die Tests wurden auf 3 physischen Maschinen (12 CPU, 384 GB RAM, 15k SAS DISK, 10 GBit/s Net) durchgeführt, Broker und Zookeeper wurden in LXC bereitgestellt.
Leistungstest
Im Laufe der Tests wurden die folgenden Ergebnisse erzielt.
- Die Schreibgeschwindigkeit von Nachrichten mit einer Größe von 1 KB gleichzeitig durch 9 Producer beträgt 1.300.000 Ereignisse pro Sekunde.
- Die Lesegeschwindigkeit von Nachrichten mit einer Größe von 1 KB gleichzeitig durch 9 Consumer beträgt 1.500.000 Ereignisse pro Sekunde.
Test der Ausfallsicherheit
Im Laufe der Tests wurden die folgenden Ergebnisse erzielt (3 Broker, 3 Zookeeper).
- Der unerwartete Ausfall eines der Broker führt nicht zur Beendigung oder zur Nichterreichbarkeit des Clusters. Der Betrieb wird regulär fortgesetzt, jedoch wird die Last auf die verbleibenden Broker verteilt.
- Ein unerwartetes Beenden von zwei Brokern in einem Cluster aus drei Brokern mit min.isr = 2 führt zur Schreibunzugänglichkeit des Clusters, jedoch bleibt es für das Lesen verfügbar. Wenn min.isr = 1 ist, bleibt der Cluster sowohl für das Lesen als auch für das Schreiben verfügbar. Dieser Modus widerspricht jedoch dem Anspruch auf hohe Datensicherheit.
- Das unerwartete Beenden eines der Zookeeper-Server führt nicht zur Beendigung oder Unzugänglichkeit des Clusters. Der Betrieb läuft normal weiter.
- Das unerwartete Beenden von zwei Zookeeper-Servern führt zur Unzugänglichkeit des Clusters, bis einer der Zookeeper-Server wiederhergestellt ist. Diese Aussage gilt für einen Zookeeper-Cluster mit 3 Servern. Infolgedessen wurde nach eingehenden Untersuchungen beschlossen, den Zookeeper-Cluster auf 5 Server zu erweitern, um die Fehlertoleranz zu erhöhen.
Kafka als Dienst

Wir haben uns davon überzeugt, dass Kafka eine hervorragende Technologie ist, die unsere Aufgabe (Implementierung eines Nachrichtenspeichers) lösen kann. Dennoch haben wir beschlossen, den Diensten den direkten Zugang zu Kafka zu verwehren und es über einen data-bus zu schließen. Warum haben wir das gemacht? Tatsächlich gibt es mehrere Gründe.
Der data-bus hat alle Aufgaben in Bezug auf die Integration mit Kafka übernommen (Implementierung und Konfiguration von Consumer und Producer, Überwachung, Alarmierung, Protokollierung, Skalierung usw.). Dadurch wird die Integration mit dem Nachrichtenspeicher so einfach wie möglich.
Der data-bus ermöglicht es, sich von einer bestimmten Programmiersprache oder Bibliothek für die Arbeit mit Kafka zu abstrahieren.
Der data-bus ermöglichte es anderen Diensten, sich von der Speicherebene zu abstrahieren. Vielleicht wechseln wir irgendwann von Kafka zu Pulsar, und dabei wird niemand etwas bemerken (alle Dienste kennen nur die API des data-bus).
Der data-bus hat die Validierung von Ereignisschemata übernommen.
Mit Hilfe des data-bus wurde die Authentifizierung implementiert.
Unter dem Deckmantel des data-bus können wir die Versionen von Kafka ohne Ausfallzeiten und unauffällig aktualisieren und zentral die Konfigurationen von Produzenten, Konsumenten, Brokern usw. verwalten.
Der data-bus ermöglichte es, notwendige Funktionen hinzuzufügen, die in Kafka nicht vorhanden sind (wie das Audit von Themen, die Überwachung von Anomalien im Cluster, die Erstellung einer DLQ usw.).
Der data-bus ermöglicht eine zentralisierte Failover-Implementierung für alle Dienste.
Derzeit reicht es aus, eine kleine Bibliothek in den Code Ihres Dienstes einzufügen, um den Versand von Ereignissen an den Nachrichtenbroker zu starten. Das war's. Sie erhalten die Möglichkeit, zu schreiben, zu lesen und mit nur einer Codezeile zu skalieren. Die gesamte Implementierung bleibt von Ihnen verborgen, nach außen ragen nur ein paar Handgriffe wie die Batch-Größe heraus. Im Hintergrund startet der Dienst data-bus die erforderliche Anzahl von Instanzen von Producer- und Consumer-Diensten in Kubernetes und liefert die benötigte Konfiguration, aber all das geschieht transparent für Ihren Dienst.
Natürlich gibt es keine silberne Kugel, und dieser Ansatz hat seine eigenen Einschränkungen.
- Data-bus muss selbst gesteuert werden, im Gegensatz zu Drittanbieter-Bibliotheken.
- Data-bus erhöht die Anzahl der Interaktionen zwischen den Diensten und dem Nachrichtenbroker, was zu einem Rückgang der Leistung im Vergleich zu reinem Kafka führt.
- Nicht alles lässt sich so einfach vor den Diensten verbergen; wir möchten die Funktionalität von KSQL oder Kafka Streams nicht im data-bus duplizieren, daher müssen wir manchmal erlauben, dass Dienste direkt zugreifen.
In unserem Fall überwiegen die Vorteile die Nachteile, und die Entscheidung, den Nachrichtenbroker durch einen separaten Dienst zu schützen, hat sich bewährt. Im Jahr unseres Betriebs gab es keine schwerwiegenden Ausfälle oder Probleme.
P.S. Danke an meine Freundin, Ekaterina Obalayeva, für die großartigen Bilder zu diesem Artikel. Wenn sie Ihnen gefallen haben, werden noch mehr Illustrationen zu finden sein.
Quelle: habr.com
