Les systèmes distribués se caractérisent par des échanges d'états à travers des liaisons peu fiables ou à forte latence. Si un système fonctionne de manière fiable, il doit être robuste à la fois face à la défaillance des nœuds et à celle du réseau. Néanmoins, tous les systèmes ne satisfont pas aux invariants de sûreté que nous voudrions. Dans cet article, nous allons explorer quelques sujets à considérer pour la conception des bases de données distribuées et la façon dont celles-ci réagissent face à un partitionnement du réseau.
Les réseaux sur IP peuvent arbitrairement perdre, retarder, réordonner ou dupliquer des messages envoyés entre des nœuds. Aussi, de nombreux systèmes distribués utilisent TCP pour prévenir les messages dupliqués ou réordonnés. Toutefois, TCP/IP est fondamentalement asynchrone : le réseau peut de façon arbitraire retarder des messages et les connexions peuvent disparaître à tout moment. De plus, la détection des défaillances n'est pas fiable : il peut être impossible de déterminer si un nœud est mort, si la connexion est perdue ou si les choses vont juste plus lentement que prévu.
Ce type de défaillance, où les messages sont arbitrairement retardés ou perdus, est appelé un partitionnement réseau (_Network Partition_). Les partitionnements peuvent apparaître dans les réseaux de production pour une variété de raisons : pression GC, échec de la carte réseau, bugs du firmware du commutateur, une mauvaise configuration, une congestion réseau ou une pelleteuse qui a arraché un câble de télécommunication pour n'en nommer que quelques-unes. Étant donné que les partitionnements se produisent, le théorème CAP limite les garanties maximales réalisables par les systèmes distribués. Lorsque des messages sont perdus, les systèmes «cohérents» (CP) préservent la linéarisabilité en rejetant certaines requêtes sur certains nœuds. Les systèmes "disponibles" (AP) peuvent traiter les demandes sur tous les nœuds, mais doivent sacrifier la linéarisabilité : différents nœuds peuvent être en désaccord sur l'ordre dans lequel les opérations ont eu lieu. Les systèmes peuvent être à la fois cohérents et disponibles lorsque le réseau est en bonne santé mais, étant donné la réalité du partitionnement des réseaux, il n'existe pas de systèmes entièrement CA.
Il est également intéressant de voir que CAP ne s'applique pas seulement à la base de données dans son ensemble mais aussi aux sous-systèmes tels que des tables, des clés, des colonnes et même des opérations distinctes. Par exemple, une base de données permet indépendamment la linéarisabilité pour chaque clé, mais pas entre les clés. Faire ce compromis permet au système de traiter un plus grand espace de demandes au cours d'un partitionnement. De nombreuses bases de données proposent, pour des opérations individuelles en lecture et écriture, des niveaux de cohérence ajustables sur la base de compromis proportionnels de performance et d'exactitude.
Tester les partitionnements
La théorie délimite un espace de conception mais, dans la vraie vie, le logiciel ne peut pas atteindre ces limites. Nous devons tester la dynamique d'un système pour réellement comprendre comment il se comporte.
Tout d'abord, vous aurez besoin d'une collection de nœuds à tester. J'utilise cinq nœuds LXC sur un ordinateur Linux, mais vous pouvez utiliser des zones Solaris, des machines virtuelles, des nœuds EC2, des serveurs physiques, etc. Vous souhaitez que vos nœuds forment un réseau d'un certain type - dans mon cas, une interface virtuelle unique. J'ai nommé mes nœuds n1, n2, ... n5 et mis en place un DNS entre eux et le système d'exploitation hôte.
Pour provoquer un partitionnement, vous devez trouver un moyen de perdre ou de retarder les messages : par exemple, avec des règles de pare-feu. Sur Linux vous pouvez utiliser iptables -A INPUT -s -j DROP pour causer un partitionnement unidirectionnel, un abandon de messages en direction du nœud local venant d'un autre nœud. En appliquant ces règles sur plusieurs hôtes, vous pouvez construire n'importe quel schéma de perte réseau.
L'exécution de ces commandes de manière répétée sur plusieurs hôtes demande un peu de travail. J'utilise un outil nommé Salticid que j'ai développé, mais vous pouvez utiliser CSSH ou n'importe quel autre système d'automatisation pour cluster. Le principal facteur est la latence - vous voulez être en mesure d'initier et de terminer rapidement un partitionnement, ainsi Chef ou autres systèmes de gestion de configuration de machines à convergence lente sont probablement moins utiles.
Ensuite, vous aurez besoin de déployer le système distribué sur ces nœuds et de concevoir une application pour le tester. J'ai écrit un test simple : un programme Clojure s'exécutant en dehors du cluster, simulant cinq clients isolés à l'aide de threads. Les clients ajoutent simultanément N entiers à un ensemble dans le système distribué : l'un écrit 0, 5, 10, ...; un autre écrit 1, 6, 11, ...; et ainsi de suite. Chaque client enregistre une trace de chacune de ses écritures et si elle a réussi ou échoué. Lorsque toutes les écritures sont terminées, il attend que l'état global du cluster converge et vérifie que l'état actuel de la base de données est en accord avec les enregistrements des clients. Il s'agit d'un type simple de vérification de la cohérence mais il peut être adapté pour tester divers modèles de données.
Le client et la configuration automatique, incluant les scripts simulant les partitionnements et la mise en place des bases de données, sont disponibles gratuitement. Pour les instructions et le code, cliquez ici.
PostgreSQL
Une instance PostgreSQL mono-nœud est un système CP ; il peut fournir une cohérence séquentielle pour les transactions, au prix de ne plus être disponible lorsque le nœud échoue. Cependant, le système distribué constitué du serveur et du client peut ne pas être cohérent.
Le protocole de commit de Postgres est un cas spécial du commit à deux phases. Dans la première phase, le client vote pour committer (ou annuler) la transaction en cours et envoie ce message au serveur. Le serveur vérifie si ses contraintes de cohérence permettent la réalisation de l'opération et, si c'est le cas, il vote pour committer. Il écrit la transaction sur le disque et informe le client que le commit a eu lieu (ou a échoué, comme cela peut-être le cas). Maintenant, le client et le serveur sont en accord sur le résultat de la transaction.
Qu'advient-il si le message d'acquittement est écarté avant que le client ne le reçoive ? Et bien, le client ne saura pas si le commit a été un succès ou non ! Le protocole 2PC dit que les nœuds doivent attendre l'arrivée du message d'acquittement pour décider de l'issue. S'il n'est pas reçu, 2PC ne peut pas arriver à son terme. C'est un protocole qui ne supporte pas le partitionnement réseau. Les systèmes réels ne peuvent pas attendre indéfiniment donc, à un moment donné, le délai d'attente du client expire, laissant le protocole de commit dans un état indéterminé.
Si je crée un partitionnement de ce type, le client JDBC Postgres lève une exception comme celle-ci :
217 An I/O error occurred while sending to the backend.
Failure to execute query with SQL:
INSERT INTO "set_app" ("element") VALUES (?) :: [219]
PSQLException:
Message: An I/O error occured while sending to the backend.
SQLState: 08006
Error Code: 0
218 An I/O error occured while sending to the backend.
...que l'on pourrait interpréter comme "les transactions en écriture de 217 et 218 ont échoué". Toutefois, lorsque l'application de test interroge la base de données pour retrouver quelles transactions en écriture ont réussi, il constate que deux écritures en échec sont effectivement présentes :
1000 total
950 acquittées
952 survivants
2 écritures non acquittées trouvées ! ?(´?`)?
(215 218)
0.95 taux d'acquittement
0.0 taux de perte
0.002105263 taux de non acquittées mais réussies
Sur 1000 tentatives en écriture, 950 ont été acquittées avec succès et toutes ces 950 sont présentes dans l'ensemble des résultats. Cependant, les deux écritures (215 et 218) qui ont réussi ont levé une exception. Notez que cette exception ne garantit pas que l'écriture a réussi ou échoué : 217 a aussi levé une exception pendant l'envoi mais, parce que la connexion est tombée avant que le message de commit du client n'arrive au serveur, la transaction n'a jamais eu lieu.
Il n'existe aucun moyen fiable de distinguer ces cas depuis le client. Un partitionnement réseau - comme c'est le cas de la plupart des erreurs réseau - ne signifie pas un échec. Cela représente une absence d'information. Sans un protocole de commit tolérant au partitionnement, comme le protocole de commit étendu à trois phases, nous ne pouvons pas affirmer l'état de ces écritures.
Vous pouvez gérer cette indétermination en rendant vos opérations idempotentes et en les ré-appliquant aveuglement, ou en écrivant l'identifiant de la transaction dans la transaction elle-même pour la requêter une fois le partitionnement disparu.
Redis
Redis est un serveur de structures de données, typiquement déployé comme une heap partagée. Comme il fonctionne sur un serveur mono-threadé, il offre une cohérence linéarisable par défaut : toutes les opérations se déroulent dans un ordre unique, bien défini.
Redis dispose également d'une réplication asynchrone primaire -> secondaire. Un seul serveur est choisi comme primaire pouvant accepter les écritures. Il relaie ses changements d'états aux serveurs secondaires, qui suivent. Asynchrone, signifie dans ce contexte que le client ne bloque pas pendant que le serveur primaire réplique une opération donnée - l'écriture arrivera "éventuellement" sur le serveur secondaire.
Pour gérer la découverte, l'élection du leader et le basculement, Redis inclut un système compagnon : la sentinelle Redis (_Redis Sentinel_). Les nœuds sentinelles discutent entre eux de l'état des serveurs Redis qu'ils peuvent voir et tentent de promouvoir ou de rétrograder les nœuds pour maintenir une seule autorité primaire. Dans ce test, j'ai installé Redis et des sentinelles Redis sur les cinq nœuds. Initialement, tous les cinq clients lisent le primaire sur n1 et n2--n5 sont les secondaires. Ensuite, nous procédons au partitionnement de {n1, n2}, à l'écart de {n3, n4, n5}.
Si Redis était un système CP, n1 et n2 deviendraient indisponibles durant le partitionnement et un nouveau serveur primaire serait élu parmi (n3, n4, n5). Ce n'est pas le cas. Au lieu de cela, les écritures continuent à être menées depuis n1. Après quelques secondes, les nœuds sentinelles commencent à détecter le partitionnement et élisent, disons, n5 comme nouveau serveur primaire.
Pour la durée du partitionnement, il y a deux nœuds primaires - un dans chaque partie du système - et les deux acceptent les écritures d'une manière indépendante. C'est un scénario classique de split-brain et celui-ci viole le C de CP. Les écritures (et lectures) ne sont pas linéarisables dans cet état parce que les clients verront des états différents de la base suivant le nœud avec lequel ils parlent.
Qu'advient-il lorsque le partitionnement est corrigé ? Jusqu'à il y a peu de temps, Redis laissait les deux serveurs primaires fonctionner indéfiniment. Tout partitionnement entraînant une promotion provoquait un split-brain permanent. Cela a changé avec Redis 2.6.13, qui a été publié le 30 avril 2013. Maintenant, les sentinelles vont résoudre le conflit en rétrogradant le serveur primaire originel, détruisant une série potentiellement illimitée d'écritures dans le processus. Par exemple :
2000 total
1998 acquittées
872 survivants 1126 écritures acquittées perdues! (?°?°)?? ???
50 51 52 53 54 55 ... 1671 1675 1676 1680 1681 1685
0.999 taux d'acquittement
0.5635636 taux de perte
0.0 taux de non-acquittées mais réussies
Sur les 2000 écritures, Redis revendique que 1998 d'entre elles ont été terminées avec succès. Cependant, seuls 872 des entiers sont présents au final. Redis a abandonné 56% des écritures qu'il a prétendu avoir réussi.
Il y a deux problèmes ici. Tout d'abord, notez que tous les clients perdent des écritures au début du partitionnement : (50, 51, 52, 53, ...). C'est parce qu'ils ont tous écrit à n1 lorsque le réseau est tombé et, puisque n1 a été rétrogradé plus tard, toutes les écritures effectuées dans cette fenêtre ont été détruites.
Le second problème a été causé par le split-brain : les deux serveurs n1 et n5 étaient primaires jusqu'au rétablissement du partitionnement. Selon le nœud avec lequel ils parlaient, pour certains clients, les écritures peuvent avoir survécu, pour d'autres, elles peuvent avoir été perdues. Les derniers nombres dans l'ensemble (modulo 5) sont tous 0 ou 1 : ils correspondent aux clients qui ont conservé n1 comme serveur primaire, c'est à dire ceux qui se sont adressé aux serveurs de la partie minoritaire.
Dans aucun schéma de réplication avec basculement (failover), Redis n'offre ni une haute disponibilité ni la cohérence. N'utilisez Redis que comme un cache "best-effort" et une heap partagée dans les cas où la perte ou la corruption de données est acceptable.
MongoDB
MongoDB est une base de données orientée document dont la conception distribuée ressemble à celle de Redis. Dans un Replica Set, il existe un unique nœud primaire qui accepte les écritures et réplique de manière asynchrone un journal de ses opérations ("oplog") sur N secondaires. Il existe toutefois quelques différences clés par rapport à Redis.
Tout d'abord, Mongo établit en interne le choix du leader ainsi que la réplication de la machine à état. Il n'existe pas de système à part qui observe le Replica Set pour décider ce qui doit être fait. Le Replica Set décide lui même quel nœud doit être primaire, quand se retirer, comment répliquer, etc. Sur le plan opérationnel, les choses sont plus simples et cela élimine toute une classe de problèmes de topologie.
Ensuite, vous pouvez demander à Mongo de confirmer si le nœud primaire a réussi la réplication d'une écriture au travers de son journal de disque, ou par les nœuds secondaires. Des garanties plus fortes peuvent être données concernant le succès ou non d'une écriture au coût d'une latence plus importante.
Pour le tester, j'ai mis en place un Replica Set composé de cinq nœuds, le primaire étant sur n1. Les clients font leurs écritures dans un unique document (l'unité de cohérence MongoDB) au travers de mises à jour atomiques du type compare-and-set. J'ai créé un partitionnement où n1 et n2 sont à part du reste du cluster pour forcer Mongo à élire un nouveau nœud primaire dans la composante majoritaire et rétrograder n1. J'ai laissé le système fonctionner dans un état partitionné pendant une courte durée, puis j'ai reconnecté les nœuds, permettant à Mongo de re-converger avant la fin du test.
Il existe plusieurs niveaux de cohérence concernant les opérations menées sur MongoDB, appelés "write concerns". Jusqu'à tout récemment, l'option par défaut était d'éviter d'effectuer tout contrôle, quel que soit type d'échec. Le driver Java appelle cela WriteConcern.UNACKNOWLEDGED. Bien sûr, cette approche peut provoquer la perte d'un certain nombre d'écritures en "succès" pendant le partitionnement :
6000 total
5700 acquittées
3319 survivants
2381 écritures acquittées perdues ! (?°?°)?? ???
469 474 479 484 489 494 ... 3166 3168 3171 3173 3178 3183
0.95 taux d'acquittement
0.4177193 taux de perte
0.0 taux de non-acquittées mais réussies
Dans cet essai, 42% des écritures, de 469 à 3183, ont été rejetées.
Toutefois, WriteConcern.SAFE, qui confirme que la donnée est commitée avec succès sur le nœud primaire, perd également un grand nombre d'écritures :
6000 total
5900 acquittées
3692 survivants
2208 écritures acquittées perdues ! (?°?°)?? ???
458 463 468 473 478 483 ... 3075 3080 3085 3090 3095 3100 0.98333335 taux
d'acquittement
0.3742373 taux de perte
0.0 taux de non-acquittées mais réussies
Parce que le protocole de réplication est asynchrone, les écritures continuent de réussir sur n1, alors que n1 ne peut pas répliquer ces écritures sur le reste du cluster. Quand n3 a été élu nœud primaire dans la composante majoritaire, il est arrivé au pouvoir avec une version ancienne de l'histoire, déconnectée du point de vue causal des écritures de n1. Les deux ont évolué de manière indépendante pendant un certain temps, avant que n1 ne réalise qu'il doive se retirer.
Lorsque le partitionnement a disparu, Mongo a tenté de déterminer quel nœud faisait autorité. Bien sûr, il n'y a pas de nœud qui fasse foi puisque les deux ont accepté des écritures pendant le partitionnement. A la place, MongoDB essaye de trouver le nœud qui possède l'optime le plus élevé (l'entrée du journal des opérations oplog avec le timestamp le plus grand). Ainsi, il force l'ancien nœud primaire n1 à revenir en arrière jusqu'au dernier point commun avec n3 pour ré-appliquer les opérations de n3.
Lors du retour en arrière, MongoDB effectue un dump sur le disque de l'état courant des objets en conflit, dans un fichier BSON. Un opérateur pourra ensuite tenter de reconstituer le bon état du document.
Ce système présente plusieurs problèmes. D'abord, il existe un bogue dans le code de l'élection du leader : MongoDB peut promouvoir un nœud qui n'a pas le plus haut optime. Ensuite, il existe un autre bogue dans le code du rollback. Dans mes tests, la restauration ne fonctionnait à peu près qu'une fois sur dix. Dans la plupart des cas, MongoDB a rejeté entièrement toutes les données contradictoires. De plus, tous les objets ne seront pas entièrement enregistrés pendant un rollback : notamment les collections capped qui par conception ignorent tous conflits. Troisièmement, même si ces systèmes fonctionnent correctement, le log du rollback ne suffit pas pour récupérer la linéarisabilité. Parce que la version de restauration et le journal des opérations (oplog) ne partagent pas un ordre causal bien défini, seules les fonctions de fusion indépendantes de l'ordre (par exemple les CRDTs) peuvent reconstruire dans tout les cas l'état correct du document.
Ce manque de linéarisibilité, qui garantit que les écritures sont acquittées par deux replicas avant que la requête n'aboutisse en succès, s'applique également à FSYNC_SAFE, JOURNAL_SAFE, et même REPLICAS_SAFE :
6000 total
5695 acquittées
3768 survivants
1927 écritures acquittées perdues ! (?°?°)?? ???
712 717 722 727 732 737 ... 2794 2799 2804 2809 2814 2819
0.94916666 taux d'acquittement
0.338367 taux de perte
0.0 taux de non acquittées mais réussies
Le seul moyen de récupérer la linéarisibilité dans le modèle de MongoDB est d'attendre qu'un quorum de nœuds réponde. Cependant, WriteConcern.MAJORITY est tout autant incohérent, abandonnant des écritures acquittées et restaurant des écritures en échec.
6000 total
5700 acquittées
5701 survivants
2 écritures acquittées perdues ! (?°?°)?? ???
(596 598)
3 écritures non acquittées trouvées ! ?(´?`)?
(562 653 3818)
0.95 taux d'acquittement
1.754386E-4 taux de perte
5.2631577E-4 taux de non acquittées mais réussies
Là où UNSAFE, SAFE, et REPLICAS_SAFE peuvent perdre une partie ou toutes les écritures pendant un partitionnement, MAJORITY peut uniquement perdre des écritures qui étaient en vol quand le partitionnement commencé. Lorsque le nœud primaire se met en indisponibilité, il approuve toutes les requêtes WriteConcern, plaçant OK à VRAI quelque soit la satisfaction du WriteConcern pour chaque réponse.
De plus, MongoDB peut émettre un nombre quelconque de faux négatifs. Dans cet essai, 3 écritures non acquittées ont été effectivement récupérées dans l'ensemble des données finales. Au moins dans la version 2.4.1 et les versions antérieures, il n'y a aucun moyen d'empêcher la perte de données lors du partitionnement, quel que soit le niveau de cohérence.
Si vous avez besoin de la linéarisabilité dans MongoDB, utilisez WriteConcern.MAJORITY. Il ne sera pas réellement cohérent, mais cela réduira considérablement la fenêtre de perte en écriture.
Riak
En tant que clone de Dynamo, Riak adopte une approche AP de la résistance au partitionnement. Riak permet de détecter les historiques incohérents - que ce soit en raison d'un partitionnement ou d'écritures concurrentes - et présente toutes les copies divergentes d'un objet au client pour qu'il choisisse ensuite la façon de les fusionner.
La fonction de fusion par défaut dans Riak est le last-write-wins. Chaque écriture comprend un timestamp, la fusion de valeurs entre elles est réalisée en conservant uniquement la version avec le timestamp le plus grand. Si les horloges sont parfaitement synchronisées, cela garantit que Riak prend la valeur la plus récente.
Même en l'absence de partitionnement et de décalage d'horloge, en présence d'écritures concurrentes du point de vue causal, la stratégie last-write-wins peut entraîner l'abandon silencieux de certaines écritures en succès :
2000 total
2000 acquittées
566 survivants
1434 écritures acquittées perdues ! (?°?°)?? ???
1 2 3 4 6 8 ... 1990 1991 1992 1995 1996 1997
1.0 taux d'acquittement
0.717 taux de perte
Dans ce cas, un cluster en bonne santé perd 71% de ces opérations parce que lorsque deux clients écrivent une valeur à peu près au même moment, Riak choisit simplement celle avec le plus grand timestamp et ignore les autres, qui auraient pu ajouter de nouveaux nombres.
Souvent, les gens tentent de résoudre ce problème en ajoutant un service de verrouillage pour éviter les accès concurrents. Puisque les verrous doivent être linéarisables, le théorème CAP nous dit que les systèmes distribués de verrous ne peuvent être totalement disponibles durant un partitionnement - mais même s'ils l'étaient, cela n'empêcherait pas la perte d'écritures. Voici un cluster Riak avec R=W=QUORUM, dans lequel tous les clients effectuent leurs lectures+écritures atomiquement en utilisant un mutex. Lorsque le partitionnement se produit, Riak perd 91% de ses écritures en succès :
2000 total
1985 acquittées
176 survivants
1815 écritures acquittées perdues ! (?°?°)?? ???
85 90 95 100 105 106 ... 1994 1995 1996 1997 1998 1999
6 écritures non acquittées trouvées ! ?(´?`)?
(203 204 218 234 262 277)
0.9925 taux d'acquittement
0.91435766 taux de perte
0.00302267 taux de non acquittées mais réussies
En fait, last-write-wins peut provoquer une perte de données illimitée, y compris la perte de l'information écrite avant que le partitionnement se produise. Ceci est possible parce que Dynamo (par conception) permet des quorums approximatifs (sloppy quorums), pour lesquels les vnodes de secours (fallback vnodes) des deux côtés du partitionnement peuvent satisfaire R et W.
Nous pouvons dire à Riak d'utiliser un quorum strict avec PR et PW, qui réussit seulement si un quorum des vnodes originaux acquitte l'opération. Cela peut encore provoquer des pertes de données illimitées si un partitionnement se produit :
2000 total
1971 acquittées
170 survivants
1807 écritures acquittées perdues ! (?°?°)?? ???
86 91 95 96 100 101 ... 1994 1995 1996 1997 1998 1999
6 écritures non acquittées trouvées ! ?(´?`)?
(193 208 219 237 249 252)
0.9855 taux d'acquittement 0.9167935 taux de perte
0.00304414 taux de non acquittées mais réussies
Dynamo est conçu pour conserver les écritures autant que possible. Même si un nœud peut retourner "PW val insatisfait" ("PW val unsatisfied") quand il ne peut pas répliquer aux vnodes primaires pour une clé, il a peut-être quand même réussi à écrire sur un unique vnode primaire, ou sur n'importe quel nombre de vnodes de secours (fallback vnodes). Considérées comme des conflits, ces valeurs vont toujours être échangées au cours de la lecture-réparation. Le timestamp sera utilisé pour écarter la plus vieille valeur, soit toutes les écritures d'une des parties du cluster.
Autrement dit, les écritures "en échec" de la composante minoritaire peuvent détruire toutes les écritures réussies de la composante majoritaire.
Il est possible de préserver les données dans un système AP en utilisant les types de données CRDT. Si nous utilisons comme structure de données les sets (les ensembles), avec l'union pour fonction de fusion, nous pouvons préserver toutes les écritures même en présence de partitionnements arbitraires.
2000 total
1948 acquittées
2000 survivants Tous
2000 écritures réussies. :-D
Ceci n'est pas de la cohérence linéarisable - et toutes structures de données ne peuvent pas être représentées comme des types CRDT. Cela n'empêche pas non plus les faux négatifs - Riak peut toujours ne pas répondre dans les délais ou avoir une défaillance - mais il doit garantir la convergence sûre des écritures acquittées.
Si vous choisissez Riak, utilisez les types CRDT, ou écrivez autant que possible une fonction de merge. Il n'y a que quelques cas (par exemple, les données non mutables) pour lesquels la stratégie LWW (last-write-wins) est appropriée ; évitez-la dans tout les autres cas.
Hypothèses sur la mesure
Nous avons vu que les systèmes distribués peuvent se comporter de manières inattendues sous la contrainte d'un partitionnement - mais la nature de ce défaut dépend de nombreux facteurs. La probabilité de perte de données et des états incohérents dépend de votre application, du réseau, de la topologie cliente, du timing, de la nature du dysfonctionnement, etc. Au lieu de vous dire quelle base de données en particulier choisir, je vous encourage à réfléchir sérieusement sur les invariants dont vous avez besoin, les risques que vous êtes prêts à accepter et à concevoir votre système en conséquence.
Un élément clé de la mise au point de cette conception est de la mesurer. Tout d'abord, établissez les limites du système - les endroits où il interagit avec l'utilisateur, Internet ou d'autres services. Déterminer les garanties que ces limites doivent tenir.
Ensuite, développez un programme qui effectue des requêtes depuis le point immédiatement à l'extérieur des limites vers le système et qui mesure les conséquences externes. Notez si les requêtes HTTP retournent les codes 200 ou 503, ou la liste des commentaires sur un post à chaque point. Si l'exécution de ce programme cause une panne : tuer le processus, démonter le disque ou isolez les nœuds les uns des autres.
Enfin, comparez les logs pour vérifier les garanties du système. Par exemple, si des messages acquittés d'un chat doivent être délivrés au moins une fois à leurs destinataires, vérifiez si effectivement les messages ont été reçus.
Les résultats peuvent être surprenants ; utilisez-les pour enquêter sur la conception du système, son implémentation et ses dépendances. Envisagez la conception du système par la mesure en continu de ses garanties critiques de bon fonctionnement, tout comme vous instrumentez vos performances.
Les leçons
Même si vous n'utilisez pas MongoDB ou Riak, des leçons générales peuvent être retenues de ces exemples.
Tout d'abord, les clients ne sont pas des observateurs objectifs, ils jouent un rôle important dans le système distribué. Les erreurs réseaux signifient "Je ne sais pas", et non "Il échoue". Dans votre code et API, faites une distinction explicite entre succès, échec et indétermination. Envisagez de prolonger les algorithmes de cohérence au-delà des limites de vos systèmes : attrapez les ETags des clients TCP ou les horloges vectorielles, ou étendez les types CRDT au navigateur.
Même les algorithmes bien connus comme le commit à deux phases ont certains inconvénients, comme de faux négatifs. La cohérence transactionnelle de SQL se décline en plusieurs niveaux. Si vous utilisez des niveaux de cohérence forts, n'oubliez pas que la gestion des conflits est essentielle.
Certains problèmes sont difficiles à bien résoudre, comme le maintien d'une autorité primaire avec basculement. La cohérence est une propriété des données, non des nœuds. Évitez les systèmes faisant l'hypothèse qu'un consensus sur l'état des nœuds implique nécessairement cohérence des données.
La mesure du temps-réel (wall-clock time) n'est utile que pour assurer la réactivité face à un deadlock, même si elle n'apporte pas une précision garantie. Dans ces tests, toutes les horloges étaient bien synchronisées avec NTP et pourtant, nous avons perdu des données. Des choses bien pires peuvent arriver en cas de désynchronisation d'une horloge, ou si un nœud interrompt ses opérations pendant un laps de temps. Utilisez les horloges logiques pour vos données. Méfiez-vous des systèmes qui reposent sur le temps système, à moins de faire fonctionner un GPS ou une horloge atomique sur vos nœuds. Mesurez toujours le décalage d'horloge.
Appuyez-vous sur les techniques de preuves formelles pour résoudre les problèmes avec exactitude, étudiez la littérature. Il y a un énorme fossé entre un algorithme théoriquement correct et le comportement d'un logiciel en vrai - en particulier en ce qui concerne la latence - mais une implémentation légèrement fausse d'un algorithme est généralement mieux qu'une bonne implémentation d'un mauvais algorithme. Les bogues peuvent être corrigés, les choix de conception sont beaucoup plus difficiles à réévaluer.
Choisissez la bonne conception pour l'ensemble du problème auquel vous être confronté. Certaines parties de votre architecture exigent une forte cohérence. D'autres parties peuvent sacrifier à la linéarisabilité tout en restant correctes, comme avec les types CRDT. Parfois, vous pouvez vous permettre de perdre toutes les données. Il existe souvent un compromis entre la performance et la justesse : pensez, expérimentez et découvrez.
Il peut être plus facile d'atteindre une certaine sûreté en restreignant votre système avec des règles particulières. L'immutabilité est une propriété très utile et peut être combinée avec des data stores CP mutables pour obtenir des systèmes hybrides puissants. Utilisez des opérations idempotentes autant que possible : elles permettent toutes sortes de mécanismes de files d'attente et les sémantiques de re-soumission. Si possible, allez plus loin en n'utilisant que des types CRDT.
Prévenir la perte d'écriture dans une base de données comme MongoDB demande un compromis important sur le temps de latence. Il peut être plus rapide de simplement utiliser Postgres. Parfois acheter du matériel réseau plus fiable et une infrastructure plus puissante est moins chère que de scaler horizontalement. Parfois non.
La problématique des états distribués est difficile, mais nous pouvons rendre nos systèmes beaucoup plus fiables avec un peu de travail. Pour en savoir plus sur les conséquences du partitionnement réseau, y compris les exemples d'échecs en production, voir ces articles.
A propos de l'Auteur
Kyle Kingsbury est ingénieur chez [Factual], et écrit sur ce blog. Il est aussi l'auteur de [Riemann], un système de monitoring événementiel open source ; et de [Timelike], une expérience en simulation réseau. Il vit à San Francisco en Californie et n'a aucune idée de comment un ordinateur fonctionne.