Les applications multi-tiers organisées en couches indépendantes sont conçues selon les meilleurs principes architecturaux (découplage des composants, séparation des responsabilités). Cependant, on observe qu’une maturité architecturale poussée n’est pas un gage de réussite lorsqu’il s’agit de soutenir une charge d’utilisation importante et que les performances de telles applications sont souvent peu satisfaisantes dès que le nombre de requêtes à servir augmente.
Dans cet article, nous allons dans un premier temps définir de quoi nous parlons. Tout le monde n’a pas à être familier avec le concept d’architecture multi-tier ou avec celui de scalabilité horizontale. Nous espérons que vous le serez un peu plus après la lecture de cet article. Dans un second temps, nous allons explorer les causes probables de la sous-performance des applications web et les raisons pour lesquelles des applications architecturées différemment ont de meilleures performances.
L’omniprésente architecture multi-tier
Dans cet article, il ne sera quasiment pas question de technologies. Ou si peu. Alors autant évacuer le problème tout de suite. Les services en ligne et, par extension, les applications web reposent souvent sur une pile de technologies dont chaque niveau est le middleware d’un tier donné. Habituellement, on distingue 3 tiers : la présentation, le métier et la persistance. Ces tiers offrent des responsabilités génériques. Le tier de présentation expose à l’utilisateur une IHM dynamique et interprète les actions de l’utilisateur en termes d’événements métier. Le tier métier traduit ces événements en activités métier dont la conséquence directe sont des changements d’état sur les entités du métier. Enfin, le tier de persistance assure que ces changements d’états seront récupérables en cas d’arrêt du système.
Dans le cas des applications web, l’architecture et son socle technologique sont très matures. Le tier de présentation, le front-end est un serveur hébergeant un serveur web Apache qui exécute du PHP pour le rendu des pages web. Le tier de logique métier (le back-end) est aussi un serveur Apache comportant du code PHP (ou du Java, ou du Python) et le tier de persistance est une base de données MySQL. Ces trois tiers peuvent être déployés sur le même serveur sous Linux ou sur des machines différentes.
Chaque tier peut être lui-même architecturé en plusieurs couches, introduits lors de la conception pour réduire le couplage. La raison d’être de ce style d’architecture n’est pas la recherche de la performance mais plutôt le contrôle de l’architecture de l’application et le respect de certaines normes de conception.
Le fonctionnement d’une application web conçue selon ces principes est assez stéréotypé. Chaque action de l’utilisateur engendre une requête HTTP à destination du serveur. Le serveur traite cette requête de bout en bout depuis le tier de présentation jusqu’au tier de persistance en passant par le tier métier et retourne un résultat à l’utilisateur sous la forme d’une réponse HTTP. Ce circuit requête/traitement/réponse est souvent synchrone, c’est-à-dire que l’utilisateur attend que le résultat de son action parviennent jusqu’à lui sous la forme d’une mise à jour de l’IHM.
La scalabilité, horizontale ou verticale ?
Lorsque de nombreux utilisateurs interagissent avec l’application, le nombre de requêtes HTTP à traiter par le serveur peut devenir énorme, surtout dans le cas de services en ligne fort populaires. Pour les services en ligne professionnels comme les services B2B, le nombre de requêtes peut aussi devenir important du simple fait que les « utilisateurs » de ces services sont en fait des programmes externes qui consomment les services en tant qu’API Web. Pour assurer une bonne adéquation entre performances et charge (qui est la définition de la scalabilité), tout particulièrement en cas de pic de charge, il existe deux stratégies de scalabilité.
-
- La scalabilité verticale implique d’augmenter les ressources de calcul de chaque serveur composant l’infrastructure dans laquelle les composants des tiers applicatifs sont déployés. Cette stratégie se heurte à des limites physiques car les machines ne peuvent être améliorées que jusqu’à un certain point. Lorsque tous les serveurs de l’infrastructure d’une application ont été améliorés, les performances théoriques atteignent leur maximum et, toute augmentation supplémentaire de charge se traduira effectivement par une dégradation des performances.
- L’autre approche, la scalabilité horizontale, est de multiplier les nœuds de calcul au niveau le plus bas de l’infrastructure et de configurer les logiciels de middleware (Apache et MySQL par exemple) pour qu’ils s’exécutent sur de multiples hôtes redondants de manière à assurer une répartition optimale de la charge (le fameux load-balancing). Des serveurs supplémentaires agissant en tant que portail ou répartiteur vont acheminer les requêtes vers ces hôtes redondants pour aplanir les pics d’utilisation entre ces serveurs et ainsi conserver un niveau de performances correct. Dans les applications web classiques, ceci est souvent effectué au niveau du tier de présentation où des serveurs spéciaux appelés reverse proxies servent à mettre les requêtes en cache de manière à décharger les serveurs web de la tâche de traiter la même requêtes plusieurs fois. Cette approche est aussi utilisée pour les serveurs de base de données. Notre expérience a montré que les serveurs qui hébergent le tier de logique métier ne bénéficient pas autant de cette approche car leur responsabilité principale est de maintenir un état (par exemple, l’état d’une instance de processus métier en train d’être exécutée) et ces états, étant des ressources partagées sont, par leur nature même, difficiles à répliquer et à mettre à jour.
Répliquer et maintenir à jour des états partagés entre plusieurs serveurs est l’un des frein qui empêche la scalabilité horizontale de devenir une approche généralisée en architecture système sauf si l’architecture a été conçue dès le début avec cet objectif en tête.
Avec ou sans état ?
La spécification initiale du protocole HTTP nécessitait que le serveur soit sans état, ce qui signifie qu’aucune information provenant du côté client ne devait être stockée par ce serveur entre deux requêtes provenant du même client. Des choses assez communes comme l’authentification ou les variables de session ne sont pas compatibles avec le protocole HTTP. Les concepteurs d’applications et les développeurs de serveurs HTTP ont depuis longtemps jeté aux orties ces aspects du protocole. Le résultat est que les implémentations de serveurs HTTP sont maintenant encrassées d’éléments dont il faut maintenir l’état, ce qui demande des ressources en mémoire et en calcul qui sont difficiles à gérer en cas de pic d’utilisation du serveur.
D’un autre côté, les services web conçus selon les recommandations du style d’architecture REST doivent être sans état et requièrent de leurs clients qu’ils fournissent le contexte intégral dans lequel une requête doit être traitée. Ceci réduit le besoin de mettre en cache et de maintenir l’état de données partagées. Des reverse-proxies et des serveurs de contenu peuvent toujours être intégrés dans de telles architectures mais, comme leur rôle est désormais bien défini dans le protocole HTTP et qu’ils doivent relayer les requêtes de façon transparente, leur influence sur la conception d’applications web est minime.
[A]?synchrone
Les requêtes envoyées vers un serveur sont souvent synchrones, c’est-à-dire que l’émetteur de la requête (le client) attend que le récepteur (le serveur) réponde avant de continuer sa propre exécution. Ce comportement est bien accepté par les utilisateurs humains derrière leur navigateur web (sauf, bien sûr, s’ils ont à attendre trop longtemps) car les humains ont une tendance naturelle à faire les choses de manière asynchrone : tandis qu’ils attendent qu’une page se mette à jour, il surfe sur une autre !
Les requêtes synchrones échangées entre deux serveurs sont responsables de goulots d’étranglement si les programmes applicatifs ne sont pas conçus avec un bon degré de parallélisme en utilisant des aspects multi-processus et/ou multi-threads. Même avec ces aspects mis en œuvre, le remède est souvent pire que le mal parce que synchroniser des processus et des threads est un problème bien connu surtout si des accès à des états partagés sont concernés.
Dans ces conditions, utiliser un modèle de requêtes asynchrones pour gérer la communication entre serveurs est souvent une bonne idée pour permettre la scalabilité horizontale de ces serveurs. Conserver un mode synchrone pour les interactions des utilisateurs et utiliser des callbacks et AJAX pour le comportement non-interactif du tier de présentation est un bon début. Envoyer des messages asynchrones en utilisant des motifs de conception issus du monde de l’EAI (Entreprise Application Integration) comme Hub, Publish/Subscribe ou Producer/Consumer peut aussi faciliter la mise en place d’une architecture horizontalement scalable pour le tier métier au lieu de se reposer uniquement sur un modèle Remote Procedure Call pour tout et n’importe quoi.
Disponible XOR cohérent ?
Dans un environnement distribué dans lequel évoluent des objets de données modifiables (mutable data objects) comme les bases de données répliquées par exemple, trois aspects sont en jeu : la disponibilité d’un élément de données, sa cohérence et la bonne santé générale du réseau.
La mauvaise nouvelle c’est que seuls deux de ces aspects peuvent être contrôlés à la fois. Ce qui ne laisse qu’un compromis entre disponibilité et cohérence car seuls les fous pourraient vouloir d’un réseau non connecté et partitionné. Pour une base de données relationnelle isolée, la cohérence est quelque chose de sérieux. Mais on ne peut avoir une cohérence forte qu’à la condition de ne pas avoir une excellente disponibilité car, pour s’assurer qu’une ligne dans une table est correctement mise à jour en respectant l’ordre des transactions, un verrou est posé sur cette ligne empêchant toute écriture. Dans ces conditions, du temps d’attente supplémentaire intervient dans le cycle requête/traitement/réponse, temps que l’utilisateur finira pas « payer » en attendant au final devant son écran que le verrou soit relâché.
Des architectures pour la performance
Nous avons vu que dans les architectures multi-tier, chaque tier, même impeccablement conçu, est gangréné par des freins à la performance qui empêchent l’application dans son ensemble d’être horizontalement scalable. Cependant, ces freins ne sont pas apparus par hasard, ils sont le résultat de décisions de conception et d’architecture et, chacun sait que changer la conception et l’architecture d’une application est un sujet à ne pas prendre à la légère car de tels changements modifient en profondeur la nature même de l’application (sans compter les cris d’architectes dogmatiques).
Suivre les indications qui précèdent et les appliquer conduisent à des applications légères, agiles et performantes tout en conservant des technologies matures (mais différentes) et en satisfaisant les utilisateurs et les parties prenantes du projet. Appliquer ces conseils à des applications légataires est plus délicat mais reste possible en adoptant une approche itérative et ascendante particulièrement lorsqu’il s’agit d’applications multi-tier bien conçues et que les principes de couplage faible et de séparation des responsabilités sont appliqués correctement.
Commencez par remplacer l’omniprésente base de données relationnelle par quelque chose de plus scalable et de moins éloigné du monde de la programmation orientée-objet. Si le tier métier possède un DAL (Data Access Layer) qui propose de bonnes abstractions, cela rend les choses plus faciles. Si, au contraire, vous utilisez un framework ORM (Object Relational Mapper) sorti de sa boîte, le refactoring sera bien plus compliqué. Choisissez une technologie de persistance qui favorise le MVCC (Multi Version Concurrency Control) par rapport à des stratégies de verrouillage. Si vos objets métier ne changent pas d’état et n’ont pas besoin d’être mis à jour, c’est encore mieux !
Travaillez sur le tier de présentation pour remplacer les encombrants serveurs HTTP par des serveurs agiles bâtis autour du pattern Reactor pour assurer le traitement asynchrones des requêtes. Offrez aux clients de votre tier de présentation des services possédant de bonnes abstractions en utilisant le style REST.
N’hésitez pas à mettre en place des reverse-proxies si vous avez une bonne compréhension du cycle de vie de vos ressources : quand peuvent-elles être mises en cache, quand peuvent-elles être marquées comme dépassées, etc.
Changez l’architecture du tier métier en y incluant là aussi des webservices REST. Là plus qu’ailleurs, la communications entre composants fonctionnels peut être largement améliorée en utilisation des frameworks asynchrones.
L’idée globale est de fragmenter l’architecture monolithique en composants atomiques (services, points d’entrée, silos de données) de façon à ce que le système dans son ensemble puisse être distribué et puisse par conséquent supporter la scalabilité horizontale. N’ayant aucun état partagé, aucune ressource verrouillée, une telle conception peut néanmoins bénéficier d’un certain degré de parallélisme apporté par des threads multiples mais ces threads sont désormais restreints à certains composants spécifiques au lieu d’être généralisés. Ce qui assure vraiment le parallélisme des entrées/sorties c’est l’aspect asynchrone de leur traitement plutôt que l’emploi des threads et des inconvénients qu’ils suggèrent (deadlocks et race conditions)