Performance

Stratégies de mise en cache

Le caching est ce qui permet aux systèmes de rester rapides sous charge. Les patterns sont simples ; la difficulté réside dans le choix de ce qu'il faut conserver, pour combien de temps, et quand le supprimer.

intermediate15 min readUpdated 16 sept. 2026
cache-aside.ts
ts
// cache-aside.ts
export async function getProduct(id: string): Promise<Product> {
  const key = `product:${id}`;

  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached) as Product;

  const product = await db.product.findUniqueOrThrow({ where: { id } });
  await redis.set(key, JSON.stringify(product), { EX: 300 });
  return product;
}
Objectif principal
Réduire la latence et la charge
Pattern par défaut
Cache-aside
TTL typique
60s à 1h
Stockage courant
Redis ou in-process
Partie difficile
L'invalidation
Couche Edge
CDN plus en-têtes HTTP

Pourquoi c'est important

Pourquoi le caching transforme tout le système

Une latence perceptible

Un cache hit répond en bien moins d'une milliseconde, alors que l'aller-retour vers la base de données qu'il remplace coûte des dizaines de millisecondes. Sur une page qui effectue vingt lectures, cette différence définit toute l'expérience utilisateur.

Une charge invisible pour la base de données

Servir la majorité des lectures depuis le cache peut réduire les requêtes vers la base de données d'un ordre de grandeur, offrant ainsi une marge de manœuvre sans avoir à migrer vers une instance plus puissante.

Un concept, plusieurs couches

Le même raisonnement s'applique dans le navigateur, au niveau du CDN, à l'intérieur de l'application et dans la base de données, chacun avec sa propre gestion de la fraîcheur et de l'invalidation.

Le tableau complet

Les trois concepts fondamentaux de tout cache

Stocker une copie plus proche du lecteur, la servir jusqu'à ce qu'elle soit obsolète, et décider délibérément comment elle est supprimée.

Placement

Localiser

Placez la copie aussi près que possible du lecteur, tant que la cohérence des données le permet. Plus c'est proche, plus c'est rapide, mais plus c'est difficile à invalider.

Stockage

Servir

Un stockage en mémoire répond aux lectures sans toucher au disque, et un stockage partagé permet à chaque instance de voir les mêmes données.

Fraîcheur

Expirer

Chaque valeur mise en cache a besoin d'une règle définissant sa durée de validité et la manière dont elle est remplacée lorsqu'elle devient obsolète.

HTML5 en un coup d'oeil

Où peut se situer un cache

Cache navigateur

Cache-Control et ETag permettent au client de réutiliser une réponse sans interroger le serveur.

CDN et Edge

Des caches partagés à l'edge absorbent le trafic pour les réponses publiques et miscibles en cache.

Mémoire applicative

Une Map in-process ou un cache LRU est le niveau le plus rapide, mais il vit et meurt avec chaque instance.

Redis ou Memcached

Un stockage en mémoire partagé se place à côté de l'application et sert chaque instance de manière cohérente.

Cache de base de données

Les buffer pools, les vues matérialisées et les plans préparés permettent de garder la source elle-même rapide.

Invalidation

Le TTL, le versionnage de clés ou les suppressions explicites décident quand une copie obsolète cesse d'être servie.

Flux

Fonctionnement d'une lecture cache-aside

Le cache est consulté en premier, et seul un 'miss' atteint la base de données.

  1. 1

    Vérifier le cache

    Construire une clé déterministe et l'interroger dans Redis ou le stockage in-process.

  2. 2

    Miss

    La clé est absente, le cache ne peut donc pas répondre. C'est le moment où le travail réel est effectué.

  3. 3

    Charger depuis la source

    Interroger la base de données ou recalculer la valeur coûteuse une seule fois.

  4. 4

    Réécrire avec un TTL

    Stocker le résultat sous la même clé avec une expiration, afin que la prochaine lecture soit un hit.

  5. 5

    Retourner et servir les hits

    Les lectures ultérieures trouvent la valeur mise en cache et ignorent la source jusqu'à l'expiration du TTL ou l'invalidation de la clé par une mise à jour.

Le guide complet

Stratégies de mise en cache: Tout ce que vous devez savoir

Pourquoi utiliser le cache ?

Un cache est une copie de données conservée dans un endroit plus rapide d’accès que la source. Chaque décision d’implémenter un cache est en réalité un pari : celui que la même valeur sera demandée à nouveau avant d’être modifiée, et que servir une copie légèrement obsolète est acceptable. Lorsque ces deux conditions sont réunies, le gain est énorme.

Un seul “hit” (accès réussi au cache) produit trois effets :

  • La latence. Une recherche dans Redis répond en bien moins d’une milliseconde ; la même ligne dans PostgreSQL coûte plusieurs millisecondes de réseau, de planification et d’E/S. Sur une page qui lit vingt éléments, cette différence définit toute l’expérience utilisateur.
  • La charge. Si 95 % des lectures sont servies par le cache, la base de données ne voit qu’une seule requête là où elle en voyait vingt. Vous pouvez ainsi survivre à un pic de trafic, ou utiliser une instance plus petite, sans modifier une seule ligne de logique de requête.
  • Le coût. Moins de lectures en base de données signifient des instances plus petites, moins de réplicas de lecture et moins de trafic inter-régions. À grande échelle, le caching est l’une des rares optimisations qui se rentabilise financièrement.

La contrepartie est la complexité. Un cache est une seconde copie de la vérité, et chaque copie peut diverger. L’essentiel de ce guide consiste à faire en sorte que cette divergence reste minime et temporaire.

Où peut se situer un cache

Le caching n’est pas un système unique, mais une pile de couches, chacune étant plus proche du lecteur et possédant sa propre logique d’invalidation.

  • Le navigateur. Les réponses HTTP marquées Cache-Control: max-age sont réutilisées sans nouvelle requête. C’est gratuit, privé et largement sous le contrôle du client.
  • Le CDN ou l’edge. Un cache partagé placé devant votre origine absorbe le trafic pour les réponses publiques. C’est rapide et global, mais une erreur à ce niveau est visible par tout le monde.
  • L’application. Un cache in-process (un Map, un LRU) est le niveau le plus rapide car il évite totalement le réseau. Il est également propre à chaque instance, il doit donc être léger et jetable.
  • Un store partagé. Redis ou Memcached se situe à côté de l’application et sert chaque instance à partir d’une copie cohérente. C’est là que se trouve la majeure partie du caching applicatif.
  • La base de données. Les buffer pools, les vues matérialisées, les plans préparés et les caches de résultats de requêtes permettent à la source de rester rapide par elle-même. C’est la dernière ligne de défense avant le disque.

Une requête peut recevoir une réponse à n’importe laquelle de ces couches. Plus la réponse est proche, plus elle est rapide, mais plus elle est difficile à invalider : c’est là que réside toute la tension centrale de ce sujet.

Taux de succès du cache (hit rate) : la métrique déterminante

La santé d’un cache se mesure à son hit rate : le nombre de succès (hits) divisé par la somme des succès et des échecs (misses). C’est le seul chiffre qui vous indique si le cache est réellement utile.

hit rate = hits / (hits + misses)

L’impact arithmétique est radical. Avec un hit rate de 90 %, la base de données ne reçoit qu’une requête sur dix, soit une réduction par 10. À 50 %, elle en reçoit une sur deux, soit seulement une réduction par 2, alors que le cache a ajouté un saut réseau à la moitié du trafic. Un cache avec un hit rate faible peut s’avérer plus lent que l’absence totale de cache.

Mesurez-le directement depuis le store. Redis rapporte keyspace_hits et keyspace_misses via INFO stats, et les bibliothèques clientes exposent ces mêmes compteurs. Surveillez le hit rate après chaque modification d’une clé, d’un TTL ou d’une requête ; un léger changement de clé peut faire chuter silencieusement un cache de 95 % à 40 %.

Le corollaire est que vous devez mettre en cache des éléments qui sont réellement répétés. Une clé unique par requête a, par construction, un hit rate de 0 % et ne fait que gaspiller de la mémoire.

Les quatre patterns fondamentaux

Presque toutes les implémentations de cache suivent l’un de ces quatre modèles. Ils diffèrent selon qui remplit le cache et quand celui-ci est mis à jour.

Cache-aside (chargement paresseux)

L’application gère la logique. Lors d’une lecture, elle vérifie le cache et, en cas d’absence (miss), elle charge la donnée depuis la source puis écrit le résultat dans le cache. C’est le pattern par défaut car il est simple, fonctionne avec n’importe quel store et ne met en cache que les données réellement demandées.

async function getUser(id: string) {
  const key = `user:${id}`;

  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);

  const user = await db.user.findUniqueOrThrow({ where: { id } });
  await redis.set(key, JSON.stringify(user), "EX", 600);
  return user;
}

L’inconvénient est que le premier lecteur paie toujours le prix fort (en termes de latence), et le cache peut contenir des données obsolètes jusqu’à l’expiration de son TTL ou jusqu’à ce qu’une écriture les supprime.

Read-through

Le read-through déplace la logique de repli directement dans la couche de cache : l’application interroge toujours le cache, et celui-ci est configuré avec un chargeur (loader) qui s’exécute en cas de miss. Certaines bibliothèques et proxys implémentent cela, de sorte que le code de l’application n’a pas de chemin explicite pour gérer le miss. Le comportement est identique au cache-aside ; seule l’emplacement de la logique change.

Write-through

Le write-through met à jour le cache et la source lors d’une seule et même opération. Les lectures sont toujours rapides (hot) et ne voient jamais de données plus anciennes que la dernière écriture réussie.

async function updateUser(id: string, patch: Partial<User>) {
  const user = await db.user.update({ where: { id }, data: patch });
  await redis.set(`user:${id}`, JSON.stringify(user), "EX", 600);
  return user;
}

Cela augmente la latence d’écriture et met en cache des données qui ne seront peut-être jamais lues, mais cela maintient le cache cohérent avec le flux d’écriture, ce qui est précisément ce dont ont besoin les mises à jour visibles par l’utilisateur.

Write-behind (write-back)

Le write-behind écrit immédiatement dans le cache et synchronise les données vers la source de manière asynchrone. Les écritures sont très rapides et peuvent être regroupées (batching), ce qui est précieux pour les compteurs et la télémétrie. Le risque est cependant important : si le cache tombe avant la synchronisation, l’écriture est perdue. Utilisez-le uniquement lorsque la perte des dernières écritures est acceptable, et jamais pour des transactions financières ou des registres officiels.

TTL et fraîcheur des données

Chaque valeur mise en cache doit avoir un time to live (TTL). Le TTL est un budget de péremption : c’est le temps maximum pendant lequel un utilisateur peut voir une valeur qui n’est plus à jour à la source. Le choix de ce délai est autant une décision produit qu’une décision technique.

  • Prix et stocks : quelques secondes. Un prix erroné se transforme rapidement en ticket de support.
  • Profils, flux et listes : quelques minutes. Un léger décalage est invisible pour l’utilisateur.
  • Données de référence, feature flags et configuration : de quelques minutes à quelques heures.
  • Assets statiques et contenus changeant rarement : plusieurs jours, avec un versionnage par nom de fichier.

Ajoutez du jitter (variation aléatoire) à votre TTL. Si mille clés sont écrites au même moment avec le même TTL, elles expireront simultanément, provoquant un pic de charge. Randomiser ce délai de quelques pourcents permet d’étaler les rechargements.

const ttl = 600 + Math.floor(Math.random() * 60);
await redis.set(key, JSON.stringify(value), "EX", ttl);

Même en cas d’invalidation explicite, conservez un TTL comme filet de sécurité. Un bug qui oublierait de supprimer une clé ne devrait vous coûter que quelques minutes de données obsolètes, et non une erreur permanente.

L’invalidation : la partie difficile

Il y a une raison pour laquelle l’invalidation du cache est la chute de la plus vieille blague de l’informatique. Stocker une valeur est trivial, mais il est réellement difficile de la supprimer au bon moment, à chaque couche, et sans conditions de concurrence (race conditions).

Trois techniques couvrent presque tous les cas de figure.

Le versionnage des clés. Au lieu de supprimer, changez la clé. Préfixez vos clés avec une version que vous incrémentez lorsque les données sous-jacentes changent ; ainsi, les anciennes entrées deviennent inaccessibles et expirent d’elles-mêmes.

const version = (await redis.get(`user:${id}:v`)) ?? "1";
const key = `user:${id}:v${version}`;

Cette méthode évite les race conditions et fonctionne entre plusieurs instances, c’est pourquoi c’est l’approche privilégiée pour tout ce qui est partagé.

L’invalidation explicite (explicit bust). Supprimez la clé lors de l’écriture. C’est l’approche la plus évidente et la plus facile à rater, car chaque chemin d’écriture doit impérativement s’en souvenir.

await db.user.update({ where: { id }, data: patch });
await redis.del(`user:${id}`);

L’invalidation pilotée par les événements. Publiez un événement de modification et laissez chaque instance et chaque couche réagir. C’est ainsi qu’un purge de CDN ou un cache multi-services reste cohérent, et cela s’adapte aux systèmes que vous ne contrôlez pas.

Quel que soit votre choix, soyez cohérent sur l’ordre des opérations, et préférez la suppression à l’écrasement lors d’une mise à jour. La suppression est idempotente ; l’écrasement peut ressusciter une valeur qui a déjà été remplacée par un autre écrivain concurrent.

Cache stampede et thundering herd

Lorsqu’une clé populaire expire, toutes les requêtes en cours subissent un “miss” au même instant. Si mille requêtes arrivent par seconde et que la clé prend 200 ms à être reconstruite, des centaines de requêtes identiques frappent la base de données simultanément. C’est ce qu’on appelle un cache stampede, ou thundering herd, et cela peut mettre à terre un service qui était pourtant stable un instant auparavant.

Voici quatre solutions d’atténuation, par ordre de préférence approximatif :

  • Stale-while-revalidate. Servez immédiatement la valeur obsolète (stale) et rafraîchissez-la en arrière-plan. Les lecteurs n’attendent jamais et la source ne reçoit qu’un seul rafraîchissement. HTTP possède un header dédié précisément à cela.
  • Locking ou single-flight. Seul le premier appelant recalcule la valeur ; tous les autres attendent brièvement ou utilisent la donnée obsolète. Redis SET key value NX EX est un verrou distribué simple.
  • TTLs avec jitter. Étalez les dates d’expiration pour éviter que toutes les clés n’expirent en même temps.
  • Expiration précoce probabiliste. Chaque lecture a une faible probabilité, croissante, de déclencher un rafraîchissement avant que la clé n’expire réellement, répartissant ainsi la charge sur plusieurs requêtes.

Le verrou doit toujours avoir une expiration, sinon un worker qui plante laissera la clé verrouillée indéfiniment.

Quoi mettre en cache et quoi ne jamais mettre en cache

Mettez en cache les données dont la production est coûteuse, qui sont lues beaucoup plus souvent qu’elles ne changent, qui tolèrent une légère obsolescence et qui sont partagées entre de nombreux lecteurs. Les listes de produits, les profils publics, la configuration, les fragments rendus et les agrégats coûteux sont tous d’excellents candidats.

Ne mettez jamais en cache :

  • Les décisions d’autorisation dans un cache partagé. Un changement de rôle ou une déconnexion doit prendre effet immédiatement, et un CDN ne doit jamais servir la réponse privée d’un utilisateur à un autre.
  • Les données sensibles par utilisateur sous une clé partagée. Chaque valeur spécifique à un utilisateur nécessite une clé incluant l’utilisateur, ainsi qu’une directive de cache private si elle atteint un edge.
  • Les secrets et les identifiants. Un cache est un endroit supplémentaire d’où ils peuvent fuiter.
  • Les données que vous ne pouvez pas invalider. Si une valeur n’a ni clé naturelle ni événement associé, le cache finira par servir une donnée erronée sans moyen de la corriger.

Un test utile : si vous ne pouvez pas décrire comment une valeur quitte le cache, ne l’y placez pas.

Mise en cache HTTP à l’edge

Le cache le moins coûteux est celui qui n’atteint jamais votre serveur. HTTP vous offre un contrôle précis à ce sujet.

Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=30
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Vary: Accept-Encoding

max-age définit la durée pendant laquelle le navigateur peut réutiliser la réponse. s-maxage outrepasse ce réglage pour les caches partagés tels qu’un CDN. stale-while-revalidate permet à l’edge de servir une copie obsolète pendant qu’il récupère une version fraîche en arrière-plan, ce qui évite les effets de stampede pour le contenu public. private et no-store empêchent totalement les réponses sensibles d’entrer dans les caches partagés.

Un ETag permet les requêtes conditionnelles. Le client envoie If-None-Match, et si le tag correspond toujours, vous répondez 304 Not Modified sans corps de message, économisant ainsi la bande passante tout en garantissant la fraîcheur des données.

app.get("/posts", async (req, res) => {
  const body = JSON.stringify(await listPosts());
  const tag = `"${createHash("sha256").update(body).digest("hex")}"`;

  res.set("Cache-Control", "public, max-age=30, stale-while-revalidate=60");
  res.set("ETag", tag);

  if (req.headers["if-none-match"] === tag) return res.status(304).end();
  res.type("application/json").send(body);
});

Vary est facile à oublier mais important : si une réponse dépend de Accept-Encoding ou Accept-Language, précisez-le, sinon un cache pourrait envoyer la mauvaise variante au mauvais client.

In-memory vs Redis vs CDN

Ces trois couches de partage ne sont pas concurrentes ; elles forment une hiérarchie.

Un cache in-process offre la recherche la plus rapide possible car elle ne traverse jamais le réseau. C’est la solution idéale pour des données volumétriques faibles, très sollicitées et principalement en lecture, comme une configuration ou une table de permissions. Ses limites sont les suivantes : chaque instance possède sa propre copie (ce qui multiplie la consommation mémoire et peut entraîner des divergences de valeurs) et le cache disparaît lors d’un déploiement.

Redis est le cache partagé de l’application. Une seule copie logique sert toutes les instances, elle survit aux redémarrages et supporte les TTL, les opérations atomiques ainsi que des structures de données allant au-delà des simples chaînes de caractères. Son coût est un aller-retour réseau, généralement bien inférieur à une milliseconde sur un même réseau.

Un CDN est la couche la plus externe. Il met en cache les réponses publiques au plus près des utilisateurs à travers le monde et peut absorber un trafic énorme avant qu’il n’atteigne vos serveurs. Il ne fonctionne que pour les réponses identiques pour de nombreux utilisateurs, et son vidage (purge) est une opération délibérée et parfois lente.

Une architecture de production courante consiste à placer un minuscule cache in-process devant Redis pour les clés les plus sollicitées, avec un CDN devant l’ensemble de l’API pour les requêtes GET publiques.

Mise en cache négative

On décrit généralement les caches comme des systèmes stockant des valeurs, mais stocker l’ absence d’une valeur est tout aussi utile. Si une requête pour un enregistrement manquant est coûteuse et répétée, mettez en cache cet échec (le “miss”).

const hit = await redis.get(key);
if (hit === "__miss__") return null;

const user = await db.user.findUnique({ where: { id } });
if (!user) {
  await redis.set(key, "__miss__", "EX", 60);
  return null;
}

Gardez des TTL négatifs courts, car un enregistrement qui n’existait pas il y a une minute peut exister maintenant. Ce modèle permet de se protéger contre la cache penetration, où un flux de requêtes pour des clés inexistantes contourne le cache et sollicite la base de données à chaque fois. Pour empêcher un attaquant de générer une infinité de clés manquantes uniques, validez les entrées et mettez en place un rate limiting avant le cache.

Cohérence et les deux défis majeurs

Un cache rend un système éventuellement cohérent (eventually consistent) : pendant un court laps de temps, différents lecteurs peuvent voir des valeurs différentes. C’est généralement acceptable, mais pas dans toutes les situations.

  • Read-your-writes. Un utilisateur qui vient de mettre à jour son profil s’attend à voir le changement. Invalidez le cache lors de l’écriture, ou lisez depuis la source pendant une courte période après une écriture effectuée par le même utilisateur.
  • Replication lag. Si les lectures sont dirigées vers une réplique, un cache alimenté par cette réplique peut être en retard par rapport au primaire. Invalidez le cache depuis le chemin d’écriture du primaire.
  • Caches multi-régions. Une purge dans une région n’atteint pas instantanément les autres. Utilisez des clés versionnées ou acceptez le délai de propagation.

Pour être honnête, la mise en cache consiste à sacrifier la cohérence au profit de la vitesse. La seule façon de le faire en toute sécurité est de décider explicitement quel niveau d’obsolescence est acceptable et d’intégrer l’invalidation directement dans le chemin d’écriture, plutôt que de l’ajouter après coup.

Les clés de cache sont une API

Une clé de cache ressemble à un détail d’implémentation, mais elle se comporte comme une interface publique. C’est l’élément sur lequel chaque lecteur et écrivain doit s’accorder, et elle est visible dans Redis, dans les slow logs et sur les tableaux de bord. Concevez vos clés de manière délibérée.

Trois règles permettent de garder le contrôle :

  • Utilisez des namespaces par version et environnement. v1:user:42 vous permet de modifier la structure plus tard et évite que l’environnement de staging ne partage des clés avec la production.
  • Soyez déterministe. La même recherche logique doit toujours produire la même chaîne de caractères. Normalisez la casse, nettoyez les entrées (trim) et n’incluez jamais de valeur qui change à chaque appel.
  • Ne placez jamais de secrets ou de données personnelles dans une clé. Les clés sont journalisées, exportées et visibles par les opérateurs.
export const cacheKeys = {
  user: (id: string) => `v1:user:${id}`,
  userPosts: (id: string, cursor: string) => `v1:user:${id}:posts:${cursor}`,
  productBySku: (sku: string) => `v1:product:sku:${sku.trim().toLowerCase()}`,
};

Centraliser la construction des clés dans un seul module est ce qui rend l’invalidation possible. Lorsqu’une écriture doit supprimer une clé, elle appelle la même fonction que celle utilisée pour la lecture ; lorsque la structure change, il n’y a qu’un seul endroit où incrémenter la version.

Politiques d’éviction

Un cache autorisé à croître sans limite n’est rien d’autre qu’une fuite de mémoire avec des étapes supplémentaires. Redis comme les caches in-process ont besoin d’un plafond et d’une politique pour déterminer quoi supprimer une fois ce plafond atteint.

Redis propose maxmemory et maxmemory-policy. Le choix courant pour un cache pur est allkeys-lru, qui évince la clé la moins récemment utilisée, ou allkeys-lfu, qui privilégie les clés fréquemment utilisées lorsque les schémas d’accès sont asymétriques.

redis-cli CONFIG SET maxmemory 2gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru

N’utilisez jamais noeviction pour un cache : une fois la mémoire pleine, les écritures échouent et votre application commence à lever des erreurs au lieu de simplement subir un cache miss. Pour les caches in-process, utilisez une bibliothèque LRU bornée plutôt qu’un simple Map, et définissez à la fois un nombre maximum d’entrées et une taille maximale.

Les évictions ne sont pas des erreurs, mais une augmentation du nombre d’évictions est un signal. Cela signifie que le working set ne tient plus en mémoire et que le hit rate est sur le point de chuter. Soit vous allouez plus de mémoire au cache, soit vous réduisez la quantité de données mises en cache.

Préchauffage du cache (Cache warming)

Un cache est au plus froid au moment où il est le plus vulnérable : juste après un déploiement, un redémarrage ou une extension d’échelle (scale-out). Chaque instance démarre vide, le trafic arrive à plein volume, et la source subit la charge totale jusqu’à ce que le cache se remplisse. C’est un effet de stampede causé par votre propre déploiement.

Deux approches fonctionnent. Le préchauffage paresseux (lazy warming) accepte cette fenêtre de froid et s’appuie sur le verrouillage (locking) et le stale-while-revalidate pour y survivre. C’est simple et ne nécessite aucun mécanisme supplémentaire. Le préchauffage proactif (proactive warming) exécute une tâche qui charge les clés connues comme étant “chaudes” avant que la nouvelle version ne reçoive le trafic.

async function warmCache() {
  const popular = await db.product.findMany({
    orderBy: { views: "desc" },
    take: 500,
    select: { id: true },
  });

  for (const { id } of popular) {
    await cached(`product:${id}`, 300, () =>
      db.product.findUniqueOrThrow({ where: { id } }),
    );
  }
}

Ne préchauffez que ce que vous savez être fréquemment utilisé. Vouloir tout préchauffer revient simplement à créer une copie lente et coûteuse de la base de données, dont la majeure partie sera évincée sans jamais avoir été utilisée.

Observabilité des caches

Un cache que vous ne mesurez pas est un cache auquel vous ne pouvez pas faire confiance. Quatre indicateurs suffisent à faire le tour de la question.

  • Hit rate (taux de succès) — les lectures sont-elles réellement servies par le cache ?
  • Latency (latence) — un hit est-il significativement moins coûteux que la source, en incluant le saut réseau ?
  • Evictions — l’ensemble de travail (working set) dépasse-t-il la mémoire disponible ?
  • Erreurs et timeouts — le cache devient-il une source de pannes ?
redis-cli INFO stats | grep -E 'keyspace_(hits|misses)|evicted_keys'
redis-cli INFO memory | grep used_memory_human

Émettez également un compteur pour les hits et les misses depuis l’application, tagué par nom de cache. C’est ce qui vous permet de corréler une chute du hit-rate avec un déploiement, et c’est la première chose à vérifier lorsque la charge de la base de données augmente sans raison apparente.

Dégradation progressive en cas de panne du cache

Si la perte du cache entraîne l’arrêt de l’API, le cache est devenu un point de défaillance unique (single point of failure) pour un système dont l’objectif même est d’être optionnel. La source de vérité existe toujours ; l’application doit être capable de l’atteindre.

Encapsulez chaque opération de cache afin qu’un échec soit traité comme un miss plutôt que comme une erreur, maintenez des timeouts courts, et envisagez l’utilisation d’un circuit breaker pour arrêter d’appeler un cache défaillant pendant une période de refroidissement.

async function safeGet(key: string) {
  try {
    return await redis.get(key);
  } catch (err) {
    logger.warn({ err, key }, "cache_read_failed");
    return null;
  }
}

La même logique s’applique aux écritures dans le cache : un set échoué ne doit jamais faire échouer la requête. La seule exception est le write-behind, où le cache fait partie du chemin d’écriture ; c’est une raison supplémentaire pour le réserver aux données que vous pouvez vous permettre de perdre.

Mise en cache des requêtes et des agrégats coûteux

Tout ce qui mérite d’être mis en cache n’est pas forcément une ligne unique. Les jointures coûteuses, les agrégats de tableaux de bord et les résultats de recherche sont souvent les meilleurs candidats, car ils sont lents à calculer et évoluent peu fréquemment.

Une vue matérialisée est un cache qui réside dans la base de données : elle stocke le résultat d’une requête et est rafraîchie selon un planning ou à la demande. Elle vous offre la fraîcheur d’un traitement batch et la vitesse de lecture d’une table.

CREATE MATERIALIZED VIEW daily_revenue AS
SELECT date_trunc('day', created_at) AS day,
       sum(total_cents)             AS revenue_cents
FROM orders
WHERE status = 'paid'
GROUP BY 1;

REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;

Pour les résultats trop dynamiques pour une vue, mettez en cache la réponse sérialisée dans Redis sous une clé incluant chaque entrée qui l’influence — filtres, ordre de tri et page. Deux requêtes pour des pages différentes correspondent à des valeurs différentes, elles doivent donc avoir des clés distinctes.

Tester un cache

Les bugs de cache sont le genre de problèmes qui passent les tests mais échouent en production. Testez donc explicitement les comportements critiques : le chemin en cas de miss, le chemin en cas de hit, l’invalidation et les pannes.

test("loads once and serves the second read from cache", async () => {
  let calls = 0;
  const load = async () => {
    calls += 1;
    return { id: "1" };
  };

  await cached("user:1", 60, load);
  await cached("user:1", 60, load);

  expect(calls).toBe(1);
});

test("falls back to the source when the cache is unavailable", async () => {
  redis.get = async () => {
    throw new Error("connection_refused");
  };

  await expect(cached("user:1", 60, load)).resolves.toEqual({ id: "1" });
});

Utilisez un vrai Redis dans un container pour vos tests d’intégration, ou un faux en mémoire qui implémente get, set et del. Testez également les cas négatifs : une mise à jour qui devrait invalider le cache, un TTL qui devrait expirer, et une panne du cache qui devrait entraîner une dégradation du service plutôt qu’un crash complet.

Bonnes pratiques

  • Ne mettez en cache qu’après avoir mesuré ; ajoutez un cache pour une lecture lente et répétée, et non par défaut.
  • Attribuez un TTL à chaque clé, même lorsque vous effectuez également une invalidation explicite.
  • Utilisez des clés déterministes et namespacées telles que user:42:profile, et versionnez-les pour les données partagées.
  • Préférez la suppression ou le versionnage des clés plutôt que leur écrasement lors d’une mise à jour.
  • Ajoutez du jitter aux TTL et utilisez le stale-while-revalidate pour survivre aux stampedes.
  • Gardez les données par utilisateur et d’autorisation hors des caches partagés ; marquez les réponses private.
  • Traitez le cache comme optionnel dans le code afin qu’une panne entraîne une dégradation du service plutôt qu’un échec complet.
  • Surveillez le hit rate et le nombre d’evictions, pas seulement la latence.
  • Mettez en cache les misses ainsi que les hits lorsque la recherche de données absentes est coûteuse.

Erreurs courantes

  • Mettre en cache sans TTL et compter sur une purge manuelle qui n’a jamais lieu.
  • Partager une seule clé entre plusieurs utilisateurs et ainsi divulguer les données d’une personne à une autre.
  • Invalider le cache dans certains flux d’écriture, mais pas dans d’autres.
  • Définir le même TTL pour toutes les clés, provoquant leur expiration simultanée.
  • Mettre en cache une décision d’autorisation qui aurait dû être révoquée suite à un changement de rôle.
  • Utiliser un cache comme base de données et perdre des écritures lors d’un redémarrage.
  • Oublier Vary et servir un encodage de contenu erroné depuis un CDN.
  • Ne rien mesurer, faisant ainsi chuter le taux de hit de manière invisible après un changement de clé.
  • Mettre en cache un élément unique par requête, ce qui garantit un taux de hit de 0 %.

Et après ?

Le caching est un outil pour contrôler la charge ; ses compagnons naturels sont les autres mécanismes qui limitent le travail effectué. Redis approfondit le fonctionnement du store sur lequel la plupart des caches sont bâtis, et la Pagination est la version au niveau de la requête de cette même idée : ne jamais effectuer plus de travail que nécessaire. Si la base de données derrière le cache est le goulot d’étranglement, le Connection Pooling réduit le coût de chaque connexion, et PostgreSQL couvre la source de vérité que vous protégez.

En pratique

Quatre implémentations de cache courantes

Le helper, le chemin d'écriture, la protection contre le stampede et l'edge HTTP.

lib/cache.ts
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

export async function cached<T>(
  key: string,
  ttlSeconds: number,
  load: () => Promise<T>,
): Promise<T> {
  const hit = await redis.get(key);
  if (hit !== null) return JSON.parse(hit) as T;

  const value = await load();
  await redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
  return value;
}

// const product = await cached(`product:${id}`, 300, () =>
//   db.product.findUniqueOrThrow({ where: { id } }),
// );

Cache-aside vs write-through

Le cache-aside ne remplit le cache que lors d'une lecture, donc les données inutilisées n'occupent jamais de mémoire. Le write-through garde le cache 'chaud' mais impacte chaque écriture.

Cache-aside
// On read: miss, load, then populate.
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);

const value = await load();
await redis.set(key, JSON.stringify(value), "EX", 300);
return value;
Write-through
// On write: update the store and the cache together.
const value = await db.product.update({ where: { id }, data: patch });
await redis.set(key, JSON.stringify(value), "EX", 300);
return value;

TTL seul vs invalidation explicite

Un TTL seul est simple et s'auto-guérit, mais un lecteur peut voir des données obsolètes pendant toute la fenêtre. L'invalidation explicite est plus fraîche mais plus complexe à implémenter sans erreur.

TTL seul
// Stale for at most five minutes, then it heals itself.
await redis.set(key, JSON.stringify(value), "EX", 300);

// No update path to maintain, and a crashed
// writer cannot leave a permanently wrong value.
Suppression explicite
// Fresher, but every write path must remember to do this.
await db.product.update({ where: { id }, data: patch });
await redis.del(`product:${id}`);

// Forget one call site and the cache lies forever.

Compromis

Le caching vaut-il sa complexité ?

Un cache est un système supplémentaire avec ses propres modes de panne. Ajoutez-le lorsque les mesures justifient l'ajout de composants mobiles.

Strengths

  • Gains de latence spectaculaires

    Passer d'une lecture sur base de données (disque) à une lecture en mémoire peut transformer une requête de 20 ms en une recherche de 0,2 ms, et cela s'accumule sur toute une page.

  • Protection sous charge

    Un cache absorbe les pics de trafic qui, autrement, s'accumuleraient dans la base de données, ce qui fait souvent la différence entre un service ralenti et un service hors ligne.

  • Réductions de coûts réelles

    Moins de lectures en base de données signifient des instances plus petites, moins de réplicas et moins de trafic sortant, ce qui se traduit directement sur la facture.

Trade-offs

  • L'invalidation est réellement difficile

    Un cache peut servir des données qui n'existent plus ou masquer des données existantes. Chaque chemin d'écriture devient un endroit potentiel pour faire une erreur.

  • Un second domaine de panne

    Le cache peut tomber, saturer ou retourner des valeurs corrompues. Le code doit pouvoir se rabattre sur la source et traiter le cache comme optionnel.

  • Les lectures obsolètes surprennent les utilisateurs

    Les utilisateurs s'attendent à voir leurs propres modifications immédiatement. Sans une invalidation rigoureuse, un profil ou un panier mis en cache ressemblera à un bug.

FAQ

Foire aux questions

Keep learning

Related topics from the roadmap.

$ commencer à apprendre

Prêt à apprendre Caching Strategies ?

Notre tutoriel interactif vous guide à travers Caching Strategies pas à pas — avec des quiz et du vrai code que vous pouvez exécuter dans le navigateur.