Nouvelles Chroniques d'Amethyste

Penser au Sens, pas au Verbe

Patterns pour haute dispo et scalabilité d’une appli web – Partie III

Poster un commentaire

L’architecture CQRS

Poursuivons avec l’architecture CQRS.

CQRS fait partie de ces patterns émergeants pas encore très connus, mais je crois promis à un certain avenir d’autant plus que souvent on le rencontre par hasard sans savoir qu’il y a une architecture!!!

Soyons tout de même clair, la littérature disponible n’est pas encore à la hauteur de ce qui existe pour les architectures traditionnelles. Beaucoup de questions sont encore débattues.

Je me garderai bien d’intervenir dans un débat où je ne me sens pas l’épaisseur suffisante, mais si j’ai réussi à vous faire comprendre de quoi on parle et l’intérêt de CQRS, le but est atteint!

Quelle est l’idée?

Vous avez certainement constaté que le code qui récupère des données dans le repository est généralement plus complexe que celui qui va écrire.

  • En écriture
    nous n’avons pas besoin d’un modèle très sophistiqué, en général une entité par table.
    La transaction n’a pas besoin d’être immédiate. Par exemple la mise à jour du stock peut être différée bien après une mise en panier
  • En lecture
    Modèle sophistiqué combinant plusieurs tables et parfois spécifique à chaque type de requête.
    Mobilise souvent plusieurs tables
    Le résultat doit être immédiatement produit

 

Il est clair qu’il est utile et en tout cas pertinent de distinguer ces deux types de transaction. C’est l’idée qu’à introduit Bertrand Meyer dans son langage Eiffel. Une nouvelle sémantique est alors apparue:

  1. les commandes: opération/méthode en écriture.
    Une commande modifie les états de la base.
  2. les requêtes (request): opération/méthode en lecture.
    Une requête ne ne modifie pas les états de la base et est donc idempotente.

Une méthode est soit une commande, soit une requête. Ce pattern est parfois appelé CQS.

 

Le point crucial est qu’une méthode ne peut être à la fois requête et commande. C’est cela que CQRS va exploiter plus profondément que ne le fait CQS.

Quel problème tente de résoudre CQRS?

Depuis une trentaine d’années, le modèle dominant est celui des bases de données relationnelles. Cela ne signifie pas que ce modèle ne présente pas quelques limites qui impactent les performances des applications.  Voyons en quelques unes:

Concurrence des opérations de lecture et d’écriture

Pour assurer un comportement cohérent il a été théorisé qu’une base de données doit répondre à une série de principes appelés ACID:

  • A: atomicité
    Des opérations multiples doivent être réalisées en un seul bloc. Le bloc échoue ou réussi, mais toujours entièrement, pas en partie. En cas d’échec d’une des opérations, c’est l’ensemble du bloc qui est déjoué et on retourne à l’état initial.
  • C: Consistance
    Les transactions sont exécutées de façon consistantes, c’est à dire qu’elles soient conformes aux contraintes posées par le système de base de données. Le résultat d’une opération est donc le passage d’un état valide, vers un autre état valide, pas un entre deux.
  • I: Isolation
    Du point de vue de l’application, chaque opération est exécutée comme si elle était la seule à accéder à la base de données.
    Les bases de données proposent un mécanisme appelée isolation avec différents niveaux pour assurer cette fonctionnalité avec le moins de coût possible.
  • D: Durabilité
    Les données écrites dans une BD doivent être durables, c’est à dire qu’elle ne peuvent pas disparaître arbitrairement, par exemple en relançant le serveur

 

La consistance et l’isolation sont les deux propriétés qui posent le plus de problèmes pour la scalabilité et qui rend ACID difficilement compatible avec les systèmes distribués. Le problème provient surtout de la façon dont les choses sont implémentées.

La plupart du temps on pose des verrous sur les tables, les lignes ou les colonnes.

Un verrou cela signifie concrètement qu’à un moment ou un autre une transaction devra attendre pour procéder à une écriture et à certains niveaux comme SERIALIZABLE ou REPEATABLE READ, même une lecture peut entrer en concurrence avec une écriture. Le problème sera d’autant plus fréquent que le trafic sera important et que certains types de transactions complexes seront utilisées.

 

Cette concurrence est de plus renforcée par le fait que l’on utilise le même canal pour ces deux types d’opération. Le throughput en écriture se verra donc concurrencé par celui en lecture.

Normalisation du modèle

L’idée de la normalisation des tables est de limiter la place utilisée par la base, mais surtout de diminuer le plus possible les opérations d’écriture qui sont toujours très coûteuses en évitant les redondances.

Le prix à payer est une plus grande complexité du modèle et surtout la gestion de jointures. On sait tous que les jointures sont un puits à performances lors de la lecture.

Conclusions

Les opérations d’écriture et de lecture présente un profil de caractéristiques différent:

Ecriture

  • On accepte un coût plus élevé
  • Utilisation de verrous pour assurer les critères ACID
  • On essaye de limiter le nombre d’écritures en dénormalisant les tables
    La dénormalisation complexifie le modèle de données, mais on peut espérer quelques optimisations en parallélisant l’écriture dans plusieurs entités différentes
  • Un seul enregistrement à la fois
  • Besoin d’une logique de validation
  • Modifie les états

Lecture

  • On attend des performances importantes
    le positionnement de verrous de lecture est donc un vrai problème
  • Filtrage des données (clause WHERE).
    Il se pose alors la question du coût des jointures et donc de la normalisation
  • Plutôt intérêt de simplifier le modèle de données quitte à avoir des redondances
  • Lecture de plusieurs enregistrements
  • Pas besoin de logique de validation
  • Pas de modification des états
  • Besoin d’une logique de dérivation
    Par exemple une marque est associée à plusieurs produits, mais un produit est lié à une seule marque.
    Ce besoin est moins clair pour l’écriture
  • Les requêtes en lecture sont en général les plus nombreuses dans un site

 

On voit clairement qu’entre les commandes et les requêtes on a deux contingences contradictoires et on ne pourra pas optimiser les deux opérations à la fois. L’architecture CQRS nous offre une solution à ce problème.

CQRS est un acronyme pour Command Query Responsability Segregation. Le terme semble avoir été proposé par un architecte anglais: Greg Young.

 

L’idée de CQRS est que si séparation il y a, autant aller jusqu’au bout et séparer aussi les objets, les interfaces et même les repositories.

 

Remarque:

La mise ne place de CQRS est assez lourde dans une architecture. Prenez donc bien soin de valider que vous avez réellement un problème d’accès concurrentiel entre commandes et requêtes.

Il peut d’ailleurs arriver que la séparation commande/requête puisse se faire plus simplement en déplaçant certains champs dans une autre table.

CQRS

Définition

Le point essentiel est que CQRS ne sépare pas juste des opérations, mais on s’autorise un modèle physique différent pour les commandes et les requêtes. En d’autres termes, on utilise une base de données pour l’écriture et une base de donnée pour la lecture avec un certain niveau de synchronisation entre les deux:

2016-08-31_22-08-31

Il est par exemple fréquent de trouver des jointures dans les requêtes, opérations notoirement coûteuses. On pourra alors envisager un modèle de données moins normalisé comme les bases NoSQL dans laquelle plusieurs tables fusionnent pour donner un repository peut être plus verbeux, mais plus efficace en lecture.

Les bases secondaires utilisées pour les requêtes sont appelées bases de reporting.

 

L’aspect critique de cette architecture est bien entendu la synchronisation. Il est important, avant de décider si on part ou pas sur du CQRS, d’analyser les besoins en écriture/lecture, décider de ce qui est acceptable comme fenêtre de divergence et voir ce qu’il est possible de faire.

Ce problème s’appelle « cohérence des données« .

Dédramatisons les choses

C’est à ce stade de la discussion que tout le monde braque. Pensez donc:

On peut avoir des données désynchronisées? Mais ce n’est pas acceptable, jamais mon client n’acceptera.

Deux repositories, des archis différentes. Oula, mais c’est compliqué.

 

Vraiment?

Le choix d’une architecture ne relève pas d’un sondage d’opinion, mais d’une réflexion argumentée. Je vais proposer deux approches de la question.

Approche pragmatique de la cohérence

L’idée de temps réel ne veux rien dire. Entre le moment où la lecture est faite et celui où la page HTML est rendue il s’écoule quelques centaines de millisecondes. En cas de trafic élevé, c’est déjà énorme et les données sont peut être obsolètes avant même d’avoir été affichées. Restez dans le réaliste et vous constaterez que ce problème de temps réel est le plus souvent surestimé.

 

  • La première question est donc de savoir à quelle fréquence mes données varient dans le temps.

Un exemple intéressant c’est le stock produits sur un site e-commerce.

Si les produits sont nombreux on peut penser qu’il est improbable que le stock de l’ensemble du catalogue chute rapidement. Il n’est peut être pas si dramatique d’annuler une ou deux commandes par jour.

Si les produits sont peu nombreux c’est quoi votre trafic? La billetterie du stade de France quand Johnny fait son show ou bien mon blog?

Probablement êtes vous dans une situation intermédiaire: un peu de variation, mais pas trop. Donc une fenêtre de divergence assez réduite si la synchronisation est régulière.

 

Par expérience ce cas sont de loin le plus fréquent. Dans la vie de tous les jours on a de toute façon une décohérence. Le vrai débat est alors de savoir jusqu’où on peut l’ignorer ou la gérer. Ce qui est notre deuxième point.

  • La deuxième question est de savoir si j’ai vraiment besoin d’afficher une valeur exacte à chaque instant.

Gardons notre exemple du stock produit. Entre le moment où le client pousse un produit dans le panier et celui où il passe sa commande il peut s’écouler des minutes voire des semaines.

Alors sur le fond à quel moment a t’il besoin d’être à jour?

Au plus tard sur le dernier écran du tunnel de commande, voire le jour de l’expédition si vous acceptez l’idée d’annuler un nombre limité de commande de temps en temps. Dans tous les cas, pas sur les pages de navigation qui sont celles qui supportent le gros du trafic.

 

Comme on vient de le voir les cas où une cohérence forte est nécessaire sont assez limités. Il y a fort à parier que votre appli, en tout cas une partie, ne soit pas concernée.

 

Il arrive que la synchronisation est coûteuse (des millions de références) et donc très longue, peut être des heures. Rien ne vous interdit de monter une stratégie de rafraichissement partiel par exemple limitée à certaines données phares. Une synchronisation plus complète se faisant moins fréquemment.

Approche rationnelle

Le problème est très ancien, il a été constaté dès les années 70 lorsqu’il s’est agit d’assurer la cohérence des premières bases de données.

CQRS est un pattern qui préconise la cohérence à terme (eventually consistent). C’est à dire que l’on garantit que les données finiront par être cohérentes si aucune mise à jour n’est effectuée. Ce modèle est lui-même très fréquent. le cas le plus notoire est le système DNS.

 

Comme vous le voyez l’abandon de la cohérence forte par CQRS n’est pas un échec de votre implémentation. Ce n’est non plus pas une conséquence ésotérique. C’est une propriété de la plupart des systèmes informatiques distribués (y compris critiques) qui réclament de la haute dispo. Aucune raison donc de s’en inquiéter en soit. Votre travail est d’analyser le problème et le gérer.

Je peux suggérer la lecture de cet article qui fait un bon point sur la question:

http://decrypt.ysance.com/2011/04/finalement-coherent-revisite/

Comment synchroniser?

Evidemment la réponse dépend totalement de la technologie utilisée par votre repositorie. Par exemple on pourrait envisager du Always On ou du Log Shipping entre bases SQL Server. Il est donc difficile de proposer un panorama complet.

L’application de démo (EscarGoCQRS) installe un webjob qui fera le travail toutes les 2 minutes. Mais d’autres solutions peuvent s’envisager comme les Azure Batch, les rôles, monter un service de fond sur une VM…

Concrètement

Inventaire de la situation

Pour clarifier faisons l’inventaire de ce qui est lu et ce qui est écrit dans EscarGo!

Lecture

  • Affichage de la liste des courses avec les likes
  • Détail de chaque course avec les côtes
  • Calcul de la cote pour chaque participant
  • Liste des concurrents avec leur entraîneur, le nombre de victoires et de défaites
  • Détail de chaque concurrent avec leur côte dans chaque course où ils participent
  • Liste des courses auxquelles participe chaque concurrent avec leur cote

Ecriture

  • Enregistrement des paris qui entraîne le re-calcul des côtes
  • Enregistrement des likes
  • Créer une nouvelle course
  • Vente d’une place

Les choix

La création d’une nouvelle course est un événement plutôt rare. On ne va donc pas s’en préoccuper et laisser son architecture CRUD.

Autant on peut discuter d’un différé sur les likes, mettre en différé les cotes des concurrents est plus difficile à justifier. CQRS n’a de sens que dans le contexte de la scalabilité, ce n’est pas un pattern de haute dispo.

Dans le contexte d’une démo je vais tout de même négliger ce point, la cote et les likes apparaîtrons dans le storage. Une appli de type e-commerce aurait peut être été un meilleur choix…

Le nombre de places peut lui varier rapidement d’autant plus que le nombre de courses est assez faible. Mais le système prévoit d’envoyer une confirmation par e-mail. Je fais peu de requêtes sur la table des tickets, plutôt des commandes. Pas trop à gagner à mettre la liste des tickets dans le storage.

 

Mon choix technique sera de garder la base SQL pour les commandes. c’est le choix le plus performant.

Pour les requêtes je vais dénormaliser les tables et pousser les données dans une Azure TABLE.

Avantages

  • C’est très simple à mettre en œuvre
  • TABLE est un service peu coûteux
  • TABLE est un service performant qui propose nativement de la haute dispo

Inconvénients

  • Faire des requêtes sur autre chose que les rowkey et partitionkey est difficile, les performances sont moyennes
    Mais je n’ai pas de filtrage donc cela ne me gêne pas trop
  • J’ai du abandonner la pagination, pas réussi à trouver comment faire sur des TABLE.

Bien entendu si ces contraintes ne vous conviennent pas libre à vous d’utiliser d’autres solutions. Azure propose deux autres services NoSQL : DocumentDB et Azure Search. Vous pouvez également monter des VM avec ce que vous souhaitez dessus.

 

OK des TABLES, mais pour y mettre quoi?

Je vais monter le contenu des pages de détail course et concurrent. Chacune de ces deux pages exposent également un tableau, par exemple:

2016-08-17_00-00-40

 

Côté Azure on se retrouve donc avec deux TABLE:

  1. Competitors
  2. Races

Races ressemble à ceci:

2016-08-17_00-03-42

  • PartitionKey  contient l’id de la course
  • RowKey contient l’id d’un des concurrents de la course.

La table Competitors est montée sur le même modèle:

2016-08-17_00-10-10

 

 

La synchronisation est assurée par un web job. C’est le choix le plus simple pour une application Web App.

Application de démo

Il s’agit bien entendu de EscargoCQRS que je construis à partir de EscarGoAsync.

Le repositorie évolue évidemment. Pas en écriture, mais en lecture dans les méthodes appelées lors de l’affichage des pages de détail.

Je ne vais pas détailler lourdement le code, il ne me semble pas très complexe à reconstituer et vous pouvez suivre le détail dans le source directement.

Les choses se trouvent ici:

2016-08-17_00-13-54

J’ai profité de l’occasion pour implémenter un pattern Unit Of Work. Pas tant qu’il soit indispensable, mais je voulais me faire la main dessus!

Regardons tout de même la méthode GetRaces() afin de souligner un point d’attention:

public List<Course> GetRaces()
{
   List<Course> concurrents = new List<Course>();
 
   TableQuery<RaceEntity> query = new TableQuery<RaceEntity>();
 
   // attention, il y a une limite à 1000 lignes par requêtes
   var result = _raceTable.ExecuteQuery(query);
   foreach (RaceEntity nosql in result)
   {
      if (nosql.Date < DateTime.Now)
      {
         continue;
      }
 
      Course course = nosql.ToCourse();
      concurrents.Add(course);
   }
   return concurrents
   .OrderBy(c => c.Label).ToList();
}

Tout est dans le commentaire. Les requêtes TABLE remontent les enregistrement par blocs de 1000. Normalement il faudrait donc écrire ce code autrement, ce que je n’ai pas fait car le cas ne se présente pas dans mon appli.

 

Ce n’est pas tout, on a besoin d’alimenter les deux TABLE à intervalles réguliers. Puisque je déploie les sites en Web App, le plus simple c’est bien entendu un web job.

Son repository est ici:

2016-08-17_00-22-47

Peu de choses à dire côté code, le mieux est d’aller lire ce qui se trouve dans TableStorageRepository.

Le web job est déclenché automatiquement toutes les deux minutes. Son code est dans le projet CQRSJobs.

Autres exemples

J’ai trouvé ceci:

http://lokad.github.io/lokad-cqrs/

Je n’ai pas eu le temps de regarder dans le détail, mais il s’agit semble t’il d’un exemple plus complet que le mien d’architecture CQRS et en tout cas réutilisable. Je me contente du service minimum pour expliquer comment ça marche.

 

Vous noterez qu’il traite également du pattern Event Sourcing que je n’aborde pas ici.

Je n’aime pas trop mélanger ces deux patterns dans un même article. Evidemment ES et CQRS fonctionnent très bien ensembles, mais ce sont surtout deux patterns différents et au cours de mes lecture j’ai pu constater que ce point échappe à pas mal de monde.

Je préfère donc ne pas entretenir la confusion, peut être qu’un jour je ferai un article dédié à Event Sourcing.

Si le sujet vous intéresse voici un autre Framework ES:

https://github.com/OrleansContrib/orleans.eventsourcing

https://channel9.msdn.com/Events/Build/2014/3-641

 

Bibliographie

 

Publicités

Laisser un commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l'aide de votre compte WordPress.com. Déconnexion / Changer )

Image Twitter

Vous commentez à l'aide de votre compte Twitter. Déconnexion / Changer )

Photo Facebook

Vous commentez à l'aide de votre compte Facebook. Déconnexion / Changer )

Photo Google+

Vous commentez à l'aide de votre compte Google+. Déconnexion / Changer )

Connexion à %s