競合のない複製データ型の紹介

1概要

この記事では、競合のない複製データ型(CRDT)と、それらをJavaで処理する方法について説明します。この例では、https://github.com/netopyr/wurmloch-crdt[ wurmloch-crdt ]ライブラリの実装を使用します。

分散システムに N 個のレプリカノードのクラスタがある場合、 ネットワークパーティションが発生する可能性があります - 一部のノードは一時的に互いに通信できません 。この状況はスプリットブレインと呼ばれます。

私たちのシステムにスプリットブレインがあると、 同じユーザーであっても いくつかの書き込み要求は、互いに接続されていない異なるレプリカに行くことができます** そのような状況が発生しても、私たちのシステムはまだ利用可能ですが、一貫性はありません。

2つの分割クラスター間のネットワークが再び機能し始めたときに、一貫性のない書き込みとデータをどうするかを決める必要があります。

2衝突のない複製データ型を救助に

スプリットブレインのために切断された2つのノード A B を考えてみましょう。

ユーザーが自分のログインを変更し、リクエストがノード A に送信されるとしましょう。それから彼/彼女はそれを再び変更することにしました、しかし今回は要求はノード B に行きます。

スプリットブレインのため、2つのノードは接続されていません。ネットワークが再び機能しているときに、このユーザーのログインがどのように見えるべきかを決める必要があります。

私たちは2つの戦略を利用することができます。(Google Docsで行われているように)ユーザーに衝突を解決する機会を与えることができます。

3 Mavenの依存関係

まず、便利なCRDTのセットを提供するライブラリへの依存関係を追加しましょう。

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

最新版はhttps://search.maven.org/classic/#search%7Cga%7C1%7Cg%3A%22com.netopyr.wurmloch%22%20AND%20a%3A%22wurmloch-crdt%22[で見つけることができますMaven Central]。

4成長専用セット

最も基本的なCRDTは、成長専用セットです。 要素は GSet にのみ追加でき、削除することはできません GSet が分岐した場合、2つの集合の和集合** を計算することで簡単にマージできます。

まず、2つのレプリカを作成して分散データ構造をシミュレートし、 connect() メソッドを使用してそれら2つのレプリカを接続します。

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

クラスタに2つのレプリカが作成されたら、最初のレプリカに GSet を作成し、それを2番目のレプリカで参照できます。

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

この時点で、私たちのクラスタは期待通りに動作しており、2つのレプリカ間にアクティブな接続があります。 2つの異なるレプリカからセットに2つの要素を追加し、セットに両方のレプリカで同じ要素が含まれていることを表明できます。

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

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

突然ネットワークパーティションができて、1番目と2番目のレプリカ間に接続がないとしましょう。 disconnect() メソッドを使用してネットワークパーティションをシミュレートできます。

crdtStore1.disconnect(crdtStore2);

次に、両方のレプリカからデータセットに要素を追加すると、それらの間に接続がないため、これらの変更はグローバルに表示されません。

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

assertThat(replica1).contains("apple", "banana", "strawberry");
assertThat(replica2).contains("apple", "banana", "pear");
  • 両方のクラスタメンバー間の接続が再度確立されると、 GSet は両方のセットで共用体を使用して内部的にマージされ、両方のレプリカは再び一貫性を保ちます。

crdtStore1.connect(crdtStore2);

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

5増分専用カウンター

Increment-Onlyカウンタは、各ノードでローカルにすべての増分を集計するCRDTです。

  • ネットワークパーティションの後にレプリカが同期するとき、結果の値はすべてのノードのすべての増分を合計することによって計算されます** - これは java.concurrent LongAdder に似ていますが、より高い抽象レベルです。

GCounter を使用して増分専用カウンタを作成し、両方のレプリカから増分します。合計が正しく計算されていることがわかります。

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);

両方のクラスタメンバーを切断してローカルのインクリメント操作を実行すると、値が矛盾していることがわかります。

crdtStore1.disconnect(crdtStore2);

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

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

しかし、クラスタが再び正常になると、増分はマージされ、適切な値になります。

crdtStore1.connect(crdtStore2);

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

6. PNカウンター

増分専用カウンターについても同様の規則を使用して、増分および減分の両方が可能なカウンターを作成できます。 PNCounter はすべての増分と減分を別々に格納します。

  • レプリカが同期するとき、結果の値は すべての増分の合計 - すべての減少の合計** に等しくなります。

@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 Registerを使用することができます。これは、分岐したデータセットをマージするときに、最後に更新された値のみを保持します。 Cassandraは衝突を解決するためにこの戦略を使います。

この戦略を使用する場合は、その間に発生した変更をドロップするため、非常に慎重になる必要があります。

LWWRegister クラスの2つのレプリカとインスタンスのクラスタを作成しましょう。

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");

最初のレプリカが値を apple に設定し、2番目のレプリカが値を bananaに変更した場合、 LWWRegister__は最後の値のみを保持します。

クラスタが切断されるとどうなるか見てみましょう。

crdtStore1.disconnect(crdtStore2);

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

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

各レプリカは、矛盾するデータのローカルコピーを保持しています。 set() メソッドを呼び出すと、 LWWRegister は内部で VectorClock アルゴリズムを使用して特定の更新を識別する特別なバージョン値を割り当てます。

クラスタが同期するとき、それは 最も高いバージョン を持つ値を取り、 以前のすべての更新を破棄します :

crdtStore1.connect(crdtStore2);

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

8結論

この記事では、可用性を維持しながら分散システムの整合性の問題を示しました。

ネットワークパーティションの場合、クラスタが同期しているときに分岐したデータをマージする必要があります。 CRDTを使用して分岐したデータをマージする方法を見ました。

これらの例とコードスニペットはすべてhttps://github.com/eugenp/tutorials/tree/master/libraries[GitHubプロジェクト]にあります。これはMavenプロジェクトなので、インポートおよび実行は簡単なはずですです。