Introduction aux types de données répliquées sans conflit

1. Vue d’ensemble

Dans cet article, nous allons examiner les types de données répliquées sans conflit (CRDT) et leur utilisation en Java. Pour nos exemples, nous utiliserons les implémentations de la bibliothèque wurmloch-crdt .

Lorsque nous avons un groupe de nœuds de réplique N dans un système distribué, nous pouvons rencontrer une partition réseau - certains nœuds sont temporairement incapables de communiquer les uns avec les autres . Cette situation s’appelle un cerveau divisé.

Lorsque nous avons un cerveau divisé dans notre système, certaines demandes d’écriture, même pour le même utilisateur, peuvent accéder à différentes répliques non connectées les unes aux autres. Quand une telle situation se produit, notre système est toujours disponible mais n’est pas cohérent .

Nous devons décider quoi faire des écritures et des données qui ne sont pas cohérentes lorsque le réseau entre deux clusters fractionnés recommence à fonctionner.

2. Types de données répliquées sans conflit à la rescousse

Considérons deux nœuds, A et B , qui ont été déconnectés à cause d’un cerveau divisé.

Supposons qu’un utilisateur modifie son identifiant et qu’une requête soit envoyée au nœud A . Ensuite, il/elle décide de le modifier à nouveau, mais cette fois, la demande est transmise au noeud B .

En raison de la division du cerveau, les deux nœuds ne sont pas connectés. Nous devons décider à quoi ressemblera la connexion de cet utilisateur lorsque le réseau fonctionnera à nouveau.

Nous pouvons utiliser plusieurs stratégies: nous pouvons donner la possibilité à l’utilisateur de résoudre les conflits (comme dans Google Documents), ou nous pouvons utiliser un CRDT pour la fusion de données provenant de répliques divergentes pour nous.

3. Dépendance Maven

Premièrement, ajoutons une dépendance à la bibliothèque qui fournit un ensemble de CRDT utiles:

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

4. Ensemble de culture seule

Le CRDT le plus élémentaire est un ensemble Grow-Only. Les éléments ne peuvent être ajoutés à un GSet et jamais supprimés. Lorsque le GSet diverge, il peut être facilement fusionné en calculant l’union de deux ensembles.

Commençons par créer deux répliques pour simuler une structure de données distribuée et connecter ces deux répliques à l’aide de la méthode connect () :

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

Une fois que nous avons deux réplicas dans notre cluster, nous pouvons créer un GSet sur le premier réplica et le référencer sur le second:

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

À ce stade, notre cluster fonctionne comme prévu et il existe une connexion active entre deux réplicas. Nous pouvons ajouter deux éléments à l’ensemble à partir de deux répliques différentes et affirmer que l’ensemble contient les mêmes éléments sur les deux répliques:

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

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

Disons que nous avons soudain une partition réseau et qu’il n’ya plus de connexion entre la première et la deuxième réplique. Nous pouvons simuler la partition réseau en utilisant la méthode disconnect () :

crdtStore1.disconnect(crdtStore2);

Ensuite, lorsque nous ajoutons des éléments au jeu de données à partir des deux réplicas, ces modifications ne sont pas visibles globalement car il n’y a pas de lien entre elles:

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

assertThat(replica1).contains("apple", "banana", "strawberry");
assertThat(replica2).contains("apple", "banana", "pear");
  • Une fois que la connexion entre les deux membres du cluster est rétablie, le GSet est fusionné ** en interne à l’aide d’une union sur les deux ensembles, et les deux réplicas sont à nouveau cohérents:

crdtStore1.connect(crdtStore2);

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

5. Compteur d’incrément seulement

Le compteur d’incrémentation uniquement est un CRDT qui regroupe toutes les incréments localement sur chaque nœud.

  • Lorsque les répliques sont synchronisées, après une partition réseau, la valeur résultante est calculée en faisant la somme de tous les incréments sur tous les nœuds ** - ceci est similaire à LongAdder de java.concurrent mais à un niveau d’abstraction supérieur.

Créons un compteur avec incréments uniquement en utilisant GCounter et incrémentons-le à partir des deux réplicas Nous pouvons voir que la somme est calculée correctement:

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

Lorsque nous déconnectons les deux membres du cluster et effectuons des opérations d’incrémentation locales, nous constatons que les valeurs sont incohérentes:

crdtStore1.disconnect(crdtStore2);

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

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

Mais une fois que le cluster est à nouveau en bonne santé, les incréments seront fusionnés, donnant la valeur appropriée:

crdtStore1.connect(crdtStore2);

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

6. PN Counter

En utilisant une règle similaire pour le compteur d’incrémentation uniquement, nous pouvons créer un compteur pouvant être à la fois incrémenté et décrémenté. Le PNCounter stocke tous les incréments et les décréments séparément.

  • Lorsque les répliques sont synchronisées, la valeur résultante sera égale à la somme de tous les incréments moins la somme de tous les décréments ** :

@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. Registre des derniers vainqueurs

Parfois, nous avons des règles de gestion plus complexes, et opérer sur des ensembles ou des compteurs est insuffisant. Nous pouvons utiliser le registre Last-Writer-Wins, qui conserve uniquement la dernière valeur mise à jour lors de la fusion de jeux de données divergents ** . Cassandra utilise cette stratégie pour résoudre les conflits.

Nous devons être très prudents lors de l’utilisation de cette stratégie car elle supprime les changements survenus entre-temps .

Créons un cluster de deux réplicas et instances de la classe 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");

Lorsque la première réplique définit la valeur sur apple et que la seconde la remplace par banana, le LWWRegister ne conserve que la dernière valeur.

Voyons ce qui se passe si le cluster se déconnecte:

crdtStore1.disconnect(crdtStore2);

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

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

Chaque réplica conserve sa copie locale des données incohérentes. Lorsque nous appelons la méthode set () , le LWWRegister affecte en interne une valeur de version spéciale qui identifie la mise à jour spécifique de chaque en utilisant un algorithme VectorClock .

Lorsque le cluster se synchronise, prend la valeur avec la version la plus récente et rejette toutes les mises à jour précédentes :

crdtStore1.connect(crdtStore2);

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

8. Conclusion

Dans cet article, nous avons montré le problème de la cohérence des systèmes distribués tout en maintenant la disponibilité.

Dans le cas de partitions réseau, nous devons fusionner les données divergentes lorsque le cluster est synchronisé. Nous avons vu comment utiliser les CRDT pour effectuer une fusion de données divergentes.

Tous ces exemples et extraits de code se trouvent dans le projet GitHub - il s’agit d’un projet Maven, il devrait donc être facile à importer et à exécuter car il est.