Nouvelles Chroniques d'Amethyste

Penser au Sens, pas au Verbe

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

Poster un commentaire

Utilisation des caches

Le cache est sans doute l’outil le mieux connu des développeurs et certainement le plus répandu. Il faut dire que l’efficacité est très grande et la mise en œuvre vraiment très simple. Il existe très peu de sites web qui se passent de cache et dans ce cas c’est probablement une erreur.

Mais avant de se lancer il y a tout de même un petit fond culturel utile à connaître:

  • Définition d’un cache
  • Mécanismes fondamentaux
  • Pattern cache-aside
  • Cache local, cache distribué
  • Création de cache maison
  • Redis
  • Accélérateur web
  • Quelques mauvaises pratiques

Définition

Tout d’abord la définition d’un cache.

Le cache est un dépôt de données en mémoire

 

Par exemple on ne peut pas appeler cache une base de données ou un storage Azure. L’objet et l’utilité des caches est d’accélérer l’accès à des données ce qui ne peut être fait qu’avec des accès en mémoire.

Ce point est très important. La mémoire est en effet une ressource plus rare qu’un espace de stockage sur un disque dur. Et ceci fait beaucoup de différence.

Une autre conséquence est que le cache n’est pas un stockage permanent, il n’est pas ACID!

Quel problème cherche t’on à résoudre?

Le pattern CQRS nous a permit de découpler les commandes des requêtes qui n’ont alors plus besoin d’entrer en concurrence et surtout que l’on peut optimiser de façon spécifique.
Même si Azure TABLE est plus performant qu’une base de données relationnelle pour les requêtes, celles-ci ont tout de même un coût.
Le problème qui se pose est qu’une partie des données stockées sont récupérées presque à chaque requête même si elles ne sont pas forcément très nombreuses.
Nous souhaitons optimiser l’accès à ces données.

Les mécanismes fondamentaux

Les données obsolètes (stale data) sont des données qui ne sont plus synchronisées avec les données réelles. Ce problème a été évoqué et discuté dans l’article sur CQRS qui rencontre la même difficulté.
Il a été expliqué que la situation n’est en général pas aussi dramatique qu’elle pourrait le sembler.
Il existe 3 mécanismes de vidage d’un cache:
  1. expiration
  2. éviction
  3. invalidation
Et un seul d’alimentation du cache:
  • read-through
Initialement, au démarrage du site le cache est vide:
2016-08-18_21-52-53
Le cache peut être alimenté par une chauffe initiale du site ou bien au cours de son fonctionnement normal:
2016-08-18_22-05-39
La mémoire consommée par le cache a évidemment augmentée, mais reste en dessous d’un certain seuil. Le site interroge directement le cache, pas la base de données.
Comme le montre la disposition des flèches, c’est le cache qui s’auto-aliment, pas le site qui écrit dans le cache. C’est un mécanisme fondamental du cache appelé read-through.
Les données enregistrées en cache sont associées à une politique d’expiration qui indique au cache combien de temps les données seront conservées avant d’être rafraîchies. Il peut s’agir d’une durée absolue ou d’une durée glissante à partir de la dernière lecture.
La valeur de cette durée résulte d’un compromis pour éviter de trainer des données trop obsolètes, mais aussi pour éviter de continuels appels vers la base de données qui diminuent d’autant l’intérêt du cache. Fixer cette valeur est assez expérimental.
2016-08-18_22-09-46
Comme on le voit, les données obsolètes sont supprimées du cache et non pas rafraîchit. On attendra qu’elle soit réclamées par le site via le read-through.
Au fil du temps le cache se remplit et se vide donc automatiquement. Il peut arriver qu’un certain seuil de mémoire soit atteint ou dépassé. Le cache doit libérer de la mémoire en supprimant une partie du cache.
2016-08-18_22-07-36
Une politique d’éviction est alors appliquée.
Dans certaines circonstances que nous verrons plus loin, l’application elle-même peut demander au cache de supprimer un enregistrement. C’est l’invalidation.

Le pattern cache-aside

Read-through peut être implémenté nativement ou bien par votre application via le pattern cache-aside:
  • La lecture des données s’effectue dans le cache.
  • Si les données ne sont pas présentes, le système de cache (pas l’application) doit recharger les données depuis une source (read-through).

2016-08-19_11-53-43

  • Si une donnée est mise à jour en base on la supprime du cache (invalidation) et on attend un read-through pour la recharger.
2016-08-19_11-55-51
Important:  Une conséquence du read-through est que l’on ne doit jamais supposer que le cache est alimenté.

Architectures de cache

On distingue deux types de cache. La principale différence est la façon dont ils gèrent ou pas la consistance des données d’une requête à l’autre.

Cache local

Un cache local est un cache hébergé directement par l’application au niveau de l’instance.
C’est le mécanisme le plus simple et le plus performant également.
Ce cache est adapté pour:
  • données statiques ou qui évoluent peu
  • besoin d’un accès rapide
    Les temps d’accès sont de l’ordre de la dizaine de ms
Il n’est pas très adapté pour:
  • Données qui ont besoin d’être consistantes
    panier client par exemple.
  • Données très volumineuses
    Le cache risque d’entrer en compétition avec le site pour l’accès à la mémoire. Cela accroit la pagination ce qui n’est pas très bon pour les performances.

Cache distribué

Cette fois le cache n’est plus local, mais distribué sur d’autres serveurs, en tout cas en dehors du processus de l’application. Il peut y avoir plusieurs instances du caches, mais le système de cache se charge de gérer la synchronisation et l’équilibrage de charge ce qui garantit que chaque instance verra les même états.
Adapté si:
  • Cycle d’obsolescence des données rapide
    Quelques minutes
  • Données transactionnelles
    Un panier par exemple
  • Les données doivent être consistantes
  • Besoin de contrôler la scalabilité du cache indépendamment de celle du site
    On peut ajouter des instances supplémentaire du cache sans modifier le nombre d’instances du site
  • Volumétrie importante
    Il est parfaitement possible d’installer un cache distribué sur des serveurs dédiés
Pas adapté si:
  • Besoin de performances réduite
    Sans exagérer la situation, un cache local sera toujours plus performant qu’un cache distribué, mais un cache distribué est lui même plus performant qu’une requête de base de données ou un accès à un storage Azure
  • Données statiques ou presque
    La raison est que c’est largement inutile. Un cache distribué a un coût d’infrastructure

Mise en œuvre pratique

On peut bien entendu implémenter son propre cache.

Ce n’est pas si facile que l’on pourrait le penser. Il ne suffira pas de déclarer un Dictionary static quelque part et c’est terminé. Un bon cache doit offrir un certain nombre de services et en particulier:

  • Thread-safe
  • Politique d’éviction
  • Politique d’expiration
  • Possibilité d’invalider un enregistrement

Je propose de regarder ensemble les patterns qui peuvent être envisagés, mais à mon avis le meilleurs choix est de se tourner vers des librairies tierces.

Implémenter cache-aside

Voici un code typique. Je prend le cas d’un Dictionary, mais ce point importe peu.

static Dictionary<string, object> Cache = new Dictionary<string, object>();
 
public static T GetValue<T>(string key, Func<T> loadingFunction)
{
   object value;
   bool getTest = Cache.TryGetValue(key, out value);
 
   if (getTest)
   {
      return (T)value;
   }
 
   T obj = loadingFunction();
   Cache.Add(key, obj);
 
   return obj;
}

Ce qui est important est la signature de GetValue et en particulier la présence du paramètre loadingFunction. Il s’agit d’une méthode anonyme qui indique au cache comment se réalimenter si la donnée qui lui est demandée ne se trouve pas dans le cache. C’est le pattern cache-aside.

Variante thread-safe

Ce code n’est pas thread-safe ce qui peut conduire à des conflits lors de l’écriture du cache ligne 14.

On commence par définit une nouvelle variable static:


static readonly object cacheLock = new object();

Il est assez important que la variable ne soit pas public et répondent à quelques critères comme indiqué ici:

https://amethyste16.wordpress.com/2014/05/09/comment-fonctionne-lock/

 

GetValue peut alors être réécrite:

public static T GetValue<T>(string key, Func<T> loadingFunction)
{
   object value;
   bool getTest = Cache.TryGetValue(key, out value);
 
   if (getTest)
   {
      return (T)value;
   }
 
   lock (cacheLock)
   {
      T obj = loadingFunction();
      Cache.Add(key, obj);
 
      return obj;
   }
}

On voit parfois apparaître un pattern de verrouillage à double test (double-checked locking). A vous d’évaluer ‘il est nécessaire, je ne l’ai pas employé dans cet exemple de code.

Il peut arriver que la structure des données en cache, le fait que le site soit ou pas préchauffé, ou le trafic modéré du site font que ce raffinement soit peu ou pas utile.

Il est difficile de répondre à la question sans tester les performances dans un cas comme dans l’autre.

 

Une difficulté qui peut apparaître avec lock est qu’il y est difficile d’appeler en asynchrone loadingFunction avec await. Voici une solution:

http://sanjeev.dwivedi.net/?p=292

 

Le verrou souffre d’un défaut majeur outre sa lenteur, un thread à la fois ne pourra parcourir le bloc de code. Ce n’est pas un problème pour les données de référence puisque tant qu’elles ne sont pas disponibles le site devra de toute façon attendre.

Mais peut être avez vous mis en cache un panier client. Le lock va donc obliger TOUS vos clients à attendre leur tour alors que dans ce cas il n’y a pas de risques de conflits en écriture.

Dans ce cas le mieux est de supprimer le lock et bien entendu charger les paniers clients par client (et non pas tous en bloc) s’ils sont également enregistrés en base. C’est d’ailleurs la stratégie adoptée par la classe que nous allons découvrir dans le chapitre qui suit.

Remarque: Depuis .Net 4.0 on peut simplifier le code qui précède avec Lazy<T> qui d’ailleurs implémente un verrouillage double test en interne. Vous trouverez un exemple dans le code de démo: EscarGoCache.

ConcurrentDictionary

ConcurrentDictionary<TKey,TValue> est un dictionnaire thread-safe. Lisez tout de même ce petit paragraphe dans la documentation (la traduction française est incohérente):

 

All public and protected members of ConcurrentDictionary<TKey, TValue> are thread-safe and may be used concurrently from multiple threads. However, members accessed through one of the interfaces the ConcurrentDictionary<TKey, TValue> implements, including extension methods, are not guaranteed to be thread safe and may need to be synchronized by the caller.

Pour une analyse plus complète de l’algorithme implémenté:

www.simple-talk.com/blogs/inside-the-concurrent-collections-concurrentdictionary

Du coup le code que nous avons déjà vu pourrait se simplifier.

 

Voici un exemple d’anti-pattern avec cette classe:

http://stackoverflow.com/questions/12163125/is-locking-necessary-in-this-concurrentdictionary-caching-scenario?rq=1

Voici un code plus correct:

static ConcurrentDictionary<string, Lazy<object>> Cache = 
       new ConcurrentDictionary<string, Lazy<object>>();
 
public static T GetValue<T>(string key, Func<T> loadingFunction)
{
   var newValue = new Lazy<T>(loadingFunction);
   object obj = Cache.GetOrAdd(key, (clef) => new Lazy<Object>()).Value;
 
   return (T)obj;
}

Notez la façon dont le cache est déclaré ligne 1 avec la présence de Lazy<> qui est donc le type retourné par GetOrAdd, d’où le Value.

MemoryCache

Depuis .Net 4.0, nous disposons d’une classe dédiée à l’écriture de cache: MemoryCache. MemoryCache présente le gros avantage de ne pas être spécialement liée au Web.

C’est très certainement le meilleurs choix. Le code de démo fournit une implémentation dans la classe LocalCache.

Je déconseille vivement d’accéder à MemoryCache directement. mettez en place un pattern comme une façade, un proxy, adaptateur, stratégie…

Cache distribué

Redis est un exemple de cache distribué qui est d’autant plus intéressant qu’il est proposé comme un service Azure. Vous n’avez donc pas à vous soucier de l’installer, le configurer ou gérer sa scalabilité. Redis est aussi un dictionnaire (REmote DIctionary Server).
Vous trouverez également une démonstration dans le projet EscarGoCache.
On peut aussi se tourner vers d’autres outils tels Memcached qu’utilisent YouTube, Twitter…

Accélérateur web

Un accélérateur web est un mécanisme qui peut accélérer l’affichage des pages web.
2016-08-20_08-48-40
Il s’agit d’un proxy inverse.
Lorsque le client réclame une page, la requête aboutit au proxy qui agit comme un cache. Si la page est déjà dans le cache celle-ci est servit. Autrement le proxy la réclame au site, la pousse dans le cache et sert la page (cache-aside).
Les accélérateurs de cache sont efficaces pour les pages statiques ou tout contenu statique (image, vidéo…)
Il existe des outils commerciaux comme Akamaï ou gratuits comme Varnish.

Notre projet de démo

Le projet est EscarGoCache.

Analyse du besoin

 Le projet de démo met en évidence au moins deux besoins:
  1. Garder en cache les données de référence
  2. Optimiser l’affichage des pages de détail Course et Concurrent qui sont les plus sollicitées
Le premier besoin correspond à des données statiques, par exemple:
2016-08-09_10-51-35
Et des données semi-statiques comme les courses, on en ajoutera relativement peu souvent. On peut de toute façon se permettre une latence de plusieurs minutes si ce n’est plus. Ce besoin sera assuré par un cache local.
Le besoin suivant exige que les données soient consistantes. Il n’est pas question que d’une requête à l’autre on obtienne un affichage différent au grès des dates d’invalidation des données. C’est le cas de figure idéal pour utiliser REDIS.
On devra donc créer un service REDIS dans Azure et alimenter correctement les fichiers de configuration.
L’un dans l’autre un certain nombre de données vont pouvoir migrer des TABLES vers le cache ce qui conduit à une simplification et donc va dans le sens de meilleures performances. En fait vu la faible volumétrie de données il n’est pas même évident que les TABLE soient indispensables, mais on va les garder malgré tout.
Vous noterez en lisant le code que TableEntity n’est pas sérialisable binaire d’où le Json. On pourrait envisager de supprimer le storage et lire directement depuis la base. Ce n’est pas ce que j’ai fais, mais ce n’est pas trop grave pour une application de démo.
Les deux classes importantes sont:
  1. RedisCache
  2. RedisRepository
Quel que soit le type de cache utilisé, il est important de noter que le code client ne doit jamais accéder directement au cache. Vous devez passer par l’intermédiaire de patterns comme façade, strategy, adaptateur, proxy…

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