Введение в бесконфликтные реплицируемые типы данных

1. Обзор

В этой статье мы рассмотрим бесконфликтные реплицируемые типы данных (CRDT) и способы работы с ними в Java. Для наших примеров мы будем использовать реализации из библиотеки wurmloch-crdt .

Когда у нас есть кластер N узлов реплики в распределенной системе, мы можем столкнуться с сетевым разделом - некоторые узлы временно не могут общаться друг с другом . Эта ситуация называется расщепленным мозгом.

Когда в нашей системе есть разделенный мозг, некоторые запросы на запись - даже для одного и того же пользователя - могут передаваться в разные реплики, не связанные друг с другом . Когда такая ситуация возникает, наша система все еще доступна, но не согласована .

Нам нужно решить, что делать с записями и данными, которые не согласованы, когда сеть между двумя разделенными кластерами снова начинает работать.

2. Бесконфликтные реплицированные типы данных для спасения

Давайте рассмотрим два узла, A и B , которые отключились из-за разделения мозга.

Допустим, пользователь меняет свой логин и запрос отправляется на узел A . Затем он/она решает изменить его снова, но на этот раз запрос переходит к узлу B .

Из-за разделения мозга эти два узла не связаны. Нам нужно решить, как должен выглядеть логин этого пользователя, когда сеть снова работает.

Мы можем использовать несколько стратегий: мы можем дать пользователю возможность разрешать конфликты (как это делается в Документах Google) или мы можем использовать CRDT для объединения данных из разнородных реплик для нас.

3. Maven Dependency

Во-первых, давайте добавим зависимость к библиотеке, которая предоставляет набор полезных 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 расходится, его можно легко объединить путем вычисления объединения двух наборов.

Во-первых, давайте создадим две реплики для имитации распределенной структуры данных и подключим эти две реплики с помощью метода connect () :

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

Как только мы получим две реплики в нашем кластере, мы можем создать GSet в первой реплике и сослаться на него во второй реплике:

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

На данный момент наш кластер работает как положено, и между двумя репликами существует активное соединение. Мы можем добавить два набора в набор из двух разных реплик и утверждать, что набор содержит одинаковые элементы в обеих репликах:

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

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

Допустим, внезапно у нас есть сетевой раздел и нет связи между первой и второй репликами. Мы можем смоделировать сетевой раздел, используя метод 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. Счетчик только для увеличения

Счетчик только инкремента - это CRDT, который агрегирует все приращения локально на каждом узле

  • Когда реплики синхронизируются, после сетевого раздела результирующее значение вычисляется суммированием всех приращений на всех узлах ** - это похоже на LongAdder из java.concurrent , но на более высоком уровне абстракции.

Давайте создадим счетчик только для приращения, используя 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, который сохраняет только последнее обновленное значение при объединении расходящихся наборов данных . Кассандра использует эту стратегию для разрешения конфликтов.

Мы должны быть очень осторожными при использовании этой стратегии, потому что она отбрасывает изменения, произошедшие за это время .

Давайте создадим кластер из двух реплик и экземпляров класса 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");

Когда первая реплика устанавливает значение apple , а вторая меняет его на 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 для слияния разнородных данных.

Все эти примеры и фрагменты кода можно найти в проекте GitHub - это проект Maven, поэтому его легко импортировать и запускать по мере необходимости. является.