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