Cohérence ou disponibilité des données : quels éléments de choix ?

Même s'il ne s’applique pas tout à fait au monde réel actuel, le théorème du CAP, énoncé par Eric Brewer, permet de pointer du doigt une dure réalité : à un instant donné, il n’est pas possible d’avoir exactement la même donnée sur plusieurs machines sans sacrifier la disponibilité de nos applications. Depuis sa démonstration (et même avant son énonciation), nous devons faire des choix pour la persistence des données de nos applications. Ces choix vont avoir des impacts importants sur nos applications, du développement à la production. Aussi, il est nécessaire de prendre le temps de les faire en connaissance de cause.

Dans cet article, ne sera considéré que le cas où la tolérance au partitionnement est nécessaire. Il va donc être question ici de faire un choix entre un référentiel majoritairement cohérent (consistant) ou un référentiel majoritairement disponible.

Cet article ne considère aussi que le cas du stockage “formaté” de données pour une consommation “en direct” par une application. Les sujets de stockage dans des data lakes ne sont pas considérés.

Rappels du théorème et terminologie

Le théorème du CAP prouve qu’il n’est pas possible, au même instant, de garantir :

  • La Cohérence/Consistance (Consistency) des données : la définition utilisée par le théorème est que les données seront les mêmes, à tout instant, sur tous les nœuds d’un système répliqué. Cette définition est très différente du C (consistant / cohérent) de la définition des bases ACID ! Dans la suite de cet article, j’utiliserai le terme cohérent au sens défini dans CAP.
  • La Disponibilité (Availability) des données : la définition utilisée par le théorème est qu’un nœud en état de fonctionnement doit être capable de traiter tout type de requête supporté par le système (que ce soit en écriture ou en lecture).
  • La Tolérance au partitionnement (Partition Tolerance) : il s’agit ici de dire que seule une panne affectant toutes les communications entre tous les nœuds doit empêcher le système de fonctionner correctement. En fait, très peu de systèmes actuels peuvent se passer de cette contrainte.

Le théorème est certes une vision simplifiée (et avec des contraintes très fortes) de la réalité. Mais le fait qu’il soit nécessaire de faire un choix, au moins dans une certaine mesure, entre la Cohérence ou la Disponibilité des données reste une réalité. On parlera de systèmes AP (Availability / Partition Tolerance) ou CP (Consistency / Partition Tolerance) en fonction des choix qui sont faits.

Spoiler! Dans la majorité des systèmes actuels, on préférera la disponibilité à la cohérence, on acceptera alors la cohérence à terme. C’est-à-dire que le référentiel sera cohérent dans le futur (sans forcément connaitre le délai).

Les grandes familles de persistance

Chaque famille de persistance offre des possibilités très différentes en termes de cohérence ou de disponibilité. Je ne rentrerai pas dans les détails ici, le sujet étant bien trop vaste. Mais voici juste un rappel sur quelques-unes des principales caractéristiques des grandes familles de persistance.

Les SGBDR

Les Système de Gestion de Bases de Données Relationnelles (ou SGBDR) répondent aux propriétés ACID :

  • Atomicité : une transaction est faite en entier ou pas du tout.
  • Cohérence : à la fin de chaque transaction le système est dans un état valide. Cette définition est très différente de la définition de cohérence ou consistance utilisée dans le théorème du CAP
  • Isolation : les transactions doivent s'exécuter comme si elles étaients les seules sur le système.
  • Durabilité : lorsqu’une transaction a été validée, les modifications en résultant resteront présentes sur le système, même en cas de panne.

Ces contraintes, très strictes, ont de forts impacts sur ce qu’il est possible de mettre en place et sur les performances atteignables avec ces systèmes.

Historiquement, ces bases ne fonctionnent pas sans la définition d’un schéma strict connu par la base (même si certaines permettent le stockage et l'interprétation de données structurées, mais non modélisées, comme le JSON ou le XML, via les intra-jointures).

Ces bases permettent de lier nativement les données de différentes tables entre elles.

Les bases NoSQL

Les bases de données Not Only SQL (le nom ne veut pas dire qu’il n’y a pas de SQL, ce nom vient d’un hashtag pour un événement et est relativement trompeur) répondent aux propriétés BASE :

  • Basic Availability : la base doit paraître fonctionnelle la grande majorité du temps.
  • Soft-state : les écritures ne sont pas consistantes, que ce soit sur disque ou entre les nœuds.
  • Eventual consistency : la cohérence des données n’est pas assurée immédiatement, mais elle arrivera avec le temps.

Ces contraintes, qui peuvent ne pas sembler suffisantes pour un système de persistance, permettent une bien meilleure disponibilité et scalabilité que les contraintes ACID.

Il existe plusieurs familles de bases NoSQL, les 4 principales sont :

  • Clé valeur : ces bases permettent d’associer une clé à une valeur (ex : Redis). Leur simplicité d’utilisation et leurs très hautes performances font qu’elles sont souvent utilisées comme systèmes de cache partagé.
  • Colonne : ces bases permettent de stocker des lignes, chaque ligne pouvant avoir une grande quantité de colonne, une colonne étant un tuple (clé -> valeur). Les lignes d’une même collection n’ont pas forcément les mêmes colonnes (ex : Cassandra). Leur excellente scalabilité horizontale en font les meilleures candidates pour le stockage de très grands volumes de données fortement sollicitées par des utilisateurs de tous les continents.
  • Document : ces bases permettent de stocker et de requêter le contenu de documents (ex : MongoDB). Leur souplesse, facilité de modélisation et relative gestion de la consistance en font d'excellente candidates pour le stockage dénormalisé s’approchant des vues à présenter aux utilisateurs finaux.
  • Graph : ces bases permettent la représentation et la navigation dans des graphes (ex : Neo4J). Ce sont les meilleures candidates pour le stockage de données liées entres elles avec des règles de navigation pouvant être complexes et qui doivent être naviguées efficacement.

La grande majorité des implémentations de ces bases assurent l’atomicité des opérations sur une de leur composante élémentaire (la ligne, un document....).

Une grande partie des implémentations de ces bases permettent de travailler efficacement sans que la base n’ait connaissance d’un schéma. Ce sont les applications qui vont induire le schéma avec l’alimentation des données.

Les stratégies de persistance

Il existe plusieurs stratégies pour le stockage des données d’une application, même lorsque nous ne parlons que du stockage “formaté” de données. Le choix d’une stratégie plutôt qu’une autre va avoir des impacts pour le choix d’un référentiel cohérent ou disponible, c’est un élément important à prendre en compte dans l’architecture générale de l’application.

Stockage en un exemplaire de l’état actuel du référentiel

C’est la manière de stocker les données la plus simple à comprendre et à manipuler (et la plus utilisée pour des raisons historiques). Un questionnement du référentiel va nous donner une vision sur l’état actuel des données qu’il sera alors facile d’afficher aux utilisateurs pour leur permettre d’y faire des modifications.

Pour permettre un fonctionnement correct de ce type de persistance, nous avons besoin de transactions lors de la manipulation des agrégats (au sens du Domain Driven Design : des groupements d’entités pensés autour du métier pour limiter la portée des transactions). Les années d’utilisation des SGBDR nous ont appris que les transactions sont très coûteuses (la manipulation d'une donnée peut bloquer les autres opérations sur cette même donnée). Nous avons donc beaucoup d’outils à notre disposition pour gérer au mieux ces transactions. Si le choix est fait d’utiliser un référentiel NoSQL, il faudra que nos agrégats se trouvent dans un seul bloc atomique de notre persistance (ou nous devrons faire des commits en plusieurs phases).

En utilisant cette stratégie de persistance, les développeurs vont s’attendre à trouver immédiatement en base les modifications qu’ils viennent de faire. De fait, l’utilisation d’un référentiel AP va demander une grande vigilance lors des revues pour s’assurer que l’application se base sur les données qui viennent d’être saisies et non pas sur les données du référentiel (qui peuvent ne pas être à jour en cas de questionnement immédiat).

À l’inverse, l’utilisation d’un référentiel CP pour une représentation complète d’un domaine persisté de cette manière va être extrêmement compliqué à faire fonctionner sur le long terme. Dans ce cas, il est souvent préférable d’utiliser une persistance active / passive qui sera bien plus facile à maintenir et permettra une meilleure tolérance aux pannes qu’une base seule.

Stockage en plusieurs exemplaires de l’état actuel du référentiel

Cette seconde manière de stocker les données devient de plus en plus nécessaire avec les besoins de manipulations de plus en plus riches et des volumes de données de plus en plus importants. La séparation des commandes et des requêtes, telle que définie par le CQRS (Command Query Responsability Segregation), permet la persistance de la même donnée sous des formes adaptées aux utilisations.

Pour que ce modèle fonctionne correctement, nous avons besoin d’un référentiel de commandes qui sera seul maître de la cohérence des données, mais qui ne sera sollicité que pour les opérations d’écriture (qui ne sont pas forcément les plus coûteuses).

Dans ce modèle, il est beaucoup plus facile d’admettre un délai entre la sauvegarde d’une donnée et sa disponibilité dans toutes ses vues (Elasticsearch aura besoin d’une seconde pour indexer les données, par exemple).

Ce modèle peut entrainer certaines complexités de résolution de conflits dans le calcul des vues.

Stockage des événements et calcul de l’état actuel du référentiel

Cette approche d’event sourcing permet d’avoir des transactions très réduites, il n’est en effet pas possible de modifier ce qui s’est passé, une mise à jour se traduira par un événement qui viendra changer l’état des objets.

L’event sourcing est souvent associé au CQRS (et vice-versa). Cela permet d’avoir un référentiel de commandes avec seulement des événements et différentes vues calculées en fonction des événements qui permettent un rendu efficace des données. Cette approche permet de bénéficier :

  • De référentiels permettant une gestion extrêmement simple des transactions (les Event Store ne doivent gérer que des ajouts et il est très simple d’en faire de nouveaux en fonction des besoins).
  • De vues calculées pour être au plus proche des usages qui permettront donc une limitation des mappings, jointures et transferts (et permettront donc de biens meilleurs temps de réponse). Le fait de pouvoir calculer des vues simplifie aussi beaucoup la gestion de la scalabilité.

Malgré la nécessité de rejouer beaucoup de lignes pour avoir l’état d’un seul objet, cette approche est très performante et permet d’assurer la cohérence à terme (chaque calcul de vue pouvant simplement résoudre les évènements lorsqu’ils arrivent). Cependant, malgré des outils aussi aboutis qu’AxonFramework, cette approche reste très complexe à comprendre et à mettre en place. Il faut garder en tête :

  • Qu’il ne sera jamais possible de modifier un événement, une erreur ou une typo dans la définition d’un événement restera dans la base.
  • Que le référentiel qui fait foi ne contient pas la donnée actuelle, mais une série d'événements permettant de connaître cette donnée. Cela complexifie fortement la compréhension de l’application.

Malgré les possibilités techniques redoutables de cette dernière approche, elle demande une grande maîtrise du domaine et des outils. Elle ne doit donc être utilisée que si c’est réellement nécessaire.

Il existe de très nombreux articles détaillants la mise en place et l’utilisation du CQRS et de l’Event sourcing. Vous pouvez cependant lire Event sourcing, CQRS, stream processing and Apache Kafka: What’s the connection? qui présente très en détail la mise en place d’une stack articulée autours des événements transportés par Kafka. Depuis sa version 0.11 et le exactly once delivery, Kafka est, plus que jamais, un sérieux atout pour les architectures d’Event Sourcing, mais ce n’est pas le sujet de cet article ;)

Quelques critères à prendre en compte

Choisir ses types de bases et stratégies de persistance n’est pas simple. Voici une liste, loin d’être exhaustive, de critères à prendre en compte.

Utilisation des données

L’utilisation qui sera faite des données est un critère de choix essentiel, bien avant le type de données à stocker. En effet, c’est bien l’utilisation qui va dicter les besoins de scalabilité, de disponibilité et de cohérence.

Si vous devez travailler sur des transactions d’argent (réel ou virtuel), la concession sur la cohérence peut signifier des pertes d’argent. C’est un risque qui doit être calculé.

Si vous devez travailler sur le stockage de données pour des objets connectés distribués par millions à travers le monde, la concession sur la disponibilité rendra votre système rapidement inutilisable.

Volume de données

Les volumes de données qui vont être stockés, mais surtout les volumes de données qui doivent être manipulés à chaque requête vont avoir un impact important sur le choix de la persistance.

Un référentiel cohérent ne pourra pas traiter autant de données dans une requête qu’un référentiel qui ne l’est pas.

Si votre prévisionnel se trouve autour du milliard d’entrées pour les collections les plus importantes de votre application avec peu de calculs complexe sur ces collections, la question du volume de données ne se pose pas, les volumes ne sont pas suffisamment important pour poser problème à une persistance actuelle.

Maturité de l’équipe produit

Les frameworks permettent maintenant une utilisation très simple de tous les types de bases. Cependant certaines intégrations sont plus naturelles que d’autres en terme d'utilisation. La maturité technique de l’équipe ainsi que sa capacité à monter en compétence est un élément essentiel à prendre en compte lors de la mise en place d’une persistance.

Il faut penser à prendre en compte :

  • La capacité à modéliser le domaine pour qu’il soit persisté dans le référentiel en suivant les bonnes pratiques du référentiel. Tout particulièrement, la constitution des agrégats (et donc des entités) doit être faite en accord avec le moteur de persistance.
  • La capacité à travailler de manière asynchrones. L’impact est réel pour les développeurs et sur la conception du code, mais n’est pas forcément simple à voir (surtout sur le poste du développeur avec un seul utilisateur).

Quelques autres

La liste précédente de critères à prendre en compte n’est pas (et ne sera jamais) exhaustive, en voici quelques autres à garder en tête :

  • La répartition géographique des utilisateurs et des serveurs.
  • Le volume et/ou le ratio des écritures et des lectures.
  • Le volume de transfert réseau.

Gestion de la cohérence à terme

Faire le choix d’un référentiel AP c’est accepter de travailler avec une cohérence à terme des données. Cela va complexifier le développement et la conception, voici quelques éléments qui peuvent aider :

  • Afficher les données que l’utilisateur vient de saisir au lieu d’afficher l’entrée venant de la base permettra d’avoir une vision à jour et bien moins perturbante pour l’utilisateur.
  • Utiliser l’option de Read Your Own Writes présente sur certains systèmes qui permet de questionner, pour les lectures, le nœud qui vient d’être interrogé pour les écritures. Même si cette solution a des failles, elle peut être suffisante et faciliter grandement l’écriture et la conception des applications.
  • Mettre en place un système d'événements et d’alertes pour les utilisateurs. De plus en plus de sites ayant recours à cette méthode, elle est de mieux en mieux acceptée par les utilisateurs.

Quelques ressources sur ces sujets

  • Chat Service Architecture: Persistence : L’article (en anglais) explique les raisons et la démarche pour la migration de la persistance du système de chat depuis un cluster MySQL (1 master, 1 backup et 1 ETL, donc le master était un SPOF) vers une infrastructure RIAK (base NoSQL distribuée).
  • Utilisation du CQRS et de l’Event Sourcing chez BlaBlaCar : JUG (même si BlaBlaCar fait du PHP) expliquant le choix de l’utilisation du CQRS et de L’Event Sourcing chez BlaBlaCar.
  • Ce que j'ai appris en construisant des systèmes distribués : Michaël Figuière (actuellement chez Netflix), spécialisé sur Cassandra, explique dans une conférence les problèmes et difficultés rencontrés dans la construction et la mise en place de système distribués.
  • CQRS EventSourcing par la pratique : Clément Heliou (actuellement chez Xebia) nous présente son expérience sur ces deux dernières années dans l’implémentation et la mise en place du CQRS et de l’Event Sourcing dans les métiers de la banque.
  • Martin Fowler | Software Design in the 21st Century : Martin Fowler présente dans un talk très complet les défis de la conception d’applications actuelles (même si le talk date de 2013), il est bien sûr question de NoSQL et de CAP.

Une dernière chose

Le point le plus important pour faire des choix avisés est toujours le même : faire preuve de pragmatisme. Il ne faut pas sur-conceptualiser, d’autant qu’il sera toujours possible de changer son modèle de persistance tant qu’il n’est pas exposé à l'extérieur et que l’on fait attention à n'exposer que des API sur lesquelles nous avons la maîtrise.