data / redis
Redis comme cache : cache-aside et write-through
Explication
Ce que vous allez apprendre
- Implémenter le pattern cache-aside, le plus utilisé en production
- Comparer cache-aside et write-through selon leurs compromis latence/cohérence
- Choisir un TTL adapté et éviter les expirations synchronisées
- Reconnaître et prévenir le "cache stampede" avec un lock distribué
- Invalider correctement un cache après une mise à jour en base
Dans quel contexte ?
Une application de fiches produits reçoit 5000 requêtes par seconde en lecture, mais seulement une dizaine de mises à jour par minute. Interroger PostgreSQL à chaque requête pour une donnée qui change si rarement est un gaspillage évident de ressources. Redis, placé devant la base de données comme cache, permet d'absorber l'immense majorité de ces lectures sans jamais toucher la base tant que la donnée n'a pas changé.
D'abord, le pattern le plus répandu : cache-aside
Dans ce pattern, c'est l'application elle-même qui pilote le cache, étape par étape. Elle regarde d'abord dans Redis (GET) ; si la donnée est présente (hit), elle la retourne immédiatement sans jamais toucher la base. Si elle est absente (miss), l'application va chercher en base de données, puis prend soin de peupler le cache avant de répondre, avec un TTL (durée de vie) raisonnable.
Prérequis
Cette leçon suppose que tu es à l'aise avec les commandes de base GET/SET et la notion de TTL (EX), vues en début de parcours.
| Pattern | Qui écrit dans le cache | Avantage | Inconvénient |
|---|---|---|---|
| Cache-aside | L'application, à la demande (lazy) | Tolère une panne de cache (fallback DB) | Premier accès toujours un miss |
| Write-through | L'application, à chaque écriture | Cache toujours à jour | Latence d'écriture plus élevée |
Une fois cache-aside en place, une alternative existe : write-through
Le pattern write-through inverse la logique côté écriture : chaque modification passe par le cache et la base de données en même temps, de façon synchrone. L'avantage est que le cache ne contient jamais de donnée périmée puisqu'il est mis à jour au moment même de l'écriture — mais chaque écriture devient plus lente puisqu'elle doit toucher deux systèmes au lieu d'un seul.
Il reste un problème classique à anticiper : le cache stampede
Imagine qu'une clé très demandée expire exactement au moment où mille requêtes arrivent simultanément. Sans protection, ces mille requêtes constatent toutes un miss en même temps et tombent toutes sur la base de données au même instant, ce qui peut la faire chuter sous la charge — un effet domino appelé cache stampede ou thundering herd.
Piège courant
Ne jamais supposer qu'un cache protège automatiquement la base de données d'un pic de charge. Sans lock ni mécanisme de protection, un cache qui expire au mauvais moment peut au contraire précipiter une panne de la base sous-jacente.
La solution la plus courante consiste à acquérir un verrou distribué (SET ... NX EX) avant de recalculer la valeur : seul le processus qui obtient le verrou interroge la base, les autres attendent brièvement puis relisent le cache, désormais repeuplé.
Bonne pratique
Ajoute toujours un léger "jitter" aléatoire au TTL de tes clés de cache (par exemple 300 + random(0, 30) secondes). Sans cela, des milliers de clés posées au même instant expirent toutes ensemble, recréant artificiellement un pic de charge périodique et prévisible.
Après avoir maîtrisé ces patterns côté logique applicative, la prochaine leçon entre dans le détail pratique : comment utiliser concrètement redis-py en Python, avec un pool de connexions adapté à la production.
Commandes & code
Redis comme cache : cache-aside et write-through
import json
import redis
r = redis.Redis(decode_responses=True)
# --- Pattern CACHE-ASIDE (le plus courant) ---
# L'application gère explicitement le cache : lire cache -> si absent, lire DB -> écrire cache
def get_produit(produit_id: int) -> dict:
cle = f"produit:{produit_id}"
cache = r.get(cle)
if cache is not None:
return json.loads(cache) # HIT : retour immédiat, pas de requête DB
produit = requete_db_produit(produit_id) # MISS : on va chercher en base
r.set(cle, json.dumps(produit), ex=300) # on peuple le cache, TTL 5 minutes
return produit
def invalider_produit(produit_id: int) -> None:
r.delete(f"produit:{produit_id}") # à appeler après un UPDATE en DB
def requete_db_produit(produit_id: int) -> dict:
... # requête réelle vers PostgreSQL/MySQL/etc.
# --- Pattern WRITE-THROUGH ---
# Chaque écriture passe par le cache ET la DB en même temps, de façon synchrone
def maj_produit(produit_id: int, data: dict) -> None:
ecrire_db_produit(produit_id, data) # 1. écrit en base (source de vérité)
r.set(f"produit:{produit_id}", json.dumps(data), ex=300) # 2. met à jour le cache immédiatement
def ecrire_db_produit(produit_id: int, data: dict) -> None:
...
# --- Anti-pattern classique : cache stampede ---
# Si 1000 requêtes arrivent en même temps sur un cache expiré, 1000 requêtes tombent sur la DB
# Solution : lock distribué le temps du recalcul
def get_produit_sans_stampede(produit_id: int) -> dict:
cle = f"produit:{produit_id}"
cache = r.get(cle)
if cache is not None:
return json.loads(cache)
lock_acquis = r.set(f"lock:{cle}", "1", nx=True, ex=10)
if lock_acquis:
try:
produit = requete_db_produit(produit_id)
r.set(cle, json.dumps(produit), ex=300)
return produit
finally:
r.delete(f"lock:{cle}")
else:
import time
time.sleep(0.05)
return get_produit_sans_stampede(produit_id) # un autre process recalcule, on réessaie bientôt# Choix du TTL : compromis fraîcheur vs charge DB
# - TTL trop court -> peu de bénéfice cache, DB toujours sollicitée
# - TTL trop long -> données obsolètes affichées après une mise à jour non invalidée
# - Ajouter un "jitter" aléatoire au TTL évite que tout le cache expire au même instantRésumé
- Cache-aside : l'application lit/écrit le cache explicitement, tolère les pannes de cache (fallback DB).
- Write-through : le cache est toujours à jour car chaque écriture le traverse, au prix d'une latence d'écriture plus haute.
- Le "cache stampede" se prévient avec un lock le temps du recalcul, ou du early-refresh avant expiration.
Exercices pratiques
Mission : éviter que la base de données ne s'effondre pendant une vente flash
Objectif : Diagnostiquer un cache stampede sur une clé très demandée, implémenter un verrou de repopulation, et repérer les limites du write-through face à l'éviction mémoire.
Contexte
Une page produit très demandée est mise en cache avec un simple SET ... EX 300, sans verrou ni jitter. Pendant une vente flash, cette clé expire exactement au moment où des milliers de requêtes arrivent, menaçant de faire chuter la base de données sous la charge. Tu dois diagnostiquer ce risque, le corriger avec un verrou, puis évaluer les limites d'une migration vers write-through.