L'épicerie du coin et l'entrepôt de quartier

Dans La Ville, personne ne traverse trois arrondissements pour un litre de lait. Il y a l'épicerie en bas de l'immeuble : un stock minuscule, réapprovisionné souvent, qui couvre l'essentiel des courses du quotidien.

Et il y a l'entrepôt de quartier, à la sortie de la rocade. Lui ne sert pas les habitants un par un. Il sert les commerces, et il évite au dépôt central de faire mille tournées par jour.

Deux étages, deux métiers. L'épicerie rapproche l'article du client, l'entrepôt rapproche la palette du quartier. Aucun des deux ne fabrique quoi que ce soit, et c'est précisément leur intérêt : ils n'ont qu'un stock, jamais la vérité.

Un système en charge a exactement ces deux étages. Le cache distribué se place devant la base de données, dans le centre de données. Le réseau de distribution de contenu, le CDN, se place devant l'application entière, en périphérie, à quelques millisecondes du navigateur.

Mettre en cache est facile. Savoir quand vider est le vrai sujet, et c'est là que ce billet passe l'essentiel de son temps.

Vue d'ensemble : deux étages devant la même base

Le principe du cache, ce qui mérite d'être caché et le choix entre cache local et cache distribué ont déjà été posés dans Couplage lâche, stateless et cache. Rappel en deux lignes : on cache ce qui est lu souvent et écrit rarement, et un cache local au processus diverge dès qu'il y a plus d'une instance. On repart de là.

Voici le chemin complet d'une requête, avec ses deux points d'arrêt possibles.

flowchart LR U["Navigateur"] --> PoP["Point de présence CDN
étage 1"] PoP -->|"échec"| LB["Répartiteur de charge"] LB --> APP["Instances applicatives"] APP --> C["Cache distribué
étage 2"] C -->|"échec"| DB["Base de données"] PoP -.->|"réussite"| U C -.->|"réussite"| APP classDef edge fill:#b5651d,stroke:#8a4d16,color:#f5f2ec; classDef mid fill:#d89253,stroke:#8a4d16,color:#1a1a1a; classDef core fill:#ede9e1,stroke:#8a4d16,color:#1a1a1a; class PoP,C edge; class LB,APP mid; class U,DB core;

Les deux étages ne stockent pas la même chose et ne s'invalident pas de la même façon.

Étage Ce qu'il conserve Où il vit Qui décide de l'expiration
CDN Des réponses HTTP entières : images, feuilles de style, pages, fragments d'API Dans des dizaines de points de présence, au plus près du lecteur Les en-têtes HTTP envoyés par l'origine, ou une purge déclenchée à la main
Cache distribué Des objets applicatifs : résultat d'une requête, profil, agrégat, compteur Dans le centre de données, à côté des instances Le code applicatif, ou la durée de vie posée sur la clé

Reste à comprendre pourquoi cet étagement change quelque chose. Prenons un service qui reçoit 10 000 requêtes de lecture par seconde, et posons le calcul.

Trafic en lecture                : 10 000 requêtes/s

Taux de réussite du cache        :     95 %
Requêtes atteignant la base      : 10 000 x 0,05 =   500 req/s

Le même trafic, cinq points de moins :
Taux de réussite du cache        :     90 %
Requêtes atteignant la base      : 10 000 x 0,10 = 1 000 req/s

Cinq points de taux de réussite en moins, et la base prend deux fois plus de charge. La même chose sur la latence, avec une lecture en cache à 1 ms et une lecture en base à 40 ms :

À 95 % : 0,95 x 1 + 0,05 x 40 = 0,95 + 2,00 = 2,95 ms
À 90 % : 0,90 x 1 + 0,10 x 40 = 0,90 + 4,00 = 4,90 ms
Écart  : 4,90 / 2,95 = 1,66, soit 66 % de latence moyenne en plus
Un cache ne se dimensionne pas sur son taux de réussite, mais sur son taux d'échec. C'est lui, et lui seul, qui décide de la charge que la base va prendre.

Les cinq patterns d'accès

À l'épicerie, deux organisations coexistent. Soit le client demande un article absent, l'épicier va le chercher au dépôt et le client attend. Soit l'épicier tient son réassort lui-même, et le client ignore d'où vient l'article.

Cinq patterns couvrent le sujet. Deux portent sur la lecture, trois sur l'écriture, et ils se combinent.

Cache-aside

L'application parle aux deux, au cache et à la base. Elle cherche dans le cache, et en cas d'échec elle interroge la base puis remplit le cache elle-même.

def get_article(article_id):
    key = f"article:{article_id}"
    value = cache.get(key)
    if value is not None:
        return value                       # réussite
    value = db.find_article(article_id)    # échec, on va en base
    if value is not None:
        cache.set(key, value, ttl=600)
    return value

C'est le pattern par défaut, et pour de bonnes raisons. Le cache ne contient que ce qui a été demandé au moins une fois, et si le cache tombe, le service continue de fonctionner, plus lentement. Son défaut : la logique se répète chez chaque appelant, et celui qui l'oublie sert une donnée périmée sans que personne le remarque.

Read-through

Même chemin de lecture, mais l'application ne parle qu'au cache. C'est le cache qui sait aller chercher la donnée, via un chargeur déclaré une fois pour toutes, à la manière du LoadingCache de Caffeine côté Java.

Le bénéfice est organisationnel : une seule implémentation de la lecture, pour tous les appelants. Le prix aussi : le cache devient une dépendance obligatoire du chemin critique, alors qu'en cache-aside il restait un accélérateur optionnel.

Write-through

Chaque écriture traverse le cache puis la base, de façon synchrone, avant que le client reçoive sa confirmation. Le cache n'est jamais en retard sur la base.

On paie deux choses. La latence d'écriture est la somme des deux, et le cache se remplit de données qui ne seront peut-être jamais relues. Ça vaut le coup quand la donnée est systématiquement relue dans la foulée de son écriture, beaucoup moins sinon.

Write-behind

L'écriture s'arrête au cache, le client est confirmé tout de suite, et la base est servie plus tard, souvent par lots. Le débit d'écriture devient excellent et les pics s'absorbent tout seuls.

Le risque est frontal : ce qui a été confirmé au client mais pas encore versé en base disparaît si le nœud de cache meurt. On ne choisit ce pattern que sur une donnée dont on a écrit noir sur blanc qu'on accepte de la perdre. Compteurs de vues, mesures d'usage, journaux applicatifs. Jamais une commande.

Write-around

L'écriture va droit en base sans toucher au cache. L'entrée sera créée à la prochaine lecture, ou pas du tout si personne ne relit.

C'est la parade au cache pollué : dans un système qui écrit énormément et relit peu, remplir le cache à chaque écriture revient à chasser les données utiles pour faire de la place à des données mortes.

Pattern Qui écrit dans le cache Latence d'écriture Risque de perte Cas d'usage
Cache-aside L'application, après un échec de lecture Inchangée Aucun, la base reste la référence Le cas général, trafic dominé par la lecture
Read-through Le cache, via son chargeur Inchangée Aucun Beaucoup d'appelants pour la même donnée
Write-through L'application, à chaque écriture Cache plus base Aucun Donnée relue juste après avoir été écrite
Write-behind L'application, la base suit en différé Cache seul Réel, tout ce qui n'est pas encore versé Compteurs et mesures, perte tolérée
Write-around Personne, l'entrée naîtra à la lecture Base seule Aucun Écritures massives, relectures rares
Cache-aside en lecture avec write-around en écriture couvre la grande majorité des systèmes. Les autres combinaisons se justifient au cas par cas, jamais par principe.

L'invalidation : borner, chasser, corriger

L'épicerie a trois façons de sortir un article du rayon. La date limite imprimée sur l'emballage. La place qui manque, quand la nouveauté arrive. Et le rappel produit, quand le fabricant téléphone à midi.

Le cache aussi, et les trois mécanismes ne se remplacent pas.

La durée de vie

Chaque entrée porte une date d'expiration. C'est le mécanisme le plus simple et le seul qui se répare tout seul : une invalidation oubliée quelque part dans le code finit par se corriger au bout d'un TTL.

# Poser une valeur avec une durée de vie de 10 minutes
redis-cli SET "article:42" '{"titre":"Le pont de la crue","prix":1990}' EX 600
# L'invalider explicitement quand l'article change
redis-cli DEL "article:42"

Le TTL se choisit sur la péremption tolérable, pas sur une habitude. Un libellé de rubrique supporte une heure. Un compteur de stock affiché en page produit, quelques secondes. Écrire ce raisonnement dans le code, à côté du nombre, évite qu'un successeur le prenne pour une valeur magique.

L'éviction

L'éviction n'est pas l'expiration. L'expiration est prévue et datée ; l'éviction survient parce que la mémoire est pleine, à un moment que personne n'a choisi. Une clé peut donc disparaître bien avant son TTL, et le code doit toujours le supporter.

Politique Ce qu'elle sort en premier Bon terrain Ce qui la met en échec
LRU L'entrée dont la dernière lecture est la plus ancienne Accès avec forte localité temporelle Un balayage complet qui lit une fois chaque clé et chasse tout le contenu chaud
LFU L'entrée la moins souvent lue Un petit noyau de clés très demandées et stable Un contenu populaire hier, difficile à déloger sans vieillissement du compteur
FIFO La plus anciennement écrite, quel que soit son usage Contenu à durée de vie homogène Ignore complètement la popularité
Aléatoire Une entrée au hasard Coût constant, aucune métadonnée à tenir Aucune garantie, résultat très variable
Par durée de vie restante L'entrée qui allait expirer le plus tôt Cache mixte où une partie seulement porte un TTL Ne regarde pas l'usage réel

Redis expose ce choix sous le nom maxmemory-policy, avec des variantes qui portent soit sur toutes les clés, soit uniquement sur celles qui ont une expiration. Memcached, lui, repose sur une LRU. Le point à retenir vaut pour les deux : sans limite mémoire configurée, le cache grossit jusqu'à ce que le système d'exploitation tranche à sa place, et il tranche mal.

L'invalidation explicite

Le chemin d'écriture supprime la clé. Exact, immédiat, et fragile : il faut que tous les chemins d'écriture y pensent, y compris le script de reprise lancé une fois par trimestre.

Deux règles simplifient la vie. Supprimer plutôt que mettre à jour, parce qu'une mise à jour ouvre une fenêtre où un lecteur concurrent réécrit une valeur plus ancienne. Et supprimer après avoir écrit en base, jamais avant, sinon un lecteur recharge l'ancienne valeur dans l'intervalle.

Reste le problème des instances multiples. Si chaque instance garde son propre cache en mémoire, l'invalidation ne touche que celle qui a reçu l'écriture, et les autres continuent de servir l'ancienne valeur. Le cache distribué règle ça par construction, une seule copie, une seule suppression. Si on tient au cache local pour la latence, il faut diffuser l'invalidation par un canal de publication et abonnement, et accepter la fenêtre de propagation.

Le TTL borne combien de temps on accepte de mentir. L'éviction gère la place. L'invalidation corrige tout de suite. Un cache sérieux utilise les trois, et sait lequel joue quand.

Les trois pannes propres au cache

L'épicerie a ses jours noirs. Celui de la réouverture après travaux, rayons vides et cent clients devant la porte. Celui où toute la ville veut le même article. Celui où tout le stock atteint sa date limite le même matin.

La ruée sur le cache froid

Une clé très demandée expire. Toutes les requêtes qui arrivent pendant sa reconstruction constatent l'échec, et partent en base chercher exactement la même valeur.

Trafic sur la clé              : 5 000 requêtes/s
Temps de reconstruction        :   200 ms
Requêtes parties en base       : 5 000 x 0,2 = 1 000 requêtes concurrentes
                                 pour une seule et même valeur

Trois parades, cumulables. Le verrou de reconstruction : un seul appelant obtient le droit de recalculer, les autres attendent ou servent la valeur précédente.

# Un seul appelant obtient le verrou, pour 5 secondes au maximum
redis-cli SET "lock:article:42" "instance-3" NX EX 5

Le recalcul anticipé : renouveler l'entrée un peu avant son expiration, avec une probabilité qui croît à mesure qu'on s'approche de la date limite, de sorte qu'un seul lecteur paie le rafraîchissement. Et le service de la valeur périmée pendant le rafraîchissement, que HTTP normalise sous le nom stale-while-revalidate.

La clé chaude

Un cache distribué est partitionné : une clé vit sur un nœud, et un seul. Le jour où un article devient viral, ou qu'un drapeau de fonctionnalité est lu à chaque requête, ce nœud sature pendant que les autres dorment. Ajouter des nœuds n'y change rien, la clé ne se déplace pas.

Trois sorties. Une réplique locale au processus pour cette clé seulement, avec une durée de vie très courte, en assumant qu'on réintroduit de l'état. L'éclatement de la clé en article:42#0 à article:42#9, le lecteur tirant un suffixe au hasard. Ou la remontée d'un cran : si la donnée est identique pour tout le monde, elle a sa place au CDN, pas dans le cache applicatif.

L'avalanche

Cent mille clés chargées au même instant, après un déploiement ou un préchauffage, avec le même TTL. Elles expirent toutes dans la même seconde.

100 000 clés chargées ensemble, TTL fixe de 600 s
  -> 100 000 expirations dans la même seconde, toutes les 10 minutes

Le même TTL, bruité de plus ou moins 10 % :
  fenêtre = 540 s à 660 s, soit 120 s d'étalement
  100 000 / 120 = 833 expirations par seconde en moyenne

Un simple bruit aléatoire sur le TTL transforme un mur en pente. C'est trois lignes de code, et c'est la mesure la plus rentable de tout ce billet.

Panne Déclencheur Ce qu'on observe Parade
Ruée sur le cache froid Expiration ou suppression d'une entrée très demandée Un pic de requêtes identiques en base, en quelques centaines de millisecondes Verrou de reconstruction, recalcul anticipé, service de la valeur périmée
Clé chaude Une clé concentre l'essentiel du trafic Un nœud de cache saturé pendant que les autres sont oisifs Réplique locale à TTL court, éclatement de la clé, remontée au CDN
Avalanche Un lot de clés chargé ensemble avec le même TTL Chute brutale et périodique du taux de réussite TTL bruité, préchauffage étalé, limitation de débit devant la base
Vues depuis la base, ces trois pannes ont le même symptôme : un pic de charge que rien dans le trafic utilisateur n'explique. Le trafic n'a pas bougé. C'est la couche qui l'absorbait qui vient de s'ouvrir.

L'étage du dessus : le CDN

L'entrepôt de quartier ne connaît pas les habitants. Il connaît les palettes et le dépôt central. Toute sa valeur tient à sa position : à la sortie de la rocade, et pas à trois cents kilomètres.

C'est exactement le sujet du CDN : la distance relève de la physique, et aucun code ne la négocie.

Paris vers la Virginie du Nord, environ 6 200 km à vol d'oiseau
Aller-retour                     : 12 400 km
Vitesse de la lumière en fibre   : environ 200 000 km/s
Plancher théorique               : 12 400 / 200 000 = 0,062 s = 62 ms

Un point de présence à 100 km du lecteur :
Aller-retour                     : 200 km
Plancher théorique               : 200 / 200 000 = 0,001 s = 1 ms

À ce plancher s'ajoutent le routage, les files d'attente et la poignée de main TLS. On ne l'atteint jamais, on ne descend jamais dessous. Servir depuis la périphérie supprime des kilomètres ; le code, lui, tourne exactement à la même vitesse.

Le chemin d'une requête

Un CDN moderne est souvent hiérarchique : un point de présence proche du lecteur, et derrière lui un cache bouclier qui concentre les échecs de toute une région avant d'aller déranger l'origine.

sequenceDiagram participant N as Navigateur participant P as Point de présence participant B as Cache bouclier participant O as Origine N->>P: "GET /article/42" P->>B: "Échec local, requête vers le bouclier" B->>O: "Échec bouclier, requête vers l'origine" O-->>B: "200 OK, fraîcheur 600 s pour les caches partagés" B-->>P: "Réponse, conservée au bouclier" P-->>N: "Réponse, conservée au point de présence" N->>P: "Second lecteur, même URL" P-->>N: "Réussite, servi sans quitter le point de présence" Note over B,O: "Le bouclier absorbe les échecs de toute une région"

Ce qui pilote le CDN

Le cache applicatif se programme ; le CDN, lui, se pilote en HTTP. Les règles sont normalisées par la RFC 9111, complétée par la RFC 5861 pour le service de contenu périmé.

Directive ou en-tête Ce qu'elle décide Le piège courant
Cache-Control: max-age=N La fraîcheur pour tous les caches, navigateur compris Une valeur longue sur une page qu'on voudra corriger : le navigateur ne redemandera rien
Cache-Control: s-maxage=N La fraîcheur pour les caches partagés seulement Oublier que le navigateur, lui, suit max-age
Cache-Control: private Réponse propre à un utilisateur, interdite au cache partagé Absente sur une page personnalisée, et le CDN sert la page d'un utilisateur à un autre
Cache-Control: no-store Rien n'est conservé, nulle part Confondu avec no-cache, qui autorise le stockage et impose la revalidation
ETag et If-None-Match La revalidation : l'origine répond 304 sans renvoyer le corps Un ETag recalculé à chaque déploiement, qui annule le bénéfice
Vary Les en-têtes de requête qui distinguent deux variantes Vary: User-Agent fragmente le cache en autant de variantes que de navigateurs
stale-while-revalidate=N Autorise à servir du périmé pendant le rafraîchissement Prise en charge inégale, à vérifier chez son fournisseur

La ligne private mérite qu'on s'y arrête. C'est la faute la plus coûteuse de la liste, parce qu'elle ne se manifeste pas par une lenteur mais par une fuite : une page personnalisée mise en cache partagé finit servie à un autre lecteur.

Purger

Trois granularités existent chez la plupart des fournisseurs : par URL, par étiquette posée sur un ensemble de réponses, et globale.

La purge globale est un outil dangereux. Elle vide toute la périphérie d'un coup, et l'origine reçoit dans la seconde la totalité d'un trafic qu'elle n'a jamais servi. C'est une ruée sur le cache froid, déclenchée volontairement, à l'échelle du site entier.

Un CDN ne rend pas une application rapide, il la rend proche. La question à se poser avant de l'installer n'est pas « combien de latence en moins », mais « que se passe-t-il le jour où il s'arrête ».

Ce qu'on ne met jamais en cache

Il y a ce qu'aucune épicerie ne met en rayon : ce qui périme en une heure, ce qui n'intéresse qu'un seul client, ce qui engage juridiquement.

Donnée Pourquoi pas Ce qu'on fait à la place
Un solde, un stock au moment du paiement, un prix avant validation Une valeur périmée se transforme en litige commercial Lecture directe, ou cache d'affichage avec vérification à la validation
Une réponse personnalisée servie par un cache partagé Elle sera rendue à quelqu'un d'autre Cache-Control: private, ou une clé de cache qui inclut l'identité
Un jeton, un secret, une donnée sensible Le cache devient un magasin de plus à protéger, à chiffrer et à purger Rien en cache, ou durée de vie très courte et périmètre restreint
Une donnée lue une seule fois Aucun gain, et de la place prise à ce qui sert Write-around, on ne crée pas d'entrée
Une donnée qui change plus vite que son TTL utile Elle passe son temps à être invalidée, le coût dépasse le gain Accepter la lecture directe, ou revoir le modèle de données

Un dernier garde-fou, plus structurel : un cache reste un accélérateur, pas une base. Dès que sa disparition casse une fonctionnalité au lieu de la ralentir, on a affaire à un magasin sans durabilité qui s'ignore.

Le test tient en une question. Si le cache disparaît entièrement à midi, que perd-on ? Si la réponse contient autre chose que de la performance pendant quelques minutes, le découpage est à revoir.

Scénario B : la panne Fastly du 8 juin 2021

Le 8 juin 2021 au matin, de nombreux sites parmi les plus fréquentés du web se sont mis à renvoyer des erreurs en même temps. Le point commun de ces sites n'était ni leur langage, ni leur hébergeur, ni leur base de données : c'était leur fournisseur de CDN.

Fastly a publié le jour même un résumé de l'incident, signé par son responsable de l'ingénierie et de l'infrastructure. Le texte est court, et chacune de ses phrases est utile.

Le déroulé, tel que Fastly le décrit. Un déploiement logiciel entamé le 12 mai introduit un défaut susceptible d'être déclenché par une configuration client précise, dans des circonstances précises. Le défaut reste latent pendant près d'un mois, sans que rien ne le signale.

Le 8 juin, un client pousse un changement de configuration parfaitement valide, qui réunit ces circonstances. Le défaut se déclenche, et 85 % du réseau se met à renvoyer des erreurs.

La suite est la partie encourageante du rapport. Fastly indique avoir détecté la perturbation en moins d'une minute, puis identifié et isolé la cause, et désactivé la configuration en question. En 49 minutes, 95 % du réseau fonctionnait de nouveau normalement. Le correctif du défaut lui-même a ensuite été déployé sur l'ensemble du réseau.

Fastly écrit aussi, sans détour, que cette panne aurait dû être anticipée, et annonce deux chantiers : comprendre pourquoi le défaut n'a pas été détecté par ses processus de test et de qualité logicielle, et réduire son délai de remise en service.

Trois enseignements se transposent directement.

Une couche périphérique mutualisée est un point de passage unique. Ce qui y est déployé s'applique à tout le monde en même temps, et le rayon de souffle d'un défaut y est maximal par construction.

Un déclencheur légitime suffit. Personne n'a fait d'erreur le 8 juin. Le client a poussé une configuration valide. Le défaut dormait depuis presque un mois, et rien dans les tests ne l'avait réveillé.

Le chiffre qui compte n'est pas le taux de panne, c'est le délai de retrait. Une minute pour détecter, quarante-neuf pour rétablir l'essentiel. C'est ce couple qu'on cherche à améliorer, plutôt que de miser sur une couche qui ne tomberait jamais.

Reste la question à se poser chez soi, et elle se calcule avec les chiffres du début de ce billet.

Trafic total                     : 10 000 requêtes/s
Taux de réussite en périphérie   :     95 %
Charge normale de l'origine      : 10 000 x 0,05 =    500 req/s
Périphérie hors service          : 10 000 x 1,00 = 10 000 req/s
Facteur de multiplication        : 10 000 / 500  =     20
Plus le CDN est efficace, plus la chute est brutale. Un taux de réussite de 95 % signifie que l'origine n'a jamais servi que le vingtième de son trafic, et qu'elle le découvrira en une seconde le jour où la périphérie s'efface.

Points clés

  • Deux étages, deux métiers : le cache distribué garde des objets applicatifs devant la base, le CDN garde des réponses HTTP entières devant l'application, au plus près du lecteur.
  • Un cache se dimensionne sur son taux d'échec : cinq points de taux de réussite en moins doublent la charge que la base encaisse.
  • Cache-aside avec write-around couvre la majorité des systèmes ; write-behind ne se choisit que sur une donnée dont on a écrit qu'on accepte de la perdre.
  • TTL, éviction et invalidation explicite se complètent au lieu de se remplacer, et un TTL bruité est la parade la plus rentable contre l'avalanche.
  • La panne Fastly du 8 juin 2021 rappelle qu'une couche périphérique mutualisée protège tout le monde et tombe pour tout le monde : la vraie mesure est le délai de retrait, une minute pour détecter, quarante-neuf pour rétablir.

Dans la série

Palier 2 : Faire circuler. Domaine : Trafic.


Pour aller plus loin