Comment améliorer les performances des applications ASP.NET dans une batterie de serveurs Web avec mise en cache
Publié: 2022-03-11Il n'y a que deux choses difficiles en informatique : l'invalidation du cache et le nommage des choses.
- Auteur : Phil Karlton
Une brève introduction à la mise en cache
La mise en cache est une technique puissante pour augmenter les performances grâce à une astuce simple : au lieu de faire un travail coûteux (comme un calcul compliqué ou une requête de base de données complexe) chaque fois que nous avons besoin d'un résultat, le système peut stocker - ou mettre en cache - le résultat de ce travail et simplement fournissez-le la prochaine fois qu'il est demandé sans avoir besoin de refaire ce travail (et peut, par conséquent, répondre beaucoup plus rapidement).
Bien sûr, toute l'idée derrière la mise en cache ne fonctionne que tant que le résultat que nous avons mis en cache reste valide. Et ici, nous arrivons à la partie la plus difficile du problème : comment déterminer quand un élément mis en cache est devenu invalide et doit être recréé ?
et parfait pour résoudre le problème de mise en cache de la ferme Web distribuée.
Habituellement, une application Web typique doit traiter un volume beaucoup plus élevé de demandes de lecture que de demandes d'écriture. C'est pourquoi une application Web typique conçue pour gérer une charge élevée est conçue pour être évolutive et distribuée, déployée sous la forme d'un ensemble de nœuds de niveau Web, généralement appelé batterie de serveurs. Tous ces faits ont un impact sur l'applicabilité de la mise en cache.
Dans cet article, nous nous concentrons sur le rôle que la mise en cache peut jouer pour assurer un débit et des performances élevés des applications Web conçues pour gérer une charge élevée, et je vais utiliser l'expérience de l'un de mes projets et fournir une solution basée sur ASP.NET pour illustrer.
Le problème de la manutention d'une charge élevée
Le problème réel que j'ai dû résoudre n'était pas original. Ma tâche était de faire en sorte qu'un prototype d'application Web monolithique ASP.NET MVC soit capable de gérer une charge élevée.
Les étapes nécessaires pour améliorer les capacités de débit d'une application Web monolithique sont :
- Permettez-lui d'exécuter plusieurs copies de l'application Web en parallèle, derrière un équilibreur de charge, et de répondre efficacement à toutes les demandes simultanées (c'est-à-dire, rendez-le évolutif).
- Profilez l'application pour révéler les goulots d'étranglement actuels des performances et les optimiser.
- Utilisez la mise en cache pour augmenter le débit des requêtes de lecture, car cela constitue généralement une part importante de la charge globale des applications.
Les stratégies de mise en cache impliquent souvent l'utilisation d'un serveur de mise en cache middleware, comme Memcached ou Redis, pour stocker les valeurs mises en cache. Malgré leur adoption élevée et leur applicabilité éprouvée, ces approches présentent certains inconvénients, notamment :
- Les latences réseau introduites par l'accès aux serveurs de cache distincts peuvent être comparables aux latences d'accès à la base de données elle-même.
- Les structures de données du niveau Web peuvent ne pas convenir à la sérialisation et à la désérialisation prêtes à l'emploi. Pour utiliser des serveurs de cache, ces structures de données doivent prendre en charge la sérialisation et la désérialisation, ce qui nécessite un effort de développement supplémentaire continu.
- La sérialisation et la désérialisation ajoutent une surcharge d'exécution avec un effet négatif sur les performances.
Toutes ces questions étaient pertinentes dans mon cas, j'ai donc dû explorer des options alternatives.
Le cache en mémoire ASP.NET intégré ( System.Web.Caching.Cache
) est extrêmement rapide et peut être utilisé sans surcharge de sérialisation et de désérialisation, à la fois pendant le développement et l'exécution. Cependant, le cache en mémoire ASP.NET a également ses propres inconvénients :
- Chaque nœud de niveau Web a besoin de sa propre copie des valeurs mises en cache. Cela peut entraîner une consommation plus élevée au niveau de la base de données lors du démarrage à froid ou du recyclage du nœud.
- Chaque nœud de niveau Web doit être averti lorsqu'un autre nœud rend une partie du cache invalide en écrivant des valeurs mises à jour. Étant donné que le cache est distribué et sans synchronisation appropriée, la plupart des nœuds renverront d'anciennes valeurs, ce qui est généralement inacceptable.
Si la charge supplémentaire au niveau de la base de données n'entraîne pas à elle seule un goulot d'étranglement, la mise en œuvre d'un cache correctement distribué semble être une tâche facile à gérer, n'est-ce pas ? Eh bien, ce n'est pas une tâche facile , mais c'est possible . Dans mon cas, les benchmarks ont montré que le niveau base de données ne devrait pas poser de problème, car la majeure partie du travail s'est déroulée dans le niveau Web. J'ai donc décidé d'utiliser le cache en mémoire ASP.NET et de me concentrer sur la mise en œuvre de la synchronisation appropriée.
Présentation d'une solution basée sur ASP.NET
Comme expliqué, ma solution consistait à utiliser le cache en mémoire ASP.NET au lieu du serveur de mise en cache dédié. Cela implique que chaque nœud de la ferme Web ait son propre cache, interroge directement la base de données, effectue tous les calculs nécessaires et stocke les résultats dans un cache. De cette façon, toutes les opérations de cache seront extrêmement rapides grâce à la nature en mémoire du cache. En règle générale, les éléments mis en cache ont une durée de vie claire et deviennent obsolètes lors de modifications ou de l'écriture de nouvelles données. Ainsi, à partir de la logique de l'application Web, il est généralement clair quand l'élément de cache doit être invalidé.
Le seul problème qui reste ici est que lorsque l'un des nœuds invalide un élément de cache dans son propre cache, aucun autre nœud ne sera au courant de cette mise à jour. Ainsi, les requêtes ultérieures traitées par d'autres nœuds fourniront des résultats obsolètes. Pour résoudre ce problème, chaque nœud doit partager ses invalidations de cache avec les autres nœuds. Lors de la réception d'une telle invalidation, les autres nœuds pourraient simplement supprimer leur valeur mise en cache et en obtenir une nouvelle à la prochaine requête.
Ici, Redis peut entrer en jeu. La puissance de Redis, par rapport à d'autres solutions, vient de ses capacités Pub/Sub. Chaque client d'un serveur Redis peut créer un canal et y publier des données. Tout autre client est capable d'écouter ce canal et de recevoir les données associées, très similaire à n'importe quel système piloté par les événements. Cette fonctionnalité peut être utilisée pour échanger des messages d'invalidation de cache entre les nœuds, afin que tous les nœuds puissent invalider leur cache lorsque cela est nécessaire.
Le cache en mémoire d'ASP.NET est simple à certains égards et complexe à d'autres. En particulier, il est simple dans la mesure où il fonctionne comme une carte de paires clé/valeur, mais il y a beaucoup de complexité liée à ses stratégies d'invalidation et à ses dépendances.
Heureusement, les cas d'utilisation typiques sont assez simples et il est possible d'utiliser une stratégie d'invalidation par défaut pour tous les éléments, permettant à chaque élément du cache d'avoir au maximum une seule dépendance. Dans mon cas, j'ai terminé avec le code ASP.NET suivant pour l'interface du service de mise en cache. (Notez qu'il ne s'agit pas du code réel, car j'ai omis certains détails par souci de simplicité et de licence propriétaire.)
public interface ICacheKey { string Value { get; } } public interface IDataCacheKey : ICacheKey { } public interface ITouchableCacheKey : ICacheKey { } public interface ICacheService { int ItemsCount { get; } T Get<T>(IDataCacheKey key, Func<T> valueGetter); T Get<T>(IDataCacheKey key, Func<T> valueGetter, ICacheKey dependencyKey); }
Ici, le service de cache permet essentiellement deux choses. Tout d'abord, il permet de stocker le résultat d'une fonction getter de valeur d'une manière thread-safe. Deuxièmement, il garantit que la valeur alors en cours est toujours renvoyée lorsqu'elle est demandée. Une fois que l'élément de cache devient obsolète ou est explicitement évincé du cache, le getter de valeur est appelé à nouveau pour récupérer une valeur actuelle. La clé de cache a été extraite par l'interface ICacheKey
, principalement pour éviter le codage en dur des chaînes de clé de cache dans toute l'application.
Pour invalider les éléments du cache, j'ai introduit un service séparé, qui ressemblait à ceci :

public interface ICacheInvalidator { bool IsSessionOpen { get; } void OpenSession(); void CloseSession(); void Drop(IDataCacheKey key); void Touch(ITouchableCacheKey key); void Purge(); }
Outre les méthodes de base consistant à déposer des éléments avec des données et à toucher des touches, qui n'avaient que des éléments de données dépendants, il existe quelques méthodes liées à une sorte de "session".
Notre application Web a utilisé Autofac pour l'injection de dépendances, qui est une implémentation du modèle de conception d'inversion de contrôle (IoC) pour la gestion des dépendances. Cette fonctionnalité permet aux développeurs de créer leurs classes sans avoir à se soucier des dépendances, car le conteneur IoC gère cette charge pour eux.
Le service de cache et l'invalidateur de cache ont des cycles de vie radicalement différents concernant IoC. Le service de cache était enregistré en tant que singleton (une instance, partagée entre tous les clients), tandis que l'invalidateur de cache était enregistré en tant qu'instance par requête (une instance distincte était créée pour chaque requête entrante). Pourquoi?
La réponse a à voir avec une subtilité supplémentaire que nous devions gérer. L'application Web utilise une architecture Model-View-Controller (MVC), qui aide principalement à séparer les problèmes d'interface utilisateur et de logique. Ainsi, une action de contrôleur typique est encapsulée dans une sous-classe d'un ActionFilterAttribute
. Dans le framework ASP.NET MVC, ces attributs C# sont utilisés pour décorer la logique d'action du contrôleur d'une manière ou d'une autre. Cet attribut particulier était responsable de l'ouverture d'une nouvelle connexion à la base de données et du démarrage d'une transaction au début de l'action. De plus, à la fin de l'action, la sous-classe d'attribut de filtre était responsable de la validation de la transaction en cas de succès et de son annulation en cas d'échec.
Si l'invalidation du cache se produisait en plein milieu de la transaction, il pourrait y avoir une condition de concurrence dans laquelle la prochaine requête adressée à ce nœud remettrait avec succès l'ancienne valeur (toujours visible pour les autres transactions) dans le cache. Pour éviter cela, toutes les invalidations sont reportées jusqu'à ce que la transaction soit validée. Après cela, les éléments du cache peuvent être supprimés en toute sécurité et, en cas d'échec d'une transaction, aucune modification du cache n'est nécessaire.
C'était exactement le but des parties liées à la "session" dans l'invalidateur de cache. C'est aussi le but de sa durée de vie liée à la demande. Le code ASP.NET ressemblait à ceci :
class HybridCacheInvalidator : ICacheInvalidator { ... public void Drop(IDataCacheKey key) { if (key == null) throw new ArgumentNullException("key"); if (!IsSessionOpen) throw new InvalidOperationException("Session must be opened first."); _postponedRedisMessages.Add(new Tuple<string, string>("drop", key.Value)); } ... public void CloseSession() { if (!IsSessionOpen) return; _postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2)); _postponedRedisMessages = null; } ... }
La méthode PublishRedisMessageSafe
ici est responsable de l'envoi du message (deuxième argument) à un canal particulier (premier argument). En fait, il existe des canaux séparés pour déposer et toucher, de sorte que le gestionnaire de messages pour chacun d'eux savait exactement quoi faire - déposer/toucher la touche égale à la charge utile du message reçu.
Une des parties délicates était de bien gérer la connexion au serveur Redis. En cas de panne du serveur pour une raison quelconque, l'application devrait continuer à fonctionner correctement. Lorsque Redis est de nouveau en ligne, l'application doit recommencer à l'utiliser de manière transparente et échanger à nouveau des messages avec d'autres nœuds. Pour ce faire, j'ai utilisé la bibliothèque StackExchange.Redis et la logique de gestion des connexions résultante a été implémentée comme suit :
class HybridCacheService : ... { ... public void Initialize() { try { Multiplexer = ConnectionMultiplexer.Connect(_configService.Caching.BackendServerAddress); ... Multiplexer.ConnectionFailed += (sender, args) => UpdateConnectedState(); Multiplexer.ConnectionRestored += (sender, args) => UpdateConnectedState(); ... } catch (Exception ex) { ... } } private void UpdateConnectedState() { if (Multiplexer.IsConnected && _currentCacheService is NoCacheServiceStub) { _inProcCacheInvalidator.Purge(); _currentCacheService = _inProcCacheService; _logger.Debug("Connection to remote Redis server restored, switched to in-proc mode."); } else if (!Multiplexer.IsConnected && _currentCacheService is InProcCacheService) { _currentCacheService = _noCacheStub; _logger.Debug("Connection to remote Redis server lost, switched to no-cache mode."); } } }
Ici, ConnectionMultiplexer
est un type de la bibliothèque StackExchange.Redis, qui est responsable du travail transparent avec Redis sous-jacent. La partie importante ici est que, lorsqu'un nœud particulier perd la connexion à Redis, il retombe en mode sans cache pour s'assurer qu'aucune demande ne recevra de données obsolètes. Une fois la connexion restaurée, le nœud recommence à utiliser le cache en mémoire.
Voici des exemples d'action sans utilisation du service de cache ( SomeActionWithoutCaching
) et une opération identique qui l'utilise ( SomeActionUsingCache
) :
class SomeController : Controller { public ISomeService SomeService { get; set; } public ICacheService CacheService { get; set; } ... public ActionResult SomeActionWithoutCaching() { return View( SomeService.GetModelData() ); } ... public ActionResult SomeActionUsingCache() { return View( CacheService.Get( /* Cache key creation omitted */, () => SomeService.GetModelData() ); ); } }
Un extrait de code d'une implémentation d' ISomeService
pourrait ressembler à ceci :
class DefaultSomeService : ISomeService { public ICacheInvalidator _cacheInvalidator; ... public SomeModel GetModelData() { return /* Do something to get model data. */; } ... public void SetModelData(SomeModel model) { /* Do something to set model data. */ _cacheInvalidator.Drop(/* Cache key creation omitted */); } }
Analyse comparative et résultats
Une fois le code ASP.NET de mise en cache défini, il était temps de l'utiliser dans la logique d'application Web existante, et l'analyse comparative peut être utile pour décider où mettre le plus d'efforts pour réécrire le code pour utiliser la mise en cache. Il est crucial de sélectionner quelques cas d'utilisation les plus courants ou les plus critiques sur le plan opérationnel à comparer. Après cela, un outil comme Apache jMeter pourrait être utilisé pour deux choses :
- Pour comparer ces cas d'utilisation clés via des requêtes HTTP.
- Pour simuler une charge élevée pour le nœud Web testé.
Pour obtenir un profil de performances, tout profileur capable de s'attacher au processus de travail IIS peut être utilisé. Dans mon cas, j'ai utilisé JetBrains dotTrace Performance. Après un certain temps passé à expérimenter pour déterminer les paramètres jMeter corrects (tels que le nombre de demandes simultanées et de requêtes), il devient possible de commencer à collecter des instantanés de performances, qui sont très utiles pour identifier les points chauds et les goulots d'étranglement.
Dans mon cas, certains cas d'utilisation ont montré qu'environ 15 % à 45 % du temps d'exécution global du code étaient consacrés aux lectures de la base de données avec les goulots d'étranglement évidents. Après avoir appliqué la mise en cache, les performances ont presque doublé (c'est-à-dire qu'elles étaient deux fois plus rapides) pour la plupart d'entre eux.
Conclusion
Comme vous pouvez le voir, mon cas pourrait sembler être un exemple de ce qu'on appelle habituellement « réinventer la roue » : pourquoi s'embêter à essayer de créer quelque chose de nouveau, alors qu'il existe déjà des pratiques exemplaires largement appliquées ? Configurez simplement un Memcached ou Redis, et laissez-le aller.
Je suis tout à fait d'accord que l'utilisation des meilleures pratiques est généralement la meilleure option. Mais avant d'appliquer aveuglément une bonne pratique, il faut se demander : dans quelle mesure cette « meilleure pratique » est-elle applicable ? Est-ce que ça correspond bien à mon cas ?
De mon point de vue, les options appropriées et l'analyse des compromis sont indispensables pour prendre une décision importante, et c'est l'approche que j'ai choisie parce que le problème n'était pas si facile. Dans mon cas, il y avait de nombreux facteurs à prendre en compte et je ne voulais pas adopter une solution unique alors que ce n'était peut-être pas la bonne approche pour le problème à résoudre.
En fin de compte, avec la mise en cache appropriée en place, j'ai obtenu une augmentation des performances de près de 50 % par rapport à la solution initiale.