Le pattern CQRS : de la performance sans micro-optimisations

Célia Doolaeghe
Aug 26 · 8 min read

Il y a quelques mois encore, je travaillais sur une application interne qui consommait les API d’une autre équipe, dont les performances variaient de 600 ms à… 6 secondes. Oui, c’est très long. Malheureusement, les contraintes techniques de cette équipe, les priorités et le public interne de notre application n’ont pas permis de se pencher sur la possibilité d’optimiser ces API. A la base, ce n’est pas non plus un sujet qui m’intéressait particulièrement.

Depuis, j’ai commencé une nouvelle mission chez Adeo, dans le projet Opus. J’ai rejoint une équipe qui cette fois développe les API pour un front e-commerce. Et j’ai découvert les performances de ces API : sur le dernier mois d’utilisation, nos API en lecture répondent en moyenne en 18 ms. En écriture, la moyenne est de 300 ms. Impressionnant, non ? Pour moi, ça a clairement été un choc.

Dans mon esprit, de telles performances étaient sûrement liées à un code ultra-optimisé, illisible et pénible à écrire. Mais en fait pas du tout, ils ont simplement mis en place des patterns et une architecture qui leur permet de répondre à leurs besoins de performance. C’est ainsi que j’ai découvert le pattern CQRS (Command Query Responsibility Segregation), et l’architecture qui permet d’exploiter ce pattern.

Je vais vous détailler tout cela, en prenant comme exemple un service de bibliothèque (classique, je sais). Je tiens à préciser que je ne développe ici qu’un exemple de ce qu’il est possible de faire avec ce pattern, les possibilités d’adaptation sont très nombreuses.

Le pattern CQRS : qu’est-ce que c’est ?

Dans un service de type CRUD (Create Read Update Delete) classique, nous allons avoir en points d’entrée toutes les API nécessaires à la gestion de nos entités, ici des livres. Pour gérer notre bibliothèque, nous avons par exemple :

Image for post
Image for post

Derrière ces points d’entrée, nous avons les services et méthodes d’accès à la base de données de notre choix, pour sauvegarder les livres.

Le pattern CQRS (Command Query Responsibility Segregation) préconise simplement de séparer les fonctions de mises à jour des données de celles qui ne font que de la lecture. D’un côté, nous avons les fonctions dites de command, c’est-à-dire celles qui modifient les objets, et de l’autre nous avons les fonctions query, qui ne font que retourner une valeur.

Si nous l’appliquons à notre bibliothèque précédente, nous obtenons donc deux services distincts, avec leurs points d’entrée :

Image for post
Image for post

Évidemment, il faut appliquer cette séparation tout le long de la chaîne, du point d’entrée de l’API à l’accès à la base de données. Nous avons donc deux services distincts, dont le seul point commun est d’utiliser la même base de données.

Quel intérêt ? Au final, ce n’est pas tellement d’un point de vue du code, mais plutôt de l’architecture autour de ce pattern que se tirent les bénéfices en termes de performances.

L’architecture autour du pattern

Maintenant que nous avons séparé nos points d’entrée en deux sous-applications distinctes, Command et Query, nous pouvons imaginer déployer notre application en deux services indépendants, qui ne communiquent pas entre eux mais utilisent toujours la même base de données, ce qui donnerait l’architecture suivante :

Image for post
Image for post

Et maintenant ? Nous avons deux services scalables indépendamment. Il n’est pas rare que les besoins de performances en lecture et en écriture divergent, avec la nécessité de répondre très rapidement en lecture beaucoup plus qu’en écriture. C’est dans ce genre de cas que ce pattern est intéressant : les traitements en écriture du service Command n’ont pas d’impact sur les performances de la lecture. Et si un pic de consommation se fait sentir, le service Query peut être répliqué pour répondre à ce besoin, peut-être même automatiquement selon la solution d’hébergement (par exemple dans le cloud). On peut imaginer une grande disparité entre lecture et écriture, avec une scalabilité adaptée au besoin.

Pour aller encore plus loin : préparer la lecture

Nous avons utilisé le pattern CQRS pour découper notre code, puis nous l’exploitons au mieux à l’aide d’une architecture adaptée. Mais est-ce suffisant pour obtenir des performances aussi impressionnantes ? Il reste un élément du schéma dont nous n’avons pas encore parlé : la base de données.

A la base, le pattern préconise deux bases de données séparées pour Command et Query, pour que les requêtes d’écriture n’aient pas d’impact sur les requêtes de lecture. Ici, je choisis de garder une seule base de données, par simplification.

Imaginons un nouveau cas dans notre bibliothèque : la possibilité de récupérer une liste de livres recommandés à partir d’un livre en paramètre. Typiquement, si ce livre m’a plu, quelles sont les lectures similaires ? Il s’agit de retourner une valeur sans mise à jour, ce nouveau point d’entrée trouve donc sa place dans notre Query :

Image for post
Image for post

Mais, si nous décidons de calculer les recommandations au moment de la lecture, cela prendrait un temps non négligeable, pour donner au final une API peu performante. Comment faire alors ?

Une solution qui est bien compatible avec le pattern CQRS est de préparer les réponses de la lecture en amont directement dans la base de données. Nous avons donc une nouvelle entité en base recommendations qui contient respectivement pour chaque livre toutes les recommandations associées. Le service Query va simplement renvoyer les recommandations du livre choisi, sans action supplémentaire. C’est le service Command qui va à la fois ajouter le nouveau livre et les recommandations dans les entités appropriées lors de la création d’un nouveau livre :

Image for post
Image for post

Les avantages de cette méthode sont d’une part d’avoir une lecture qui reste extrêmement rapide, puisqu’elle se contente de renvoyer les données en base, et d’autre part de garder toute l’intelligence de notre application dans un seul des deux services.

L’inconvénient, c’est qu’on multiplie les entités à modifier, et qu’on donne au service Command la responsabilité de garder les données cohérentes. Ici par exemple, il ne faut pas non plus oublier de mettre à jour les recommandations des anciens livres, car notre nouveau livre peut faire partie de leurs recommandations.

Dénormaliser les données peut permettre d’éviter des opérations de jointures parfois coûteuses : il vaut mieux dupliquer les données pour les renvoyer telles quelles que recalculer les associations à la volée. Prenons un exemple : un livre A a pour recommandations les livres B et C. Dans ce cas, le livre B a pour recommandations A et C, et C aura en recommandations A et B. Chaque recommandation existe plusieurs fois dans la base de données, plutôt que d’être recalculée par des liens réciproques. L’avantage, c’est que la donnée est prête à être consommée. L’inconvénient, c’est que si un livre change, il faut le mettre à jour dans toutes les recommandations qui le référencent.

Cela nécessite donc de bien maîtriser son modèle de données pour garder sa cohérence. Malgré cette complexité, c’est une solution très efficace pour avoir de super performances en lecture.

Et pour les traitements longs ?

Le risque, c’est qu’à force de complexifier l’écriture pour simplifier la lecture, nous pouvons nous retrouver avec des temps de réponse assez longs lors des écritures, ce qui n’est pas souhaitable. Dans notre exemple de bibliothèque, lorsque nous calculons les recommandations pour les stocker en base de données, il faut à la fois calculer les recommandations du nouveau livre, et l’ajouter en recommandation dans tous les livres auxquels il doit être associé. Ce traitement peut être assez long et coûteux. Alors, comment optimiser la partie Command ? Le pattern CQRS se combine également très bien avec des traitements non-bloquants.

Lors de l’ajout d’un nouveau livre, au lieu d’attendre la fin du calcul des recommandations pour répondre, nous envoyons immédiatement en réponse que nous avons bien pris en compte la création du nouveau livre. Dans notre service Command, en plus de l’ajout en base de données du nouveau livre, nous déclenchons le calcul des recommandations, sans attendre qu’il ait terminé. Celui-ci mettra lui-même à jour les recommandations. Nous aurons donc le fonctionnement suivant :

Pour ce genre de traitement asynchrone, nous pouvons par exemple imaginer une communication par messages pour déclencher le calcul des recommandations. L’avantage, c’est qu’il existe des mécanismes de resoumission pour un message qui n’aurait pas été traité correctement, selon la solution choisie. Il existe par ailleurs plusieurs solutions techniques pour gérer les erreurs dans les traitements asynchrones, à choisir selon sa préférence.

Les traitements non-bloquants se combinent bien avec CQRS, pour pouvoir garder de très bonnes performances ressenties en écriture également. Je précise ici que les API asynchrones ne sont pas spécifiques à CQRS et peuvent s’appliquer n’importe où. Elles permettent ici de rester efficaces en écriture là où le pattern avantageait surtout la lecture.

Conclusion

Le principal intérêt du pattern CQRS est donc de séparer la lecture et l’écriture pour pouvoir optimiser les deux séparément, grâce à l’architecture mise en place autour du pattern, la préparation par le service Command de ce qui sera renvoyé par le service Query, ou encore par la mise en place de traitements non-bloquants.

Il existe encore beaucoup d’autres techniques qui permettent d’exploiter au mieux le pattern CQRS. Je ne suis moi-même pas une experte, je l’ai découvert récemment lors de mon changement de mission, et j’ai été impressionnée par les résultats obtenus. Il a l’avantage de permettre des performances optimales en gardant un code élégant, sans micro-optimisations à tout bout de champs.

En revanche, je ne pense pas qu’il soit adapté à toutes les infrastructures : le pattern en lui-même ajoute déjà une certaine complexité en séparant complètement lecture et écriture, mais il nécessite aussi une architecture autour pour l’exploiter. Il peut également ajouter de la complexité au modèle de données. Je n’ai ici donné qu’un exemple plutôt simple, une introduction au pattern.

Maintenant, c’est à mon tour de participer activement au maintien de cette pratique, en veillant à ne pas dégrader nos performances.

Sources

Pour ceux qui souhaitent approfondir leurs connaissances sur le pattern CQRS:

CodeShake

Learnings and insights from SFEIR community.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store