Développement Web

Patterns de cache avec Redis 8 : cache-aside, write-through et TTL

11 دقائق للقراءة

📌 Article principal de la série : Redis 8 : caching, queues, pub/sub et streams pour applications production
Prérequis : Installer Redis 8 sur Linux et configurer RDB + AOF

Le caching est l’usage historique et toujours dominant de Redis. Bien implémenté, il divise par dix la charge sur une base de données relationnelle et réduit drastiquement les temps de réponse perçus par l’utilisateur final. Mal implémenté, il introduit des incohérences silencieuses, des fuites mémoire, voire des effondrements en cascade. Ce tutoriel détaille les trois patterns de caching canoniques — cache-aside, write-through, write-behind — en Node.js avec ioredis, leurs trade-offs, la gestion fine des TTL, et l’invalidation par tags.

Prérequis

  • Un serveur Redis 8 opérationnel (voir tutoriel d’installation)
  • Node.js 22 LTS et npm installés
  • Une base de données PostgreSQL ou MySQL pour simuler le backend lent
  • Connaissances de base async/await en JavaScript
  • Temps estimé : 60 minutes

Étape 1 — Initialiser le projet et installer les dépendances

Commençons par un projet Node.js minimal avec les bibliothèques nécessaires pour parler à Redis et à PostgreSQL. Le client ioredis est le plus mature de l’écosystème : il supporte nativement Cluster, Sentinel et le pipelining, et son API Promise est élégante.

mkdir cache-redis-demo
cd cache-redis-demo
npm init -y
npm install ioredis pg

Cette installation crée un répertoire de projet, génère un package.json minimal puis installe ioredis (client Redis) et pg (client PostgreSQL). Si vous préférez MySQL, remplacez pg par mysql2 — l’adaptation du code est triviale.

Étape 2 — Établir la connexion Redis avec gestion des erreurs

Une connexion Redis robuste doit gérer les déconnexions transitoires, les erreurs d’authentification, et logger correctement les événements. ioredis reconnecte automatiquement avec backoff exponentiel, mais il faut écouter les événements pour observer l’état.

// redis-client.js
import Redis from 'ioredis';

export const redis = new Redis({
    host: process.env.REDIS_HOST || '127.0.0.1',
    port: 6379,
    username: 'appuser',
    password: process.env.REDIS_PASSWORD,
    db: 0,
    retryStrategy(times) {
        // Backoff exponentiel plafonné à 5 secondes
        return Math.min(times * 200, 5000);
    },
    maxRetriesPerRequest: 3
});

redis.on('connect', () => console.log('Redis connecté'));
redis.on('error', (err) => console.error('Erreur Redis :', err.message));
redis.on('reconnecting', (ms) => console.log(`Reconnexion Redis dans ${ms}ms`));

Le retryStrategy dicte la stratégie de reconnexion : à chaque échec, il retourne le délai en millisecondes avant la prochaine tentative. La formule times * 200 plafonnée à 5 000 ms produit 200 ms, 400 ms, 600 ms… jusqu’à 5 s. La directive maxRetriesPerRequest: 3 limite à 3 essais avant d’échouer la commande individuelle (utile pour ne pas bloquer indéfiniment une requête HTTP en cas de panne Redis prolongée).

Étape 3 — Implémenter le pattern cache-aside

Cache-aside (aussi appelé lazy loading) est le pattern le plus simple et le plus utilisé. La logique est : l’application vérifie d’abord le cache ; si la valeur est présente, elle est servie ; sinon, l’application interroge la base, écrit le résultat dans le cache avec un TTL, et le sert. Le cache ne sait rien de la base — c’est l’application qui orchestre.

// cache-aside.js
import { redis } from './redis-client.js';
import pg from 'pg';

const db = new pg.Client({ /* configuration PostgreSQL */ });
await db.connect();

async function getProduit(id) {
    const cacheKey = `produit:${id}`;

    // 1. Tenter de lire le cache
    const cached = await redis.get(cacheKey);
    if (cached !== null) {
        console.log(`HIT pour ${cacheKey}`);
        return JSON.parse(cached);
    }

    // 2. Cache miss : interroger la base
    console.log(`MISS pour ${cacheKey}, requete base`);
    const result = await db.query('SELECT * FROM produits WHERE id = $1', [id]);
    if (result.rows.length === 0) {
        return null;
    }
    const produit = result.rows[0];

    // 3. Écrire dans le cache avec TTL de 300 secondes
    await redis.set(cacheKey, JSON.stringify(produit), 'EX', 300);
    return produit;
}

Cette fonction illustre toute la logique cache-aside. L’argument 'EX', 300 à SET applique un TTL de 300 secondes : passé ce délai, la clé est automatiquement supprimée et la prochaine lecture provoquera un nouveau cache miss. La sérialisation JSON est nécessaire car Redis stocke des strings ; pour des objets très volumineux, envisager MessagePack ou Protocol Buffers qui sont plus compacts. Le coût d’un hit est typiquement 0,3 à 1 ms (réseau local) ; le coût d’un miss est le temps de la requête base + ~1 ms pour l’écriture cache.

Étape 4 — Le problème du cache stampede et sa solution

Le pattern naïf ci-dessus a un défaut majeur en production : quand une clé chaude expire, des dizaines de requêtes simultanées provoquent toutes un miss, toutes interrogent la base en parallèle, et toutes réécrivent la même valeur dans le cache. C’est le cache stampede — il peut saturer la base et provoquer un effondrement en cascade. La solution standard est le lock probabiliste ou le request coalescing.

// cache-aside-protege.js
async function getProduitProtege(id) {
    const cacheKey = `produit:${id}`;
    const lockKey = `lock:${cacheKey}`;

    const cached = await redis.get(cacheKey);
    if (cached !== null) return JSON.parse(cached);

    // Tenter d'acquérir un lock pour reconstruire la clé
    const lockAcquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);

    if (!lockAcquired) {
        // Un autre processus reconstruit deja, attendre brievement
        await new Promise(r => setTimeout(r, 50));
        const retry = await redis.get(cacheKey);
        if (retry !== null) return JSON.parse(retry);
        // Sinon, fallback : interroger la base directement
    }

    try {
        const result = await db.query('SELECT * FROM produits WHERE id = $1', [id]);
        if (result.rows.length === 0) return null;
        const produit = result.rows[0];
        await redis.set(cacheKey, JSON.stringify(produit), 'EX', 300);
        return produit;
    } finally {
        await redis.del(lockKey);
    }
}

L’option NX à SET (« set if not exists ») crée la clé uniquement si elle n’existe pas, et retourne OK ou null selon le résultat. Le premier processus qui arrive obtient le verrou et reconstruit la valeur ; les suivants attendent 50 ms et retentent la lecture du cache. L’EX 10 au lock garantit qu’il est libéré même si le processus crashe entre l’acquisition et le DEL final, évitant un deadlock permanent. Pour aller plus loin, le pattern probabiliste de Vattani et al. (2015) ajoute une expiration aléatoire pour étaler les reconstructions.

Étape 5 — Pattern write-through pour la cohérence forte

Write-through inverse la perspective : à chaque écriture, l’application met à jour la base ET le cache dans la même opération. La lecture devient triviale (toujours un hit après la première écriture). L’inconvénient est l’augmentation de la latence d’écriture, puisque deux systèmes doivent répondre.

async function mettreAJourProduit(id, modifications) {
    // 1. Ecriture en base (source de verite)
    const result = await db.query(
        'UPDATE produits SET prix = $2, stock = $3 WHERE id = $1 RETURNING *',
        [id, modifications.prix, modifications.stock]
    );
    if (result.rows.length === 0) throw new Error('Produit introuvable');

    const produit = result.rows[0];

    // 2. Mise a jour synchrone du cache
    await redis.set(`produit:${id}`, JSON.stringify(produit), 'EX', 3600);

    return produit;
}

Avec write-through, le TTL peut être beaucoup plus long (ici 3600 s = 1 h, voire infini si on ne définit pas d’EX) car le cache est garanti synchronisé avec la base à chaque modification. C’est le pattern idéal pour des objets référentiels qui changent peu mais sont lus très souvent : catalogue produit, configuration applicative, taxonomies. Limite : si la mise à jour Redis échoue après l’écriture en base, on a une incohérence temporaire jusqu’à la prochaine lecture qui réhydratera correctement le cache via cache-aside (configurer un TTL même long est donc prudent).

Étape 6 — Invalidation par tags

Quand un changement affecte plusieurs clés cache (par exemple, modifier une catégorie doit invalider tous les produits de cette catégorie), une invalidation par tags est nécessaire. Redis n’offre pas nativement de tags, mais on les implémente avec des sets qui répertorient les clés associées à un tag.

async function cacheAvecTags(cacheKey, valeur, tags, ttl = 300) {
    const pipeline = redis.pipeline();
    pipeline.set(cacheKey, JSON.stringify(valeur), 'EX', ttl);
    for (const tag of tags) {
        pipeline.sadd(`tag:${tag}`, cacheKey);
        pipeline.expire(`tag:${tag}`, ttl + 60);
    }
    await pipeline.exec();
}

async function invaliderTag(tag) {
    const cles = await redis.smembers(`tag:${tag}`);
    if (cles.length === 0) return 0;
    const pipeline = redis.pipeline();
    cles.forEach(k => pipeline.del(k));
    pipeline.del(`tag:${tag}`);
    await pipeline.exec();
    return cles.length;
}

// Exemple d'usage :
await cacheAvecTags('produit:42', produit42, ['categorie:smartphones', 'marque:samsung']);
await cacheAvecTags('produit:43', produit43, ['categorie:smartphones', 'marque:apple']);

// Quand la categorie smartphones est modifiee, on invalide tout :
const n = await invaliderTag('categorie:smartphones');
console.log(`${n} cles invalidees`);

Le pattern utilise un pipeline pour grouper les commandes en un seul aller-retour réseau. SADD ajoute la clé au set du tag, EXPIRE garantit que le set lui-même finit par s’évincer (au-delà du TTL maximum de ses clés membres). La fonction invaliderTag récupère toutes les clés d’un tag et les supprime atomiquement. Pour des dizaines de milliers de clés par tag, envisager SSCAN au lieu de SMEMBERS qui charge tout d’un coup en mémoire.

Étape 7 — Mesurer le hit ratio

Sans métriques, impossible de savoir si votre cache est efficace. Redis expose nativement les statistiques de hits via INFO stats.

async function statsCache() {
    const info = await redis.info('stats');
    const hits = parseInt(info.match(/keyspace_hits:(\d+)/)[1]);
    const misses = parseInt(info.match(/keyspace_misses:(\d+)/)[1]);
    const total = hits + misses;
    const ratio = total > 0 ? (hits / total * 100).toFixed(2) : 0;
    return { hits, misses, ratio: `${ratio}%`, total };
}

console.log(await statsCache());
// Exemple de sortie : { hits: 8432, misses: 1156, ratio: '87.94%', total: 9588 }

Un hit ratio supérieur à 80 % est généralement bon pour un cache applicatif ; au-dessus de 95 % indique un excellent dimensionnement. Si le ratio descend sous 60 %, il faut soit augmenter les TTL (les données sont expirées trop vite), soit augmenter maxmemory (les clés sont évincées trop tôt par la politique LRU), soit revoir la stratégie de cache (peut-être que les patterns d’accès ne sont pas adaptés).

Étape 8 — TTL adaptatif et jitter

Un TTL fixe pour toutes les clés crée un risque : si beaucoup de clés ont été créées au même moment (au démarrage de l’application par exemple), elles expirent toutes en même temps, provoquant un pic de cache misses. La solution est un TTL avec jitter aléatoire.

function ttlAvecJitter(baseSeconds, jitterPercent = 0.1) {
    const jitter = baseSeconds * jitterPercent;
    return Math.floor(baseSeconds + Math.random() * jitter - jitter / 2);
}

// Au lieu de :
// redis.set(key, value, 'EX', 300);
// Utiliser :
redis.set(key, value, 'EX', ttlAvecJitter(300));
// Le TTL sera reparti entre 285 et 315 secondes

Ce simple ajustement répartit les expirations dans le temps et évite les ondes de cache misses synchronisées. Pour des données très chaudes, on peut aussi rafraîchir le cache de manière proactive avant l’expiration via un job en arrière-plan, plutôt que d’attendre le miss qui pénalisera un utilisateur.

Erreurs fréquentes

Erreur Cause Solution
Cache invalide après mise à jour DB Cache-aside sans invalidation lors des écritures Faire DEL de la clé cache après chaque UPDATE
Hit ratio très bas TTL trop court ou maxmemory insuffisant Augmenter TTL, vérifier evicted_keys via INFO stats
OOM kill du processus Redis maxmemory non défini ou politique noeviction Configurer maxmemory-policy allkeys-lru
Race condition au cache miss Cache stampede sans lock Implémenter le lock probabiliste (étape 4)
JSON.parse plante sur valeur cache Donnée corrompue ou format change entre versions Encapsuler dans try/catch, traiter exception comme cache miss

Tutoriels suivants

FAQ

Cache-aside ou write-through : que choisir ?
Cache-aside pour la majorité des cas : objets qui peuvent tolérer quelques secondes de staleness, code applicatif découplé. Write-through pour les objets très lus mais rarement écrits où la cohérence forte est critique (configuration, taxonomies). Write-behind (écrire en cache d’abord, persister en base de manière asynchrone) est dangereux car en cas de crash Redis avant persistance, on perd les données.
Quel TTL appliquer ?
Cela dépend du domaine. Pour des données qui changent rarement (catalogue produit, profils utilisateurs) : 1 à 24 heures. Pour des données fréquemment modifiées (paniers, sessions) : 5 à 60 minutes. Pour des données quasi-statiques (configuration applicative, traductions) : sans TTL avec invalidation explicite à chaque modification.
Comment invalider plusieurs clés en une fois ?
Soit via tags (étape 6), soit via patterns avec SCAN + DEL en boucle (jamais KEYS qui bloque Redis). En Redis 8, la commande UNLINK supprime de manière asynchrone, ce qui évite de bloquer le serveur sur des suppressions de gros sets.
Faut-il sérialiser en JSON ou en binaire ?
JSON est suffisant pour la grande majorité des cas — facile à débugger avec redis-cli, compatible avec tous les langages. Pour des objets très volumineux (> 100 Ko) ou un trafic massif, MessagePack ou Protocol Buffers réduisent de 30 à 50 % la taille stockée et accélèrent la sérialisation. Le module Redis JSON natif permet aussi de manipuler des fragments d’objet sans relire/réécrire entièrement.

Références

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité