Sur la plupart des diagrammes d'architecture, un cache est une boîte entre un service et sa base de données. Cette boîte cache les questions qui décident si le cache aide ou nuit : qui y place les données, qui écrit dans la base, et que voit un lecteur entre les deux ?
Quatre stratégies répondent différemment à ces questions : cache-aside, read-through, write-through et write-back. Ce guide les présente une à une sur le même exemple, les compare, passe en revue les défaillances qui apparaissent en production et se termine par un diagramme réutilisable.
Deux questions auxquelles tout cache répond
Chaque stratégie de cache est une paire de réponses :
- Qui charge les données dans le cache lors d'une lecture ? Soit le code de votre application, soit une couche de cache qui sait atteindre la base.
- Qui écrit dans la base, et quand ? Votre application, la couche de cache dans la même requête, ou la couche de cache plus tard.
| Stratégie | Lecture : qui remplit le cache | Écriture : qui écrit dans la base |
|---|---|---|
| Cache-aside | L'application, après un miss | L'application, qui invalide ensuite la clé |
| Read-through | La couche de cache, après un miss | Non défini par la stratégie ; souvent associé à l'une des deux suivantes |
| Write-through | En général combiné à read-through ou cache-aside | La couche de cache, dans la même requête |
| Write-back (write-behind) | En général combiné à read-through | La couche de cache, plus tard et souvent par lots |
L'exemple suivi tout au long du guide est une API produit avec une table Postgres products et un cache Redis. Une page produit lit product:42, et un outil d'administration met à jour les prix.
Cache-aside (chargement paresseux)
L'application parle aux deux stockages. Elle interroge d'abord le cache ; en cas de miss, elle lit la base et place le résultat dans le cache avec une durée de vie (TTL). La documentation ElastiCache d'AWS décrit le même pattern sous le nom de lazy loading.
def get_product(product_id):
key = f"product:{product_id}"
cached = redis.get(key)
if cached is not None:
return json.loads(cached) # hit
row = db.fetch_one("SELECT * FROM products WHERE id = %s", product_id)
if row is not None:
redis.set(key, json.dumps(row), ex=300) # fill, with a 300 s TTL
return row
def update_price(product_id, price):
db.execute("UPDATE products SET price = %s WHERE id = %s", price, product_id)
redis.delete(f"product:{product_id}") # invalidate, don't overwrite
Lors d'une écriture, supprimez la clé au lieu d'écrire la nouvelle valeur dans le cache. La lecture suivante charge la ligne à jour. Le pattern cache-aside de Microsoft décrit la même règle d'écriture.
Ce que vous gagnez : seules les données demandées par quelqu'un sont mises en cache, et si le cache tombe, les lectures continuent en allant à la base, simplement plus lentement. Ce que vous payez : chaque miss coûte trois allers-retours (cache, base, cache), et il existe une situation de concurrence. Un lecteur qui a fait un miss peut lire l'ancienne ligne, l'écrivain met la ligne à jour et supprime la clé, puis le lecteur réécrit l'ancienne valeur dans le cache. Elle reste obsolète jusqu'à l'expiration du TTL, l'une des raisons pour lesquelles le cache-aside a toujours besoin d'un TTL.
Read-through
L'application n'appelle que le cache. En cas de miss, la couche de cache charge la valeur depuis la base via un chargeur que vous configurez, la stocke et la renvoie. Le résultat de lecture est le même qu'en cache-aside, mais le code de chargement vit à un seul endroit au lieu de se répéter chez chaque appelant.
Un serveur Redis seul ne lit pas votre base de lui-même. Le read-through vient d'une couche qui en est capable : une bibliothèque de cache avec une fonction de chargement, un proxy, ou un cache managé conçu pour une base précise. Amazon DynamoDB Accelerator (DAX) en est un exemple : en cas de miss, il transmet la lecture à DynamoDB, renvoie le résultat et le met en cache.
Le compromis : la couche de cache se trouve désormais sur le chemin de lecture. Si elle tombe, les lectures échouent, sauf si le client dispose d'un repli vers la base.
Write-through
L'application écrit dans la couche de cache, qui écrit dans la base au cours de la même opération. L'application ne reçoit un succès que si les deux écritures réussissent. DAX documente exactement ce fonctionnement : les données sont écrites d'abord dans la table DynamoDB, puis dans le cache, et l'appel ne réussit que si les deux écritures réussissent.
Ce que vous gagnez : les données écrites via le cache y sont à jour, donc une lecture juste après une écriture les voit. Ce que vous payez : chaque écriture attend deux stockages, et le cache se remplit de données écrites mais jamais lues. Ajoutez un TTL pour qu'elles expirent. Un nouveau nœud de cache vide ne contient rien non plus tant que les données ne sont pas réécrites ; c'est pourquoi le write-through est généralement combiné au read-through ou au chargement paresseux.
Write-back (write-behind)
L'application écrit dans le cache et reçoit un succès immédiatement. La couche de cache écrit dans la base plus tard, souvent par lots, et les mises à jour répétées d'une même clé se réduisent à une seule écriture en base.
Ce que vous gagnez : la latence d'écriture la plus faible et beaucoup moins d'écritures en base pendant les pics. Ce que vous payez : tout ce qui n'a pas encore été écrit est perdu si le nœud de cache tombe, sauf si le tampon est répliqué ou adossé à un journal durable. La base est aussi en retard sur le cache, si bien que les rapports, les autres services et les sauvegardes voient des données plus anciennes. Le write-back convient aux compteurs de vues et aux données similaires, pour lesquelles perdre quelques secondes est acceptable.
Côte à côte
| Cache-aside | Read-through | Write-through | Write-back | |
|---|---|---|---|---|
| Le code applicatif connaît la base | Oui | Non | Non | Non |
| Latence d'écriture | Écriture en base plus une suppression dans le cache | Dépend de la stratégie d'écriture | Cache et base | Cache seulement |
| Lectures obsolètes | Jusqu'à l'invalidation ou au TTL | Jusqu'à l'invalidation ou au TTL | Rares pour les écritures qui passent par le cache | La base est en retard sur le cache |
| Perte d'un nœud de cache | Lectures plus lentes | Lectures en échec sans repli | Données manquantes jusqu'à la réécriture | Les écritures non vidées sont perdues |
| Usage typique | Le choix par défaut avec Redis | Une bibliothèque ou un cache managé | Fraîcheur juste après l'écriture | Compteurs et pics d'écriture |
TTL, éviction et trois défaillances classiques
Un TTL borne le degré d'obsolescence d'une valeur. Choisissez-le par type de donnée, selon l'obsolescence que le produit accepte : une description de produit peut vivre plusieurs minutes, un niveau de stock peut-être quelques secondes. Une clé expirée se comporte comme si elle n'existait pas.
L'éviction est un mécanisme distinct : ce que le cache retire quand il manque de mémoire. Dans Redis, maxmemory fixe la limite et maxmemory-policy choisit ce qui est retiré. La valeur par défaut dans redis.conf est noeviction, qui renvoie des erreurs aux commandes qui ajoutent des données une fois la mémoire pleine. Pour un cache pur, la documentation de Redis suggère allkeys-lru comme bon choix par défaut. Les services managés fixent leurs propres valeurs par défaut : vérifiez la vôtre. Mesurez le taux de succès à partir de INFO stats avec keyspace_hits / (keyspace_hits + keyspace_misses).
Trois défaillances apparaissent dans presque tous les caches :
- Stampede. Une clé très demandée expire et des centaines de requêtes font un miss au même moment, et toutes interrogent la base. Laissez une seule requête reconstruire la valeur, et faites patienter brièvement les autres ou servez-leur l'ancienne valeur. Un verrou Redis fait cela :
SET lock:product:42 <token> NX PX 5000ne réussit que pour un seul appelant. L'article de Facebook sur memcache résout le même problème avec des leases et rapporte que, pour les clés concernées, le pic de requêtes vers la base est passé de 17 000 à 1 300 par seconde. - Pénétration. Les requêtes pour des identifiants inexistants font toujours un miss et frappent toujours la base. Mettez en cache le résultat « introuvable » avec un TTL court, ou consultez d'abord un filtre de Bloom des identifiants valides.
- Avalanche. De nombreuses clés expirent en même temps, par exemple après un chargement en masse ou un redémarrage du cache, et la base encaisse toute la charge d'un coup. Ajoutez une variation aléatoire aux TTL, par exemple 300 secondes plus jusqu'à 60, et réchauffez le cache progressivement.
Le diagramme
Quatre rangées avec les trois mêmes participants : la différence se lit dans les liens, c'est-à-dire qui parle à la base, et quand.
Contenu du diagramme
- Product API
- Redis
- Postgres
- Cache layer
- 1 GET (miss)
- 2 SELECT
- 3 SET, TTL 300 s
- 1 read
- 2 on miss: SELECT
- 1 write
- 2 UPDATE, same request
- 1 write, OK at once
- 2 flush later, batched
Comment le lire :
- Rangée 1, cache-aside. L'API produit lit Redis (1), lit elle-même Postgres en cas de miss (2) et remplit Redis avec un TTL (3). Le lien vers Postgres contourne le cache : l'application est responsable des deux appels.
- Rangée 2, read-through. L'API ne parle qu'à la couche de cache, qui charge depuis Postgres en cas de miss.
- Rangée 3, write-through. L'écriture passe par la couche de cache jusqu'à Postgres dans la même requête.
- Rangée 4, write-back. L'API reçoit son succès tout de suite. Le lien en tirets représente l'écriture différée, par lots.
Comment dessiner les caches sur un diagramme d'architecture
- Nommez la stratégie et le TTL. « Cache-aside, TTL 300 s » sur le nœud de cache ou sur son lien répond à la première question d'un relecteur.
- Dessinez qui parle à la base. En cache-aside, le service a un lien vers la base en plus de son lien vers le cache. Un cache dessiné en ligne devant la base suggère le read-through, et c'est ce que les lecteurs supposeront.
- Dessinez le chemin d'invalidation. Montrez qui supprime les clés, y compris les traitements par lots et les outils d'administration qui écrivent directement dans la base. Un écrivain sans chemin vers le cache est la source des données obsolètes.
- Écrivez le comportement en cas de panne. « Cache indisponible : les lectures vont vers Postgres » en dit plus à l'astreinte qu'une deuxième icône de cache.
- Mettez les chiffres sur le canvas. Taux de succès visé, limite mémoire et politique d'éviction tiennent dans une petite note à côté du cache.
- Passez en tirets tout ce qui est asynchrone. Un vidage en write-back ou un événement d'invalidation ne fait pas partie de la requête.
Choisir
Commencez par du cache-aside avec un TTL. Cela fonctionne avec n'importe quel cache et n'importe quelle base, et en cas de panne du cache, vous n'avez que des lectures plus lentes. Passez au read-through quand une bibliothèque ou un cache managé vous fournit un chargeur. Ajoutez le write-through quand les lectures juste après une écriture doivent voir la nouvelle valeur et que les écritures peuvent supporter la latence supplémentaire. N'utilisez le write-back que si vous pouvez perdre ou rejouer les écritures non vidées.
Pour le contexte autour du cache, consultez l'exemple de diagramme de conception de systèmes, où Redis sert le chemin de lecture d'un réducteur d'URL, et les règles du diagramme d'architecture microservices pour montrer quel service possède quelles données.
Le construire dans Archboard
- Connectez-vous avec Google ou GitHub et créez un diagramme.
- Pour chaque stratégie, ajoutez un titre Texte du pack Base, puis placez trois nœuds sur une rangée : un Conteneur du pack Modèle C4 pour l'API produit, un Redis du pack Données pour le cache et un Postgres du pack Données pour la base. Nommez le cache « Cache layer » dans les rangées 2 à 4.
- Reliez chaque rangée avec des libellés de liens numérotés : « 1 GET (miss) », « 2 SELECT » et « 3 SET, TTL 300 s » pour le cache-aside, et les étapes de lecture ou d'écriture pour les autres rangées. Faites passer le lien cache-aside de l'API vers Postgres par-dessus le cache.
- Passez en tirets le lien de vidage du write-back.
- Ajoutez sous chaque rangée une note Texte du pack Base avec la règle d'écriture et ce qui se passe quand le cache tombe.
L'exemple utilise 12 nœuds de diagramme et reste dans les limites de l'offre Free. Pour la méthode générale qui sous-tend tout diagramme d'architecture, consultez comment dessiner un diagramme d'architecture logicielle.
FAQ
Cache-aside et lazy loading, est-ce la même chose ? Oui. Les deux noms désignent une application qui ne charge les données dans le cache qu'après un miss. La documentation d'AWS parle de « lazy loading », beaucoup d'autres sources de « cache-aside ».
Faut-il mettre à jour ou supprimer la clé de cache quand les données changent ? La supprimer, en cache-aside. Deux écrivains qui placent chacun leur nouvelle valeur dans le cache peuvent terminer dans le mauvais ordre et laisser une ancienne valeur. Une suppression suivie d'un nouveau chargement évite ce problème, et le TTL limite la situation de concurrence plus rare décrite plus haut.
Redis peut-il faire du read-through ou du write-through tout seul ? Non. Un serveur Redis stocke ce que les clients lui envoient. Le read-through et le write-through ont besoin d'une couche qui connaît votre base : une bibliothèque de cache, un proxy, ou un cache managé conçu pour une base précise.
Quel TTL utiliser ? L'obsolescence la plus longue que le produit accepte pour ce type de donnée. Ajoutez ensuite une variation aléatoire pour que les clés chargées ensemble n'expirent pas ensemble.
Le write-back est-il sûr ? Seulement si perdre ou rejouer les écritures non vidées est acceptable, ou si le tampon d'écriture est répliqué ou adossé à un journal durable. Évitez-le pour des données comme les paiements ou les commandes.