Einführung in konfliktfreie replizierte Datentypen

1. Überblick

In diesem Artikel werden konfliktfreie replizierte Datentypen (CRDT) und deren Verwendung in Java beschrieben. Für unsere Beispiele verwenden wir Implementierungen aus der Bibliothek wurmloch-crdt .

Wenn sich in einem verteilten System ein Cluster aus N -Replikationsknoten befindet, kann es zu einer Netzwerkpartition kommen - einige Knoten können vorübergehend nicht miteinander kommunizieren . Diese Situation wird als Split-Brain bezeichnet.

Wenn wir ein Split-Brain in unserem System haben, können einige Schreibanforderungen - auch für denselben Benutzer - auf verschiedene Replikate gehen, die nicht miteinander verbunden sind . Wenn eine solche Situation eintritt, ist unser System noch verfügbar, aber nicht konsistent .

Wir müssen entscheiden, was mit Schreibvorgängen und Daten zu tun ist, die nicht konsistent sind, wenn das Netzwerk zwischen zwei aufgespaltenen Clustern wieder funktioniert.

2. Konfliktfreie replizierte Datentypen zur Rettung

Betrachten wir zwei Knoten, A und B , die aufgrund eines Split-Brain getrennt wurden.

Angenommen, ein Benutzer ändert sein Login und eine Anfrage geht an den Knoten A . Dann beschließt er, es erneut zu ändern, aber diesmal geht die Anforderung an den Knoten B .

Aufgrund des Split-Brain sind die beiden Knoten nicht miteinander verbunden. Wir müssen entscheiden, wie das Login dieses Benutzers aussehen soll, wenn das Netzwerk wieder funktioniert.

Wir können ein paar Strategien anwenden: Wir können dem Benutzer die Möglichkeit geben, Konflikte zu lösen (wie in Google Docs), oder wir können einen CRDT verwenden, um Daten aus unterschiedlichen Replikaten für uns zusammenzuführen.

3. Maven-Abhängigkeit

Zunächst fügen wir der Bibliothek eine Abhängigkeit hinzu, die eine Reihe nützlicher CRDTs enthält:

<dependency>
    <groupId>com.netopyr.wurmloch</groupId>
    <artifactId>wurmloch-crdt</artifactId>
    <version>0.1.0</version>
</dependency>

Die neueste Version finden Sie unter Maven Central .

4. Grow-Only-Set

Das grundlegendste CRDT ist ein Grow-Only-Set. Elemente können nur zu einem GSet hinzugefügt und niemals entfernt werden. Wenn das GSet auseinandergeht, kann es einfach zusammengefügt werden, indem die Vereinigung ** von zwei Mengen berechnet wird.

Zunächst erstellen wir zwei Replikate, um eine verteilte Datenstruktur zu simulieren und verbinden diese beiden Repliken mit der connect () -Methode:

LocalCrdtStore crdtStore1 = new LocalCrdtStore();
LocalCrdtStore crdtStore2 = new LocalCrdtStore();
crdtStore1.connect(crdtStore2);

Sobald wir zwei Replikate in unserem Cluster haben, können wir ein GSet auf dem ersten Replikat erstellen und auf das zweite Replikat verweisen:

GSet<String> replica1 = crdtStore1.createGSet("ID__1");
GSet<String> replica2 = crdtStore2.<String>findGSet("ID__1").get();

Zu diesem Zeitpunkt arbeitet unser Cluster wie erwartet und es besteht eine aktive Verbindung zwischen zwei Replikaten. Wir können dem Satz zwei Elemente aus zwei verschiedenen Replikaten hinzufügen und behaupten, dass das Set auf beiden Replikaten die gleichen Elemente enthält:

replica1.add("apple");
replica2.add("banana");

assertThat(replica1).contains("apple", "banana");
assertThat(replica2).contains("apple", "banana");

Nehmen wir an, wir haben plötzlich eine Netzwerkpartition und es gibt keine Verbindung zwischen der ersten und der zweiten Replik. Wir können die Netzwerkpartition mit der Methode disconnect () simulieren:

crdtStore1.disconnect(crdtStore2);

Wenn wir Elemente aus beiden Replikaten zum Datensatz hinzufügen, sind diese Änderungen nicht global sichtbar, da zwischen ihnen keine Verbindung besteht:

replica1.add("strawberry");
replica2.add("pear");

assertThat(replica1).contains("apple", "banana", "strawberry");
assertThat(replica2).contains("apple", "banana", "pear");
  • Sobald die Verbindung zwischen den beiden Clustermitgliedern wieder hergestellt ist, wird GSet intern unter Verwendung einer Union in beiden Sätzen zusammengeführt, und beide Replikate sind wieder konsistent:

crdtStore1.connect(crdtStore2);

assertThat(replica1)
  .contains("apple", "banana", "strawberry", "pear");
assertThat(replica2)
  .contains("apple", "banana", "strawberry", "pear");

5. Inkrementzähler

Increment-Only-Zähler ist ein CRDT, der alle Inkremente lokal auf jedem Knoten zusammenfasst.

  • Wenn Replikate nach einer Netzwerkpartition synchronisiert werden, wird der resultierende Wert berechnet, indem alle Inkremente auf allen Knoten summiert werden ** - dies ist ähnlich wie LongAdder aus java.concurrent , jedoch auf einer höheren Abstraktionsebene.

Erstellen Sie mit GCounter einen Inkrementzähler und erhöhen Sie ihn von beiden Replikaten aus. Wir können sehen, dass die Summe richtig berechnet wird:

LocalCrdtStore crdtStore1 = new LocalCrdtStore();
LocalCrdtStore crdtStore2 = new LocalCrdtStore();
crdtStore1.connect(crdtStore2);

GCounter replica1 = crdtStore1.createGCounter("ID__1");
GCounter replica2 = crdtStore2.findGCounter("ID__1").get();

replica1.increment();
replica2.increment(2L);

assertThat(replica1.get()).isEqualTo(3L);
assertThat(replica2.get()).isEqualTo(3L);

Wenn wir beide Clustermitglieder trennen und lokale Inkrementierungsoperationen durchführen, können wir feststellen, dass die Werte inkonsistent sind:

crdtStore1.disconnect(crdtStore2);

replica1.increment(3L);
replica2.increment(5L);

assertThat(replica1.get()).isEqualTo(6L);
assertThat(replica2.get()).isEqualTo(8L);

Sobald der Cluster wieder einwandfrei ist, werden die Inkremente zusammengeführt und ergeben den richtigen Wert:

crdtStore1.connect(crdtStore2);

assertThat(replica1.get())
  .isEqualTo(11L);
assertThat(replica2.get())
  .isEqualTo(11L);

6. PN-Zähler

Mithilfe einer ähnlichen Regel für den Inkrementzähler können Sie einen Zähler erstellen, der sowohl inkrementiert als auch dekrementiert werden kann. Der PNCounter speichert alle Inkremente und Dekremente separat.

  • Wenn Repliken synchronisiert werden, ist der resultierende Wert gleich der Summe aller Inkremente minus der Summe aller Dekremente ** :

@Test
public void givenPNCounter__whenReplicasDiverge__thenMergesWithoutConflict() {
    LocalCrdtStore crdtStore1 = new LocalCrdtStore();
    LocalCrdtStore crdtStore2 = new LocalCrdtStore();
    crdtStore1.connect(crdtStore2);

    PNCounter replica1 = crdtStore1.createPNCounter("ID__1");
    PNCounter replica2 = crdtStore2.findPNCounter("ID__1").get();

    replica1.increment();
    replica2.decrement(2L);

    assertThat(replica1.get()).isEqualTo(-1L);
    assertThat(replica2.get()).isEqualTo(-1L);

    crdtStore1.disconnect(crdtStore2);

    replica1.decrement(3L);
    replica2.increment(5L);

    assertThat(replica1.get()).isEqualTo(-4L);
    assertThat(replica2.get()).isEqualTo(4L);

    crdtStore1.connect(crdtStore2);

    assertThat(replica1.get()).isEqualTo(1L);
    assertThat(replica2.get()).isEqualTo(1L);
}

7. Last-Writer-Wins-Registrierung

Manchmal haben wir komplexere Geschäftsregeln, und der Einsatz von Mengen oder Zählern reicht nicht aus. Wir können das Last-Writer-Wins-Register verwenden, das beim Zusammenführen divergenter Datensätze nur den letzten aktualisierten Wert enthält. Cassandra verwendet diese Strategie, um Konflikte zu lösen.

Wir müssen bei der Anwendung dieser Strategie sehr vorsichtig sein, da dadurch die zwischenzeitlich aufgetretenen Änderungen zurückfallen.

Erstellen wir ein Cluster aus zwei Replikaten und Instanzen der Klasse LWWRegister :

LocalCrdtStore crdtStore1 = new LocalCrdtStore("N__1");
LocalCrdtStore crdtStore2 = new LocalCrdtStore("N__2");
crdtStore1.connect(crdtStore2);

LWWRegister<String> replica1 = crdtStore1.createLWWRegister("ID__1");
LWWRegister<String> replica2 = crdtStore2.<String>findLWWRegister("ID__1").get();

replica1.set("apple");
replica2.set("banana");

assertThat(replica1.get()).isEqualTo("banana");
assertThat(replica2.get()).isEqualTo("banana");

Wenn die erste Replik den Wert auf apple setzt und die zweite in banana ändert, behält LWWRegister__ nur den letzten Wert.

Mal sehen, was passiert, wenn der Cluster die Verbindung trennt:

crdtStore1.disconnect(crdtStore2);

replica1.set("strawberry");
replica2.set("pear");

assertThat(replica1.get()).isEqualTo("strawberry");
assertThat(replica2.get()).isEqualTo("pear");

Jedes Replikat behält seine lokale Kopie der Daten bei, die inkonsistent sind. Wenn wir die set () -Methode aufrufen, weist der LWWRegister intern einen speziellen Versionswert zu, der die Verwendung des VectorClock -Algorithmus für jeden einzelnen identifiziert.

Wenn der Cluster synchronisiert wird, nimmt er den Wert mit der höchsten Version und verwirft jedes vorherige Update :

crdtStore1.connect(crdtStore2);

assertThat(replica1.get()).isEqualTo("pear");
assertThat(replica2.get()).isEqualTo("pear");

8. Fazit

In diesem Artikel wurde das Problem der Konsistenz verteilter Systeme unter Beibehaltung der Verfügbarkeit aufgezeigt.

Im Falle von Netzwerkpartitionen müssen die auseinandergeteilten Daten zusammengeführt werden, wenn der Cluster synchronisiert wird. Wir haben gesehen, wie man CRDTs verwendet, um unterschiedliche Daten zusammenzuführen.

Alle diese Beispiele und Codeausschnitte finden Sie im GitHub-Projekt . Dies ist ein Maven-Projekt, daher sollte es einfach sein, es zu importieren und auszuführen ist.