Retour au cours

data / redis

Redis comme cache : cache-aside et write-through

Leçon 121 exercice

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.

PatternQui écrit dans le cacheAvantageInconvénient
Cache-asideL'application, à la demande (lazy)Tolère une panne de cache (fallback DB)Premier accès toujours un miss
Write-throughL'application, à chaque écritureCache toujours à jourLatence 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

python
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
bash
# 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 instant

Ré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

1 disponible
1

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.

Résoudre l’exercice →