Fünf Studenten und drei verteilte Key-Value-Stores

Oder wie wir eine Client-C++-Bibliothek für ZooKeeper, etcd und Consul KV geschrieben haben

In der Welt verteilter Systeme gibt es eine Reihe typischer Aufgaben: Informationen über die Zusammensetzung des Clusters speichern, die Konfiguration von Knoten verwalten, fehlerhafte Knoten erkennen und einen Anführer auswählen andere. Um diese Probleme zu lösen, wurden spezielle verteilte Systeme geschaffen – Koordinierungsdienste. Jetzt interessieren uns drei davon: ZooKeeper, etcd und Consul. Von all den umfangreichen Funktionen von Consul konzentrieren wir uns auf Consul KV.

Fünf Studenten und drei verteilte Key-Value-Stores

Im Wesentlichen handelt es sich bei all diesen Systemen um fehlertolerante, linearisierbare Schlüsselwertspeicher. Obwohl ihre Datenmodelle erhebliche Unterschiede aufweisen, auf die wir später noch eingehen werden, lösen sie dieselben praktischen Probleme. Offensichtlich ist jede Anwendung, die den Koordinationsdienst nutzt, an einen von ihnen gebunden, was dazu führen kann, dass mehrere Systeme in einem Rechenzentrum unterstützt werden müssen, die dieselben Probleme für verschiedene Anwendungen lösen.

Die Idee, dieses Problem zu lösen, stammt von einer australischen Beratungsagentur und es fiel uns, einem kleinen Team von Studenten, zu, sie umzusetzen, worüber ich sprechen werde.

Es ist uns gelungen, eine Bibliothek zu erstellen, die eine gemeinsame Schnittstelle für die Arbeit mit ZooKeeper, etcd und Consul KV bietet. Die Bibliothek ist in C++ geschrieben, es gibt jedoch Pläne, sie in andere Sprachen zu portieren.

Datenmodelle

Um eine gemeinsame Schnittstelle für drei verschiedene Systeme zu entwickeln, müssen Sie verstehen, was sie gemeinsam haben und wie sie sich unterscheiden. Lass es uns herausfinden.

Zookeeper

Fünf Studenten und drei verteilte Key-Value-Stores

Die Schlüssel sind in einem Baum organisiert und werden Knoten genannt. Dementsprechend können Sie für einen Knoten eine Liste seiner untergeordneten Knoten erhalten. Die Vorgänge zum Erstellen eines Znodes (create) und zum Ändern eines Werts (setData) sind getrennt: Nur vorhandene Schlüssel können gelesen und geändert werden. Den Vorgängen zum Prüfen der Existenz eines Knotens, Lesen eines Werts und Abrufen untergeordneter Knoten können Uhren zugeordnet werden. Watch ist ein einmaliger Trigger, der ausgelöst wird, wenn sich die Version der entsprechenden Daten auf dem Server ändert. Ephemere Knoten werden zur Erkennung von Fehlern verwendet. Sie sind an die Sitzung des Clients gebunden, der sie erstellt hat. Wenn ein Client eine Sitzung schließt oder ZooKeeper nicht mehr über seine Existenz benachrichtigt, werden diese Knoten automatisch gelöscht. Es werden einfache Transaktionen unterstützt – eine Reihe von Vorgängen, die entweder alle erfolgreich sind oder fehlschlagen, wenn dies für mindestens einen von ihnen nicht möglich ist.

usw

Fünf Studenten und drei verteilte Key-Value-Stores

Die Entwickler dieses Systems haben sich eindeutig von ZooKeeper inspirieren lassen und daher alles anders gemacht. Es gibt keine Hierarchie der Schlüssel, sondern sie bilden eine lexikografisch geordnete Menge. Sie können alle Schlüssel, die zu einem bestimmten Bereich gehören, abrufen oder löschen. Diese Struktur mag seltsam erscheinen, aber sie ist tatsächlich sehr ausdrucksstark und eine hierarchische Sichtweise kann dadurch leicht nachgeahmt werden.

etcd verfügt nicht über eine Standardoperation zum Vergleichen und Festlegen, aber über etwas Besseres: Transaktionen. Natürlich gibt es sie in allen drei Systemen, aber etcd-Transaktionen sind besonders gut. Sie bestehen aus drei Blöcken: Prüfung, Erfolg, Misserfolg. Der erste Block enthält eine Reihe von Bedingungen, der zweite und dritte - Operationen. Die Transaktion wird atomar ausgeführt. Wenn alle Bedingungen wahr sind, wird der Erfolgsblock ausgeführt, andernfalls wird der Fehlerblock ausgeführt. In API 3.3 können Erfolgs- und Fehlerblöcke verschachtelte Transaktionen enthalten. Das heißt, es ist möglich, bedingte Konstrukte nahezu beliebiger Verschachtelungsebene atomar auszuführen. Erfahren Sie mehr darüber, welche Prüfungen und Vorgänge es gibt Dokumentation.

Auch hier gibt es Uhren, allerdings etwas komplizierter und wiederverwendbar. Das heißt, nach der Installation einer Uhr in einem Schlüsselbereich erhalten Sie alle Updates in diesem Bereich, bis Sie die Uhr kündigen, und nicht nur das erste. In etcd sind Leases das Analogon zu ZooKeeper-Clientsitzungen.

Konsul K.V.

Auch hier gibt es keine strenge hierarchische Struktur, aber Consul kann den Anschein erwecken, dass es existiert: Sie können alle Schlüssel mit dem angegebenen Präfix abrufen und löschen, also mit dem „Teilbaum“ des Schlüssels arbeiten. Solche Abfragen werden als rekursiv bezeichnet. Darüber hinaus kann Consul nur Schlüssel auswählen, die das angegebene Zeichen nach dem Präfix nicht enthalten, was dem Erhalten unmittelbarer „Kinder“ entspricht. Es sei jedoch daran erinnert, dass dies genau der Anschein einer hierarchischen Struktur ist: Es ist durchaus möglich, einen Schlüssel zu erstellen, wenn sein übergeordneter Schlüssel nicht existiert, oder einen Schlüssel zu löschen, der untergeordnete Schlüssel hat, während die untergeordneten Schlüssel weiterhin im System gespeichert bleiben.

Fünf Studenten und drei verteilte Key-Value-Stores
Anstelle von Überwachungen blockiert Consul HTTP-Anfragen. Im Wesentlichen handelt es sich dabei um gewöhnliche Aufrufe der Datenlesemethode, für die neben anderen Parametern die letzte bekannte Version der Daten angegeben wird. Wenn die aktuelle Version der entsprechenden Daten auf dem Server größer als die angegebene ist, wird die Antwort sofort zurückgegeben, andernfalls - wenn sich der Wert ändert. Es gibt auch Sitzungen, die jederzeit an Schlüssel gekoppelt werden können. Es ist erwähnenswert, dass es im Gegensatz zu etcd und ZooKeeper, wo das Löschen von Sitzungen zum Löschen der zugehörigen Schlüssel führt, einen Modus gibt, in dem die Sitzung einfach von ihnen getrennt wird. Verfügbar Transaktionen, ohne Filialen, aber mit allerlei Schecks.

Alles zusammenfügen

ZooKeeper verfügt über das strengste Datenmodell. Die in etcd verfügbaren ausdrucksstarken Bereichsabfragen können weder in ZooKeeper noch in Consul effektiv emuliert werden. Bei dem Versuch, das Beste aus allen Diensten zu integrieren, haben wir am Ende eine Schnittstelle erhalten, die fast der ZooKeeper-Schnittstelle entspricht, mit den folgenden wesentlichen Ausnahmen:

  • Sequenz-, Container- und TTL-Knoten nicht unterstützt
  • ACLs werden nicht unterstützt
  • die Set-Methode erstellt einen Schlüssel, wenn dieser nicht existiert (in ZK gibt setData in diesem Fall einen Fehler zurück)
  • set- und cas-Methoden sind getrennt (in ZK sind sie im Wesentlichen dasselbe)
  • Die Erase-Methode löscht einen Knoten zusammen mit seinem Teilbaum (in ZK gibt delete einen Fehler zurück, wenn der Knoten Kinder hat)
  • Für jeden Schlüssel gibt es nur eine Version – die Wertversion (in ZK es gibt drei davon)

Die Ablehnung sequentieller Knoten ist auf die Tatsache zurückzuführen, dass etcd und Consul keine integrierte Unterstützung für diese haben und sie vom Benutzer einfach über die resultierende Bibliotheksschnittstelle implementiert werden können.

Die Implementierung eines ähnlichen Verhaltens wie ZooKeeper beim Löschen eines Scheitelpunkts würde die Verwaltung eines separaten untergeordneten Zählers für jeden Schlüssel in etcd und Consul erfordern. Da wir versucht haben, die Speicherung von Metainformationen zu vermeiden, wurde beschlossen, den gesamten Teilbaum zu löschen.

Feinheiten der Umsetzung

Schauen wir uns einige Aspekte der Implementierung der Bibliotheksschnittstelle in verschiedenen Systemen genauer an.

Hierarchie in etcd

Die Aufrechterhaltung einer hierarchischen Ansicht in etcd erwies sich als eine der interessantesten Aufgaben. Bereichsabfragen erleichtern das Abrufen einer Liste von Schlüsseln mit einem angegebenen Präfix. Zum Beispiel, wenn Sie alles brauchen, was damit beginnt "/foo", Sie fragen nach einem Bereich ["/foo", "/fop"). Dies würde jedoch den gesamten Teilbaum des Schlüssels zurückgeben, was möglicherweise nicht akzeptabel ist, wenn der Teilbaum groß ist. Zunächst planten wir die Verwendung eines Schlüsselübersetzungsmechanismus. implementiert in zetcd. Dabei wird am Anfang des Schlüssels ein Byte hinzugefügt, das der Tiefe des Knotens im Baum entspricht. Lassen Sie mich Ihnen ein Beispiel geben.

"/foo" -> "u01/foo"
"/foo/bar" -> "u02/foo/bar"

Holen Sie sich dann alle unmittelbaren Kinder des Schlüssels "/foo" möglich, indem Sie einen Bereich anfordern ["u02/foo/", "u02/foo0"). Ja, in ASCII "0" steht gleich danach "/".

Aber wie lässt sich in diesem Fall das Entfernen eines Scheitelpunkts umsetzen? Es stellt sich heraus, dass Sie alle Bereiche des Typs löschen müssen ["uXX/foo/", "uXX/foo0") für XX von 01 bis FF. Und dann trafen wir aufeinander Begrenzung der Vorgangsanzahl innerhalb einer Transaktion.

Als Ergebnis wurde ein einfaches Schlüsselumwandlungssystem erfunden, das es ermöglichte, sowohl das Löschen eines Schlüssels als auch das Abrufen einer Liste von Kindern effektiv umzusetzen. Es reicht aus, vor dem letzten Token ein Sonderzeichen einzufügen. Zum Beispiel:

"/very" -> "/u00very"
"/very/long" -> "/very/u00long"
"/very/long/path" -> "/very/long/u00path"

Dann den Schlüssel löschen "/very" wird zur Löschung "/u00very" und Reichweite ["/very/", "/very0"), und alle Kinder bekommen - in einer Anfrage nach Schlüsseln aus dem Sortiment ["/very/u00", "/very/u01").

Entfernen eines Schlüssels in ZooKeeper

Wie ich bereits erwähnt habe, können Sie in ZooKeeper einen Knoten nicht löschen, wenn er untergeordnete Knoten hat. Wir wollen den Schlüssel zusammen mit dem Teilbaum löschen. Was soll ich machen? Wir tun dies mit Optimismus. Zuerst durchlaufen wir rekursiv den Teilbaum und erhalten die untergeordneten Elemente jedes Scheitelpunkts mit einer separaten Abfrage. Dann erstellen wir eine Transaktion, die versucht, alle Knoten des Teilbaums in der richtigen Reihenfolge zu löschen. Natürlich können zwischen dem Lesen eines Teilbaums und dem Löschen desselben Änderungen auftreten. In diesem Fall schlägt die Transaktion fehl. Darüber hinaus kann sich der Teilbaum während des Lesevorgangs ändern. Eine Anfrage nach den Kindern des nächsten Knotens kann einen Fehler zurückgeben, wenn dieser Knoten beispielsweise bereits gelöscht wurde. In beiden Fällen wiederholen wir den gesamten Vorgang noch einmal.

Dieser Ansatz macht das Löschen eines Schlüssels sehr ineffektiv, wenn er untergeordnete Elemente hat, und dies umso mehr, wenn die Anwendung weiterhin mit dem Unterbaum arbeitet und Schlüssel löscht und erstellt. Dadurch konnten wir jedoch vermeiden, dass die Implementierung anderer Methoden in etcd und Consul komplizierter wurde.

in ZooKeeper eingestellt

In ZooKeeper gibt es separate Methoden, die mit der Baumstruktur arbeiten (create, delete, getChildren) und die mit Daten in Knoten arbeiten (setData, getData). Darüber hinaus haben alle Methoden strenge Voraussetzungen: create gibt einen Fehler zurück, wenn der Knoten dies bereits getan hat erstellt wurde, löschen oder setzen Sie die Daten – falls sie noch nicht vorhanden sind. Wir brauchten eine Set-Methode, die aufgerufen werden kann, ohne über das Vorhandensein eines Schlüssels nachdenken zu müssen.

Eine Möglichkeit besteht darin, wie bei der Löschung optimistisch vorzugehen. Überprüfen Sie, ob ein Knoten vorhanden ist. Wenn vorhanden, setData aufrufen, andernfalls erstellen. Wenn die letzte Methode einen Fehler zurückgegeben hat, wiederholen Sie den Vorgang noch einmal. Zunächst ist zu beachten, dass der Existenztest sinnlos ist. Sie können sofort create aufrufen. Ein erfolgreicher Abschluss bedeutet, dass der Knoten nicht existierte und erstellt wurde. Andernfalls gibt create den entsprechenden Fehler zurück, woraufhin Sie setData aufrufen müssen. Natürlich könnte zwischen Aufrufen ein Vertex durch einen konkurrierenden Aufruf gelöscht werden, und setData würde ebenfalls einen Fehler zurückgeben. In diesem Fall können Sie es noch einmal machen, aber lohnt es sich?

Wenn beide Methoden einen Fehler zurückgeben, wissen wir mit Sicherheit, dass eine konkurrierende Löschung stattgefunden hat. Stellen wir uns vor, dass dieser Löschvorgang nach dem Aufruf von set erfolgt ist. Dann ist die Bedeutung, die wir zu etablieren versuchen, bereits ausgelöscht. Das bedeutet, dass wir davon ausgehen können, dass set erfolgreich ausgeführt wurde, auch wenn tatsächlich nichts geschrieben wurde.

Weitere technische Details

In diesem Abschnitt machen wir eine Pause von verteilten Systemen und sprechen über Codierung.
Eine der Hauptanforderungen des Kunden war die Plattformübergreifendheit: Mindestens einer der Dienste muss auf Linux, MacOS und Windows unterstützt werden. Zunächst entwickelten wir nur für Linux und begannen später mit dem Testen auf anderen Systemen. Dies verursachte viele Probleme, bei denen eine Zeit lang völlig unklar war, wie man sie angehen sollte. Daher werden nun alle drei Koordinationsdienste unter Linux und MacOS unterstützt, während unter Windows nur Consul KV unterstützt wird.

Von Anfang an haben wir versucht, vorgefertigte Bibliotheken für den Zugriff auf Dienste zu nutzen. Im Fall von ZooKeeper fiel die Wahl auf ZooKeeper C++, das letztendlich nicht unter Windows kompiliert werden konnte. Dies ist jedoch nicht überraschend: Die Bibliothek ist als reine Linux-Bibliothek positioniert. Für Consul war die einzige Option ppconsul. Es musste Unterstützung hinzugefügt werden Sitzungen и Transaktionen. Für etcd wurde keine vollwertige Bibliothek gefunden, die die neueste Version des Protokolls unterstützt, also haben wir einfach generierter GRPC-Client.

Inspiriert durch die asynchrone Schnittstelle der ZooKeeper C++-Bibliothek haben wir uns entschieden, auch eine asynchrone Schnittstelle zu implementieren. ZooKeeper C++ verwendet hierfür Future/Promise-Primitive. In STL sind sie leider sehr bescheiden implementiert. Zum Beispiel nein dann Methode, das die übergebene Funktion auf das Ergebnis der Zukunft anwendet, sobald es verfügbar wird. In unserem Fall ist eine solche Methode notwendig, um das Ergebnis in das Format unserer Bibliothek zu konvertieren. Um dieses Problem zu umgehen, mussten wir einen eigenen einfachen Thread-Pool implementieren, da wir auf Wunsch des Kunden keine umfangreichen Bibliotheken von Drittanbietern wie Boost verwenden konnten.

Unsere damalige Implementierung funktioniert so. Beim Aufruf wird ein zusätzliches Versprechen/Zukunftspaar erstellt. Die neue Zukunft wird zurückgegeben und die übergebene wird zusammen mit der entsprechenden Funktion und einem zusätzlichen Versprechen in die Warteschlange gestellt. Ein Thread aus dem Pool wählt mehrere Futures aus der Warteschlange aus und fragt sie mithilfe von wait_for ab. Wenn ein Ergebnis verfügbar wird, wird die entsprechende Funktion aufgerufen und ihr Rückgabewert an das Versprechen übergeben.

Wir haben denselben Thread-Pool verwendet, um Abfragen an etcd und Consul auszuführen. Dies bedeutet, dass auf die zugrunde liegenden Bibliotheken von mehreren verschiedenen Threads zugegriffen werden kann. ppconsul ist nicht threadsicher, daher sind Aufrufe durch Sperren geschützt.
Sie können mit grpc von mehreren Threads aus arbeiten, es gibt jedoch Feinheiten. In etcd werden Uhren über GRPC-Streams implementiert. Dabei handelt es sich um bidirektionale Kanäle für Nachrichten eines bestimmten Typs. Die Bibliothek erstellt einen einzelnen Thread für alle Uhren und einen einzelnen Thread, der eingehende Nachrichten verarbeitet. Daher verbietet grpc parallele Schreibvorgänge zum Streamen. Das bedeutet, dass Sie beim Initialisieren oder Löschen einer Uhr warten müssen, bis die vorherige Anfrage vollständig gesendet wurde, bevor Sie die nächste senden. Wir verwenden zur Synchronisierung bedingte Variablen.

Ergebnis

Sehen Sie selbst: liboffkv.

Unser Team: Raed Romanov, Iwan Gluschenkow, Dmitri Kamaldinow, Victor Krapivensky, Vitaly Ivanin.

Source: habr.com

Kommentar hinzufügen