Il n'y a que deux choses difficiles en informatique: l'invalidation du cache et la dénomination des choses.
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éé?
En règle générale, une application Web classique 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 que j’ai dû résoudre n’était pas un problème original. Ma tâche était de faire un ASP.NET MVC prototype d'application web monolithique 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 les suivantes:
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 forte adoption et leur applicabilité éprouvée, ces approches présentent certains inconvénients, notamment:
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 lors de l'exécution. Cependant, le cache en mémoire ASP.NET a également ses propres inconvénients:
Si la charge supplémentaire au niveau de la base de données n'entraîne pas en soi un goulot d'étranglement, la mise en œuvre d'un cache correctement réparti semble être une tâche facile à gérer, n'est-ce pas? Eh bien, ce n’est pas un facile tâche, mais c'est possible . Dans mon cas, les tests de performance ont montré que le niveau de la base de données ne devrait pas poser de problème, car la plupart des travaux se déroulaient au niveau du 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.
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 batterie de serveurs Web ait son propre cache, interroge directement la base de données, effectue 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 en cas de modification ou d'écriture de nouvelles données. Ainsi, à partir de la logique de l'application Web, il est généralement clair à quel moment l'élément de cache doit être invalidé.
Le seul problème qui reste ici est que lorsqu'un des nœuds invalide un élément de cache dans son propre cache, aucun autre nœud ne connaîtra cette mise à jour. Ainsi, les demandes 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 en cache et en obtenir une nouvelle à la demande suivante.
Ici, Redis peut entrer en jeu. La puissance de Redis, par rapport aux autres solutions, vient de sa 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 similaires à tout système événementiel. Cette fonctionnalité peut être utilisée pour échanger des messages d'invalidation de cache entre les nœuds, de sorte que tous les nœuds pourront 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 en ce sens qu'il fonctionne comme une carte de paires clé / valeur, mais il existe une grande 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, ce qui permet à chaque élément de cache d’avoir au plus 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 que ce n'est pas le 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(IDataCacheKey key, Func valueGetter); T Get(IDataCacheKey key, Func 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 de lecture 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 expulsé du cache, le récupérateur de valeur est à nouveau appelé pour récupérer une valeur actuelle. La clé de cache a été extraite par ICacheKey
interface, 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 pour déposer des éléments avec des données et des touches tactiles, 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 utilisée Autofac pour l'injection de dépendances, qui est une implémentation de inversion de contrôle (IoC) modèle de conception 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 l'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 d'une manière ou d'une autre la logique d'action du contrôleur. 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 chargée de valider la transaction en cas de succès et de la restaurer 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 demande suivante à 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 expulsés en toute sécurité et, en cas d'échec de transaction, il n'est pas du tout nécessaire de modifier le cache.
C'était le but exact 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('drop', key.Value)); } ... public void CloseSession() { if (!IsSessionOpen) return; _postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2)); _postponedRedisMessages = null; } ... }
Le 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 le drop et le touch, de sorte que le gestionnaire de messages de chacun d'eux savait exactement quoi faire - déposer / toucher la touche égale à la charge utile du message reçu.
L'une des parties les plus délicates était de gérer correctement la connexion au serveur Redis. En cas de panne du serveur pour quelque raison que ce soit, l'application devrait continuer à fonctionner correctement. Lorsque Redis est de nouveau en ligne, l'application devrait recommencer à l'utiliser de manière transparente et à échanger à nouveau des messages avec d'autres nœuds. Pour y parvenir, j'ai utilisé le StackExchange.Redis bibliothèque et la logique de gestion des connexions résultante ont été implémentées 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 périmées. Une fois la connexion rétablie, 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'un ISomeService
la mise en œuvre 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 */); } }
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 critiques sur le plan opérationnel à évaluer. Après cela, un outil comme Apache jMeter pourrait être utilisé pour deux choses:
Pour obtenir un profil de performances, n'importe quel profileur capable de se connecter au processus de travail IIS peut être utilisé. Dans mon cas, j'ai utilisé Performances de JetBrains dotTrace . Après un certain temps passé à expérimenter pour déterminer les paramètres jMeter corrects (tels que le nombre simultané et le nombre 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 global d'exécution du code était passé dans les lectures de 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 deux fois plus rapides) pour la plupart d'entre eux.
En relation: Huit raisons pour lesquelles Microsoft Stack est toujours un choix viableComme vous pouvez le voir, mon cas peut sembler être un exemple de ce que l'on appelle habituellement «réinventer la roue»: pourquoi se donner la peine d'essayer de créer quelque chose de nouveau, alors qu'il existe déjà des meilleures pratiques 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 meilleure pratique, il faut se demander: dans quelle mesure cette «meilleure pratique» est-elle applicable? Convient-il bien à mon cas?
La façon dont je vois les choses, 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 universelle alors que ce n'était peut-être pas la bonne approche pour le problème en question.
En fin de compte, avec la mise en cache appropriée, j'ai obtenu une augmentation des performances de près de 50% par rapport à la solution initiale.