Stratégies de cache Redis qui marchent vraiment en production
Cache-aside, write-through, prévention du cache stampede, stratégies TTL et patterns d'invalidation. Les patterns Redis que j'ai utilisés en production dans des apps Node.js avec de vrais exemples de code.
Tout le monde te dit de « juste ajouter Redis » quand ton API est lente. Personne ne te dit ce qui se passe six mois plus tard quand ton cache sert des données périmées, que ta logique d'invalidation est éparpillée dans 40 fichiers, et qu'un déploiement provoque un cache stampede qui met ta base de données à genoux plus violemment que si tu n'avais jamais caché quoi que ce soit.
J'utilise Redis en production depuis des années. Pas comme un jouet, pas dans un tutoriel — dans des systèmes qui gèrent du vrai trafic où se tromper sur le caching signifie des alertes à 3 h du matin. Voici tout ce que j'ai appris pour bien faire les choses.
Pourquoi cacher ?#
Commençons par l'évidence : les bases de données sont lentes par rapport à la mémoire. Une requête PostgreSQL qui prend 15 ms est rapide selon les standards des SGBD. Mais si cette requête tourne sur chaque requête API, et que tu gères 1 000 requêtes par seconde, c'est 15 000 ms de temps cumulé de base de données par seconde. Ton pool de connexions est épuisé. Ta latence au p99 crève le plafond. Les utilisateurs fixent des spinners.
Redis sert la plupart des lectures en moins de 1 ms. La même donnée, cachée, transforme une opération de 15 ms en une opération de 0,3 ms. Ce n'est pas une micro-optimisation. C'est la différence entre avoir besoin de 4 réplicas de base de données et n'en avoir besoin d'aucun.
Mais le caching n'est pas gratuit. Il ajoute de la complexité, introduit des problèmes de cohérence, et crée une toute nouvelle catégorie de modes de défaillance. Avant de cacher quoi que ce soit, pose-toi la question :
Quand le cache aide :
- Les données sont lues beaucoup plus souvent qu'elles ne sont écrites (ratio 10:1 ou plus)
- La requête sous-jacente est coûteuse (jointures, agrégations, appels API externes)
- Un léger décalage est acceptable (catalogue de produits, profils utilisateurs, config)
- Tu as des patterns d'accès prévisibles (les mêmes clés sont sollicitées régulièrement)
Quand le cache fait mal :
- Les données changent constamment et doivent être fraîches (cours boursiers en temps réel, scores en direct)
- Chaque requête est unique (requêtes de recherche avec de nombreux paramètres)
- Ton jeu de données est minuscule (si le tout tient en mémoire dans ton app, saute Redis)
- Tu n'as pas la maturité opérationnelle pour surveiller et débuguer les problèmes de cache
Phil Karlton a dit de manière célèbre qu'il n'y a que deux choses difficiles en informatique : l'invalidation de cache et le nommage. Il avait raison sur les deux, mais l'invalidation de cache est celle qui te réveille la nuit.
Configurer ioredis#
Avant de plonger dans les patterns, établissons la connexion. J'utilise ioredis partout — c'est le client Redis le plus mature pour Node.js, avec un support TypeScript correct, le mode cluster, le support Sentinel et le scripting Lua.
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: Number(process.env.REDIS_DB) || 0,
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 200, 5000);
return delay;
},
lazyConnect: true,
enableReadyCheck: true,
connectTimeout: 10000,
});
redis.on("error", (err) => {
console.error("[Redis] Connection error:", err.message);
});
redis.on("connect", () => {
console.log("[Redis] Connected");
});
export default redis;Quelques points à noter. lazyConnect: true signifie que la connexion n'est pas établie tant que tu n'exécutes pas réellement une commande, ce qui est utile pendant les tests et l'initialisation. retryStrategy implémente un backoff exponentiel plafonné à 5 secondes — sans ça, une panne Redis fait que ton app spamme les tentatives de reconnexion. Et maxRetriesPerRequest: 3 garantit que les commandes individuelles échouent rapidement au lieu de bloquer indéfiniment.
Le pattern Cache-Aside#
C'est le pattern que tu utiliseras 80 % du temps. On l'appelle aussi « lazy loading » ou « look-aside ». Le flux est simple :
- L'application reçoit une requête
- Vérifie Redis pour la valeur en cache
- Si trouvée (cache hit), la retourne
- Si non trouvée (cache miss), interroge la base de données
- Stocke le résultat dans Redis
- Retourne le résultat
Voici une implémentation typée :
import redis from "./redis";
interface CacheOptions {
ttl?: number; // seconds
prefix?: string;
}
async function cacheAside<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions = {}
): Promise<T> {
const { ttl = 3600, prefix = "cache" } = options;
const cacheKey = `${prefix}:${key}`;
// Step 1: Try to read from cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
return JSON.parse(cached) as T;
} catch {
// Corrupted cache entry, delete it and fall through
await redis.del(cacheKey);
}
}
// Step 2: Cache miss — fetch from source
const result = await fetcher();
// Step 3: Store in cache (don't await — fire and forget)
redis
.set(cacheKey, JSON.stringify(result), "EX", ttl)
.catch((err) => {
console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
});
return result;
}L'utilisation ressemble à ceci :
interface User {
id: string;
name: string;
email: string;
plan: "free" | "pro" | "enterprise";
}
async function getUser(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{ ttl: 1800 } // 30 minutes
);
}Remarque que j'envoie le redis.set en « fire-and-forget ». C'est intentionnel. Si Redis est down ou lent, la requête se termine quand même. Le cache est une optimisation, pas une obligation. Si l'écriture en cache échoue, la prochaine requête ira simplement chercher en base de données. Pas de quoi en faire un drame.
Il y a un bug subtil dans beaucoup d'implémentations cache-aside que les gens ratent : le caching des valeurs nulles. Si un utilisateur n'existe pas et que tu ne caches pas ce fait, chaque requête pour cet utilisateur tape la base de données. Un attaquant peut exploiter ça en demandant des IDs utilisateur aléatoires, transformant ton cache en no-op. Cache toujours le résultat négatif aussi — juste avec un TTL plus court.
async function getUserSafe(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{
// Shorter TTL for null results to limit memory usage
// but long enough to absorb repeated misses
ttl: row ? 1800 : 300,
}
);
}En fait, laisse-moi restructurer ça pour que le TTL dynamique fonctionne correctement :
async function getUserWithDynamicTTL(userId: string): Promise<User | null> {
const cacheKey = `cache:user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as User | null;
}
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
const user: User | null = row[0] ?? null;
// Cache exists results for 30 min, null results for 5 min
const ttl = user ? 1800 : 300;
await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
return user;
}Write-Through et Write-Behind#
Le cache-aside fonctionne très bien pour les charges de travail lourdes en lecture, mais il a un problème de cohérence : si un autre service ou processus met à jour la base de données directement, ton cache est périmé jusqu'à l'expiration du TTL. C'est là qu'interviennent les patterns write-through et write-behind.
Write-Through#
Avec le write-through, chaque écriture passe par la couche de cache. Le cache est mis à jour d'abord, puis la base de données. Cela garantit que le cache est toujours cohérent avec la base de données (à condition que les écritures passent toujours par ton application).
async function updateUser(
userId: string,
updates: Partial<User>
): Promise<User> {
// Step 1: Update the database
const updated = await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *",
[userId, updates.name, updates.email]
);
const user: User = updated[0];
// Step 2: Update the cache immediately
const cacheKey = `cache:user:${userId}`;
await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
return user;
}La différence clé avec le cache-aside : on écrit dans le cache à chaque écriture, pas seulement à la lecture. Cela signifie que le cache est toujours chaud pour les données récemment mises à jour.
Le compromis : la latence d'écriture augmente parce que chaque écriture touche maintenant à la fois la base de données et Redis. Si Redis est lent, tes écritures sont lentes. Dans la plupart des applications, les lectures dépassent largement les écritures, donc ce compromis en vaut la peine.
Write-Behind (Write-Back)#
Le write-behind inverse le script : les écritures vont d'abord dans Redis, et la base de données est mise à jour de manière asynchrone. Cela te donne des écritures extrêmement rapides au prix d'une perte de données potentielle si Redis tombe avant que les données ne soient persistées.
async function updateUserWriteBehind(
userId: string,
updates: Partial<User>
): Promise<User> {
const cacheKey = `cache:user:${userId}`;
// Read current state
const current = await redis.get(cacheKey);
const user = current ? JSON.parse(current) as User : null;
if (!user) throw new Error("User not in cache");
// Update cache immediately
const updated = { ...user, ...updates };
await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
// Queue database write for async processing
await redis.rpush(
"write_behind:users",
JSON.stringify({ userId, updates, timestamp: Date.now() })
);
return updated;
}Tu aurais ensuite un worker séparé qui vide cette file d'attente :
async function processWriteBehindQueue(): Promise<void> {
while (true) {
const item = await redis.blpop("write_behind:users", 5);
if (item) {
const { userId, updates } = JSON.parse(item[1]);
try {
await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1",
[userId, updates.name, updates.email]
);
} catch (err) {
// Re-queue on failure with retry count
console.error("[WriteBehind] Failed:", err);
await redis.rpush("write_behind:users:dlq", item[1]);
}
}
}
}J'utilise rarement le write-behind en pratique. Le risque de perte de données est réel — si Redis crashe avant que le worker ne traite la file, ces écritures sont perdues. Utilise ça uniquement pour des données où la cohérence à terme est véritablement acceptable, comme les compteurs de vues, les événements analytics, ou les préférences utilisateur non critiques.
Stratégie TTL#
Bien calibrer le TTL est plus nuancé qu'il n'y paraît. Un TTL fixe d'une heure sur tout est facile à implémenter et presque toujours faux.
Niveaux de volatilité des données#
Je catégorise les données en trois niveaux et assigne les TTL en conséquence :
const TTL = {
// Tier 1: Rarely changes, expensive to compute
// Examples: product catalog, site config, feature flags
STATIC: 86400, // 24 hours
// Tier 2: Changes occasionally, moderate cost
// Examples: user profiles, team settings, permissions
MODERATE: 1800, // 30 minutes
// Tier 3: Changes frequently, cheap to compute but called often
// Examples: feed data, notification counts, session info
VOLATILE: 300, // 5 minutes
// Tier 4: Ephemeral, used for rate limiting and locks
EPHEMERAL: 60, // 1 minute
// Null results: always short-lived
NOT_FOUND: 120, // 2 minutes
} as const;TTL Jitter : prévenir le Thundering Herd#
Voici un scénario qui m'a mordu : tu déploies ton app, le cache est vide, et 10 000 requêtes cachent toutes la même donnée avec un TTL d'une heure. Une heure plus tard, les 10 000 clés expirent simultanément. Les 10 000 requêtes tapent la base de données en même temps. La base s'effondre. J'ai vu ça mettre à genoux une instance PostgreSQL de production.
La solution c'est le jitter — ajouter de l'aléatoire aux valeurs de TTL :
function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
const jitter = baseTtl * jitterPercent;
const offset = Math.random() * jitter * 2 - jitter;
return Math.max(1, Math.round(baseTtl + offset));
}
// Instead of: redis.set(key, value, "EX", 3600)
// Use: redis.set(key, value, "EX", ttlWithJitter(3600))
// 3600 ± 10% = random value between 3240 and 3960Cela répartit l'expiration sur une fenêtre, donc au lieu de 10 000 clés qui expirent à la même seconde, elles expirent sur une fenêtre de 12 minutes. La base de données voit une augmentation progressive du trafic, pas une falaise.
Pour les chemins critiques, je vais plus loin et j'utilise 20 % de jitter :
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 secondsExpiration glissante#
Pour les données de type session où le TTL doit se réinitialiser à chaque accès, utilise GETEX (Redis 6.2+) :
async function getWithSlidingExpiry<T>(
key: string,
ttl: number
): Promise<T | null> {
// GETEX atomically gets the value AND resets the TTL
const value = await redis.getex(key, "EX", ttl);
if (value === null) return null;
return JSON.parse(value) as T;
}Si tu es sur une version plus ancienne de Redis, utilise un pipeline :
async function getWithSlidingExpiryCompat<T>(
key: string,
ttl: number
): Promise<T | null> {
const pipeline = redis.pipeline();
pipeline.get(key);
pipeline.expire(key, ttl);
const results = await pipeline.exec();
if (!results || !results[0] || results[0][1] === null) return null;
return JSON.parse(results[0][1] as string) as T;
}Cache Stampede (Thundering Herd)#
Le TTL jitter aide avec l'expiration massive, mais il ne résout pas le stampede sur une clé unique : quand une clé populaire expire et que des centaines de requêtes concurrentes essaient toutes de la régénérer simultanément.
Imagine que tu caches le feed de ta page d'accueil avec un TTL de 5 minutes. Il expire. Cinquante requêtes concurrentes voient le cache miss. Les cinquante tapent la base de données avec la même requête coûteuse. Tu t'es essentiellement DDoS toi-même.
Solution 1 : verrou mutex#
Une seule requête régénère le cache. Tout le monde attend.
async function cacheAsideWithMutex<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const lockKey = `lock:${key}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as T;
}
// Try to acquire lock (NX = only if not exists, EX = auto-expire)
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (acquired) {
try {
// We got the lock — fetch and cache
const result = await fetcher();
await redis.set(
cacheKey,
JSON.stringify(result),
"EX",
ttlWithJitter(ttl)
);
return result;
} finally {
// Release lock
await redis.del(lockKey);
}
}
// Another request holds the lock — wait and retry
await sleep(100);
const retried = await redis.get(cacheKey);
if (retried !== null) {
return JSON.parse(retried) as T;
}
// Still no cache — fall through to database
// (this handles the case where the lock holder failed)
return fetcher();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}Il y a une condition de concurrence subtile dans la libération du verrou ci-dessus. Si le détenteur du verrou met plus de 10 secondes (le TTL du verrou), une autre requête acquiert le verrou, puis la première requête supprime le verrou de la seconde. La vraie correction est d'utiliser un token unique :
import { randomUUID } from "crypto";
async function acquireLock(
lockKey: string,
ttl: number
): Promise<string | null> {
const token = randomUUID();
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
return acquired ? token : null;
}
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
// Lua script ensures atomic check-and-delete
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, 1, lockKey, token);
return result === 1;
}C'est essentiellement un Redlock simplifié. Pour une instance Redis unique, c'est suffisant. Pour des configurations Redis Cluster ou Sentinel, regarde l'algorithme Redlock complet — mais honnêtement, pour la prévention du cache stampede, cette version simple fonctionne très bien.
Solution 2 : expiration anticipée probabiliste#
C'est mon approche préférée. Au lieu d'attendre que la clé expire, on la régénère aléatoirement juste avant l'expiration. L'idée vient d'un article de Vattani, Chierichetti et Lowenstein.
interface CachedValue<T> {
data: T;
cachedAt: number;
ttl: number;
}
async function cacheWithEarlyExpiration<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T> {
const cacheKey = `cache:${key}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
const entry = JSON.parse(cached) as CachedValue<T>;
const age = (Date.now() - entry.cachedAt) / 1000;
const remaining = entry.ttl - age;
// XFetch algorithm: probabilistically regenerate as expiry approaches
// beta * Math.log(Math.random()) produces a negative number
// that grows larger (more negative) as expiry approaches
const beta = 1; // tuning parameter, 1 works well
const shouldRegenerate =
remaining - beta * Math.log(Math.random()) * -1 <= 0;
if (!shouldRegenerate) {
return entry.data;
}
// Fall through to regenerate
console.log(`[Cache] Early regeneration triggered for ${key}`);
}
const data = await fetcher();
const entry: CachedValue<T> = {
data,
cachedAt: Date.now(),
ttl,
};
// Set with extra buffer so Redis doesn't expire before we can regenerate
await redis.set(
cacheKey,
JSON.stringify(entry),
"EX",
Math.round(ttl * 1.1)
);
return data;
}La beauté de cette approche : à mesure que le TTL restant de la clé diminue, la probabilité de régénération augmente. Avec 1 000 requêtes concurrentes, peut-être une ou deux déclencheront la régénération tandis que le reste continue de servir les données en cache. Pas de verrous, pas de coordination, pas d'attente.
Solution 3 : Stale-While-Revalidate#
Servir la valeur périmée tout en régénérant en arrière-plan. Cela donne la meilleure latence parce qu'aucune requête n'attend jamais le fetcher.
async function staleWhileRevalidate<T>(
key: string,
fetcher: () => Promise<T>,
options: {
freshTtl: number; // how long the data is "fresh"
staleTtl: number; // how long stale data can be served
}
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const metaKey = `meta:${key}`;
const [cached, meta] = await redis.mget(cacheKey, metaKey);
if (cached !== null) {
const parsedMeta = meta ? JSON.parse(meta) : null;
const isFresh =
parsedMeta && Date.now() - parsedMeta.cachedAt < options.freshTtl * 1000;
if (!isFresh) {
// Data is stale — serve it but trigger background refresh
revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
}
return JSON.parse(cached) as T;
}
// Complete cache miss — must fetch synchronously
return fetchAndCache(key, cacheKey, metaKey, fetcher, options);
}
async function fetchAndCache<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): Promise<T> {
const data = await fetcher();
const totalTtl = options.freshTtl + options.staleTtl;
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(data), "EX", totalTtl);
pipeline.set(
metaKey,
JSON.stringify({ cachedAt: Date.now() }),
"EX",
totalTtl
);
await pipeline.exec();
return data;
}
function revalidateInBackground<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): void {
// Use a lock to prevent multiple background refreshes
const lockKey = `revalidate_lock:${key}`;
redis
.set(lockKey, "1", "EX", 30, "NX")
.then((acquired) => {
if (!acquired) return;
return fetchAndCache(key, cacheKey, metaKey, fetcher, options)
.finally(() => redis.del(lockKey));
})
.catch((err) => {
console.error(`[SWR] Background revalidation failed for ${key}:`, err);
});
}Utilisation :
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
freshTtl: 300, // 5 minutes fresh
staleTtl: 3600, // serve stale for up to 1 hour while revalidating
});J'utilise ce pattern pour tout ce qui est visible par l'utilisateur où la latence compte plus que la fraîcheur absolue. Données de tableau de bord, pages de profil, listes de produits — tous de parfaits candidats.
Invalidation de cache#
Phil Karlton ne plaisantait pas. L'invalidation est le moment où le caching passe de « optimisation facile » à « problème de systèmes distribués ».
Invalidation simple par clé#
Le cas le plus simple : quand tu mets à jour un utilisateur, supprime sa clé de cache.
async function updateUserAndInvalidate(
userId: string,
updates: Partial<User>
): Promise<User> {
const user = await db.query(
"UPDATE users SET name = $2 WHERE id = $1 RETURNING *",
[userId, updates.name]
);
// Invalidate the cache
await redis.del(`cache:user:${userId}`);
return user[0];
}Ça fonctionne jusqu'à ce que les données utilisateur apparaissent dans d'autres résultats cachés. Peut-être qu'elles sont embarquées dans une liste de membres d'équipe. Peut-être dans un résultat de recherche. Peut-être dans 14 réponses API cachées différentes. Maintenant tu dois suivre quelles clés de cache contiennent quelles entités.
Invalidation par tags#
Tague tes entrées de cache avec les entités qu'elles contiennent, puis invalide par tag.
async function setWithTags<T>(
key: string,
value: T,
ttl: number,
tags: string[]
): Promise<void> {
const pipeline = redis.pipeline();
// Store the value
pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
// Add the key to each tag's set
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, `cache:${key}`);
pipeline.expire(`tag:${tag}`, ttl + 3600); // Tag sets live longer than values
}
await pipeline.exec();
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}Utilisation :
// When caching team data, tag it with all member IDs
const team = await fetchTeam(teamId);
await setWithTags(
`team:${teamId}`,
team,
1800,
[
`entity:team:${teamId}`,
...team.members.map((m) => `entity:user:${m.id}`),
]
);
// When user 42 updates their profile, invalidate everything that contains them
await invalidateByTag("entity:user:42");Invalidation événementielle#
Pour les systèmes plus grands, utilise Redis Pub/Sub pour diffuser les événements d'invalidation :
// Publisher (in your API service)
async function publishInvalidation(
entityType: string,
entityId: string
): Promise<void> {
await redis.publish(
"cache:invalidate",
JSON.stringify({ entityType, entityId, timestamp: Date.now() })
);
}
// Subscriber (in each app instance)
const subscriber = new Redis(/* same config */);
subscriber.subscribe("cache:invalidate", (err) => {
if (err) console.error("[PubSub] Subscribe error:", err);
});
subscriber.on("message", async (_channel, message) => {
const { entityType, entityId } = JSON.parse(message);
await invalidateByTag(`entity:${entityType}:${entityId}`);
console.log(`[Cache] Invalidated ${entityType}:${entityId}`);
});C'est crucial dans les déploiements multi-instances. Si tu as 4 serveurs applicatifs derrière un load balancer, une invalidation sur le serveur 1 doit se propager à tous les serveurs. Le Pub/Sub gère ça automatiquement.
Invalidation par pattern (avec précaution)#
Parfois tu as besoin d'invalider toutes les clés correspondant à un pattern. N'utilise jamais KEYS en production. Ça bloque le serveur Redis pendant qu'il scanne l'ensemble du keyspace. Avec des millions de clés, ça peut bloquer pendant des secondes — une éternité en termes Redis.
Utilise SCAN à la place :
async function invalidateByPattern(pattern: string): Promise<number> {
let cursor = "0";
let deletedCount = 0;
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
deletedCount += keys.length;
}
} while (cursor !== "0");
return deletedCount;
}
// Invalidate all cached data for a specific team
await invalidateByPattern("cache:team:42:*");SCAN itère de manière incrémentale — il ne bloque jamais le serveur. Le hint COUNT suggère combien de clés retourner par itération (c'est un indice, pas une garantie). Pour les grands keyspaces, c'est la seule approche sûre.
Cela dit, l'invalidation par pattern est un code smell. Si tu te retrouves à scanner fréquemment, repense ta structure de clés ou utilise des tags. SCAN est O(N) sur le keyspace et est conçu pour les opérations de maintenance, pas les chemins chauds.
Structures de données au-delà des strings#
La plupart des développeurs traitent Redis comme un key-value store pour des chaînes JSON. C'est comme acheter un couteau suisse et n'utiliser que le décapsuleur. Redis a des structures de données riches, et choisir la bonne peut éliminer des catégories entières de complexité.
Hashes pour les objets#
Au lieu de sérialiser un objet entier en JSON, stocke-le comme un Redis Hash. Cela te permet de lire et mettre à jour des champs individuels sans désérialiser le tout.
// Store user as a hash
async function setUserHash(user: User): Promise<void> {
const key = `user:${user.id}`;
await redis.hset(key, {
name: user.name,
email: user.email,
plan: user.plan,
updatedAt: Date.now().toString(),
});
await redis.expire(key, 1800);
}
// Read specific fields
async function getUserPlan(userId: string): Promise<string | null> {
return redis.hget(`user:${userId}`, "plan");
}
// Update a single field
async function upgradeUserPlan(
userId: string,
plan: string
): Promise<void> {
await redis.hset(`user:${userId}`, "plan", plan);
}
// Read entire hash as object
async function getUserHash(userId: string): Promise<User | null> {
const data = await redis.hgetall(`user:${userId}`);
if (!data || Object.keys(data).length === 0) return null;
return {
id: userId,
name: data.name,
email: data.email,
plan: data.plan as User["plan"],
};
}Les hashes sont efficaces en mémoire pour les petits objets (Redis utilise un encodage ziplist compact sous le capot) et évitent le surcoût de sérialisation/désérialisation. Le compromis : tu perds la capacité de stocker des objets imbriqués sans les aplatir d'abord.
Sorted Sets pour les classements et le rate limiting#
Les Sorted Sets sont la structure de données la plus sous-estimée de Redis. Chaque membre a un score, et l'ensemble est toujours trié par score. Cela les rend parfaits pour les classements, le ranking et le rate limiting en fenêtre glissante.
// Leaderboard
async function addScore(
leaderboard: string,
userId: string,
score: number
): Promise<void> {
await redis.zadd(leaderboard, score, userId);
}
async function getTopPlayers(
leaderboard: string,
count: number = 10
): Promise<Array<{ userId: string; score: number }>> {
const results = await redis.zrevrange(
leaderboard,
0,
count - 1,
"WITHSCORES"
);
const players: Array<{ userId: string; score: number }> = [];
for (let i = 0; i < results.length; i += 2) {
players.push({
userId: results[i],
score: parseFloat(results[i + 1]),
});
}
return players;
}
async function getUserRank(
leaderboard: string,
userId: string
): Promise<number | null> {
const rank = await redis.zrevrank(leaderboard, userId);
return rank !== null ? rank + 1 : null; // 0-indexed to 1-indexed
}Pour le rate limiting en fenêtre glissante :
async function slidingWindowRateLimit(
identifier: string,
windowMs: number,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number }> {
const key = `ratelimit:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
// Remove entries outside the window
pipeline.zremrangebyscore(key, 0, windowStart);
// Add current request
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// Count requests in window
pipeline.zcard(key);
// Set expiry on the whole key
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results?.[2]?.[1] as number;
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
};
}C'est plus précis que l'approche par compteur à fenêtre fixe et ne souffre pas du problème de frontière où un pic à la fin d'une fenêtre et au début de la suivante double effectivement ta limite de débit.
Lists pour les files d'attente#
Les Redis Lists avec LPUSH/BRPOP font d'excellentes files d'attente légères pour les jobs :
interface Job {
id: string;
type: string;
payload: Record<string, unknown>;
createdAt: number;
}
// Producer
async function enqueueJob(
queue: string,
type: string,
payload: Record<string, unknown>
): Promise<string> {
const job: Job = {
id: randomUUID(),
type,
payload,
createdAt: Date.now(),
};
await redis.lpush(`queue:${queue}`, JSON.stringify(job));
return job.id;
}
// Consumer (blocks until a job is available)
async function dequeueJob(
queue: string,
timeout: number = 5
): Promise<Job | null> {
const result = await redis.brpop(`queue:${queue}`, timeout);
if (!result) return null;
return JSON.parse(result[1]) as Job;
}Pour tout ce qui est plus complexe que du queuing basique (retries, dead letter queues, priorité, jobs différés), utilise BullMQ qui s'appuie sur Redis mais gère tous les cas limites.
Sets pour le tracking unique#
Besoin de suivre les visiteurs uniques, dédupliquer des événements, ou vérifier l'appartenance ? Les Sets sont O(1) pour l'ajout, la suppression et les vérifications d'appartenance.
// Track unique visitors per day
async function trackVisitor(
page: string,
visitorId: string
): Promise<boolean> {
const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
const isNew = await redis.sadd(key, visitorId);
// Auto-expire after 48 hours
await redis.expire(key, 172800);
return isNew === 1; // 1 = new member, 0 = already existed
}
// Get unique visitor count
async function getUniqueVisitors(page: string, date: string): Promise<number> {
return redis.scard(`visitors:${page}:${date}`);
}
// Check if user has already performed an action
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}Pour les très grands ensembles (des millions de membres), envisage plutôt HyperLogLog. Il n'utilise que 12 Ko de mémoire quelle que soit la cardinalité, au prix d'une erreur standard d'environ 0,81 % :
// HyperLogLog for approximate unique counts
async function trackVisitorApprox(
page: string,
visitorId: string
): Promise<void> {
const key = `hll:visitors:${page}:${new Date().toISOString().split("T")[0]}`;
await redis.pfadd(key, visitorId);
await redis.expire(key, 172800);
}
async function getApproxUniqueVisitors(
page: string,
date: string
): Promise<number> {
return redis.pfcount(`hll:visitors:${page}:${date}`);
}Sérialisation : JSON vs MessagePack#
JSON est le choix par défaut pour la sérialisation Redis. C'est lisible, universel, et suffisant pour la plupart des cas. Mais pour les systèmes à haut débit, le surcoût de sérialisation/désérialisation s'accumule.
Le problème avec JSON#
const user = {
id: "usr_abc123",
name: "Ahmet Kousa",
email: "ahmet@example.com",
plan: "pro",
preferences: {
theme: "dark",
language: "tr",
notifications: true,
},
};
// JSON: 189 bytes
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
// JSON.parse on a hot path: ~0.02ms per call
// At 10,000 requests/sec: 200ms total CPU time per secondL'alternative MessagePack#
MessagePack est un format de sérialisation binaire plus petit et plus rapide que JSON :
npm install msgpackrimport { pack, unpack } from "msgpackr";
// MessagePack: ~140 bytes (25% smaller)
const packed = pack(user);
console.log(packed.length); // ~140
// Store as Buffer
await redis.set("user:123", packed);
// Read as Buffer
const raw = await redis.getBuffer("user:123");
if (raw) {
const data = unpack(raw);
}Note l'utilisation de getBuffer au lieu de get — c'est critique. get retourne une string et corromprait les données binaires.
Compression pour les grandes valeurs#
Pour les grandes valeurs cachées (réponses API avec des centaines d'éléments, HTML rendu), ajoute de la compression :
import { promisify } from "util";
import { gzip, gunzip } from "zlib";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
async function setCompressed<T>(
key: string,
value: T,
ttl: number
): Promise<void> {
const json = JSON.stringify(value);
// Only compress if larger than 1KB (compression overhead isn't worth it for small values)
if (Buffer.byteLength(json) > 1024) {
const compressed = await gzipAsync(json);
await redis.set(key, compressed, "EX", ttl);
} else {
await redis.set(key, json, "EX", ttl);
}
}
async function getCompressed<T>(key: string): Promise<T | null> {
const raw = await redis.getBuffer(key);
if (!raw) return null;
try {
// Try to decompress first
const decompressed = await gunzipAsync(raw);
return JSON.parse(decompressed.toString()) as T;
} catch {
// Not compressed, parse as regular JSON
return JSON.parse(raw.toString()) as T;
}
}D'après mes tests, la compression gzip réduit typiquement la taille des payloads JSON de 70 à 85 %. Une réponse API de 50 Ko devient 8 Ko. C'est important quand tu paies pour la mémoire Redis — moins de mémoire par clé signifie plus de clés dans la même instance.
Le compromis : la compression ajoute 1 à 3 ms de temps CPU par opération. Pour la plupart des applications, c'est négligeable. Pour les chemins à ultra-faible latence, passe ton tour.
Ma recommandation#
Utilise JSON sauf si le profilage montre que c'est un goulot d'étranglement. La lisibilité et la débuggabilité du JSON dans Redis (tu peux faire redis-cli GET key et lire réellement la valeur) l'emporte sur le gain de performance de MessagePack pour 95 % des applications. N'ajoute la compression que pour les valeurs de plus de 1 Ko.
Redis dans Next.js#
Next.js a sa propre histoire de caching (Data Cache, Full Route Cache, etc.), mais Redis comble les lacunes que le caching intégré ne peut pas gérer — surtout quand tu as besoin de partager le cache entre plusieurs instances ou de persister le cache entre les déploiements.
Cacher les réponses des routes API#
// app/api/products/route.ts
import { NextResponse } from "next/server";
import redis from "@/lib/redis";
export async function GET(request: Request) {
const url = new URL(request.url);
const category = url.searchParams.get("category") || "all";
const cacheKey = `api:products:${category}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return NextResponse.json(JSON.parse(cached), {
headers: {
"X-Cache": "HIT",
"Cache-Control": "public, s-maxage=60",
},
});
}
// Fetch from database
const products = await db.products.findMany({
where: category !== "all" ? { category } : undefined,
orderBy: { createdAt: "desc" },
take: 50,
});
// Cache for 5 minutes with jitter
await redis.set(
cacheKey,
JSON.stringify(products),
"EX",
ttlWithJitter(300)
);
return NextResponse.json(products, {
headers: {
"X-Cache": "MISS",
"Cache-Control": "public, s-maxage=60",
},
});
}Le header X-Cache est inestimable pour le débugage. Quand la latence explose, un rapide curl -I te dit si le cache fonctionne.
Stockage de sessions#
Next.js avec Redis pour les sessions bat les JWT pour les applications stateful :
// lib/session.ts
import { randomUUID } from "crypto";
import redis from "./redis";
interface Session {
userId: string;
role: string;
createdAt: number;
data: Record<string, unknown>;
}
const SESSION_TTL = 86400; // 24 hours
const SESSION_PREFIX = "session:";
export async function createSession(
userId: string,
role: string
): Promise<string> {
const sessionId = randomUUID();
const session: Session = {
userId,
role,
createdAt: Date.now(),
data: {},
};
await redis.set(
`${SESSION_PREFIX}${sessionId}`,
JSON.stringify(session),
"EX",
SESSION_TTL
);
return sessionId;
}
export async function getSession(
sessionId: string
): Promise<Session | null> {
const key = `${SESSION_PREFIX}${sessionId}`;
// Use GETEX to refresh TTL on every access (sliding expiry)
const raw = await redis.getex(key, "EX", SESSION_TTL);
if (!raw) return null;
return JSON.parse(raw) as Session;
}
export async function destroySession(sessionId: string): Promise<void> {
await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
// Destroy all sessions for a user (useful for "logout everywhere")
export async function destroyAllUserSessions(
userId: string
): Promise<void> {
// This requires maintaining a user->sessions index
const sessionIds = await redis.smembers(`user_sessions:${userId}`);
if (sessionIds.length > 0) {
const pipeline = redis.pipeline();
for (const sid of sessionIds) {
pipeline.del(`${SESSION_PREFIX}${sid}`);
}
pipeline.del(`user_sessions:${userId}`);
await pipeline.exec();
}
}Middleware de rate limiting#
// middleware.ts (or a helper used by middleware)
import redis from "@/lib/redis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
export async function rateLimit(
identifier: string,
limit: number = 60,
windowSeconds: number = 60
): Promise<RateLimitResult> {
const key = `rate:${identifier}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
// Lua script for atomic rate limiting
const script = `
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
local count = redis.call('ZCARD', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return count
`;
const count = (await redis.eval(
script,
1,
key,
windowStart,
now,
`${now}:${Math.random()}`,
windowSeconds
)) as number;
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt: now + windowSeconds,
};
}Le script Lua est important ici. Sans lui, la séquence ZREMRANGEBYSCORE + ZADD + ZCARD n'est pas atomique, et sous forte concurrence, le comptage pourrait être inexact. Les scripts Lua s'exécutent de manière atomique dans Redis — ils ne peuvent pas être entrelacés avec d'autres commandes.
Verrous distribués pour Next.js#
Quand tu as plusieurs instances Next.js et que tu dois garantir qu'une seule traite une tâche (comme l'envoi d'un email planifié ou l'exécution d'un job de nettoyage) :
// lib/distributed-lock.ts
import { randomUUID } from "crypto";
import redis from "./redis";
export async function withLock<T>(
lockName: string,
fn: () => Promise<T>,
options: { ttl?: number; retryDelay?: number; maxRetries?: number } = {}
): Promise<T | null> {
const { ttl = 30, retryDelay = 200, maxRetries = 10 } = options;
const token = randomUUID();
const lockKey = `dlock:${lockName}`;
// Try to acquire lock
for (let attempt = 0; attempt < maxRetries; attempt++) {
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
if (acquired) {
try {
// Extend lock automatically for long-running tasks
const extender = setInterval(async () => {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else
return 0
end
`;
await redis.eval(script, 1, lockKey, token, ttl);
}, (ttl * 1000) / 3);
const result = await fn();
clearInterval(extender);
return result;
} finally {
// Release lock only if we still own it
const releaseScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(releaseScript, 1, lockKey, token);
}
}
// Wait before retrying
await new Promise((r) => setTimeout(r, retryDelay));
}
// Could not acquire lock after all retries
return null;
}Utilisation :
// In a cron-triggered API route
export async function POST() {
const result = await withLock("daily-report", async () => {
// Only one instance runs this
const report = await generateDailyReport();
await sendReportEmail(report);
return report;
});
if (result === null) {
return NextResponse.json(
{ message: "Another instance is already processing" },
{ status: 409 }
);
}
return NextResponse.json({ success: true });
}L'intervalle d'extension du verrou à ttl/3 est important. Sans lui, si ta tâche dure plus longtemps que le TTL du verrou, le verrou expire et une autre instance le prend. L'extender maintient le verrou vivant tant que la tâche tourne.
Monitoring et débugage#
Redis est rapide jusqu'à ce qu'il ne le soit plus. Quand les problèmes arrivent, tu as besoin de visibilité.
Ratio de cache hit#
La métrique la plus importante. Suis-la dans ton application :
// lib/cache-metrics.ts
import redis from "./redis";
const METRICS_KEY = "metrics:cache";
export async function recordCacheHit(): Promise<void> {
await redis.hincrby(METRICS_KEY, "hits", 1);
}
export async function recordCacheMiss(): Promise<void> {
await redis.hincrby(METRICS_KEY, "misses", 1);
}
export async function getCacheStats(): Promise<{
hits: number;
misses: number;
hitRate: number;
}> {
const stats = await redis.hgetall(METRICS_KEY);
const hits = parseInt(stats.hits || "0", 10);
const misses = parseInt(stats.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? hits / total : 0,
};
}
// Reset metrics daily
export async function resetCacheStats(): Promise<void> {
await redis.del(METRICS_KEY);
}Un ratio de cache hit sain est au-dessus de 90 %. Si tu es en dessous de 80 %, soit tes TTL sont trop courts, soit tes clés de cache sont trop spécifiques, soit tes patterns d'accès sont plus aléatoires que ce que tu pensais.
La commande INFO#
La commande INFO est le tableau de bord de santé intégré de Redis :
redis-cli INFO memory# Memory
used_memory:1234567
used_memory_human:1.18M
used_memory_peak:2345678
used_memory_peak_human:2.24M
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23
Métriques clés à surveiller :
- used_memory vs maxmemory : tu approches de la limite ?
- mem_fragmentation_ratio : au-dessus de 1.5 signifie que Redis utilise significativement plus de RSS que de mémoire logique. Envisage un redémarrage.
- evicted_keys : si c'est non-nul et que tu n'as pas prévu d'éviction, tu manques de mémoire.
redis-cli INFO statsSurveille :
- keyspace_hits / keyspace_misses : ratio de hit au niveau serveur
- total_commands_processed : débit
- instantaneous_ops_per_sec : débit actuel
MONITOR (à utiliser avec extrême précaution)#
MONITOR diffuse en temps réel chaque commande exécutée sur le serveur Redis. C'est incroyablement utile pour le débugage et incroyablement dangereux en production.
# NEVER leave this running in production
# It adds significant overhead and can log sensitive data
redis-cli MONITOR1614556800.123456 [0 127.0.0.1:52340] "SET" "cache:user:123" "{\"name\":\"Ahmet\"}" "EX" "1800"
1614556800.234567 [0 127.0.0.1:52340] "GET" "cache:user:456"
J'utilise MONITOR pour exactement deux choses : débuguer les problèmes de nommage de clés pendant le développement, et vérifier qu'un chemin de code spécifique tape Redis comme attendu. Jamais plus de 30 secondes. Jamais en production sauf si tu as déjà épuisé toutes les autres options de débugage.
Notifications de keyspace#
Tu veux savoir quand des clés expirent ou sont supprimées ? Redis peut publier des événements :
# Enable keyspace notifications for expired and evicted events
redis-cli CONFIG SET notify-keyspace-events Exconst subscriber = new Redis(/* config */);
// Listen for key expiration events
subscriber.subscribe("__keyevent@0__:expired", (err) => {
if (err) console.error("Subscribe error:", err);
});
subscriber.on("message", (_channel, expiredKey) => {
console.log(`Key expired: ${expiredKey}`);
// Proactively regenerate important keys
if (expiredKey.startsWith("cache:homepage")) {
regenerateHomepageCache().catch(console.error);
}
});C'est utile pour le préchauffage proactif du cache — au lieu d'attendre qu'un utilisateur déclenche un cache miss, tu régénères les entrées critiques au moment où elles expirent.
Analyse mémoire#
Quand la mémoire Redis grandit de manière inattendue, tu dois trouver quelles clés consomment le plus :
# Sample 10 largest keys
redis-cli --bigkeys# Scanning the entire keyspace to find biggest keys
[00.00%] Biggest string found so far '"cache:search:electronics"' with 524288 bytes
[25.00%] Biggest zset found so far '"leaderboard:global"' with 150000 members
[50.00%] Biggest hash found so far '"session:abc123"' with 45 fields
Pour une analyse plus détaillée :
# Memory usage of a specific key (in bytes)
redis-cli MEMORY USAGE "cache:search:electronics"// Programmatic memory analysis
async function analyzeMemory(pattern: string): Promise<void> {
let cursor = "0";
const stats: Array<{ key: string; bytes: number }> = [];
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
for (const key of keys) {
const bytes = await redis.memory("USAGE", key);
if (bytes) {
stats.push({ key, bytes: bytes as number });
}
}
} while (cursor !== "0");
// Sort by size descending
stats.sort((a, b) => b.bytes - a.bytes);
console.log("Top 20 keys by memory usage:");
for (const { key, bytes } of stats.slice(0, 20)) {
const mb = (bytes / 1024 / 1024).toFixed(2);
console.log(` ${key}: ${mb} MB`);
}
}Politiques d'éviction#
Si ton instance Redis a une limite maxmemory (elle devrait), configure une politique d'éviction :
# In redis.conf or via CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lruPolitiques disponibles :
- noeviction : retourne une erreur quand la mémoire est pleine (par défaut, le pire pour le caching)
- allkeys-lru : évince la clé la moins récemment utilisée (meilleur choix généraliste pour le caching)
- allkeys-lfu : évince la clé la moins fréquemment utilisée (meilleur si certaines clés sont accédées par rafales)
- volatile-lru : n'évince que les clés avec un TTL défini (utile si tu mélanges cache et données persistantes)
- allkeys-random : éviction aléatoire (étonnamment correct, pas de surcoût)
Pour les charges de travail purement cache, allkeys-lfu est généralement le meilleur choix. Il garde les clés fréquemment accédées en mémoire même si elles n'ont pas été accédées récemment.
Tout assembler : un module de cache production#
Voici le module de cache complet que j'utilise en production, combinant tout ce dont on a discuté :
// lib/cache.ts
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 200, 5000);
},
});
// TTL tiers
const TTL = {
STATIC: 86400,
MODERATE: 1800,
VOLATILE: 300,
EPHEMERAL: 60,
NOT_FOUND: 120,
} as const;
type TTLTier = keyof typeof TTL;
function ttlWithJitter(base: number, jitter = 0.1): number {
const offset = base * jitter * (Math.random() * 2 - 1);
return Math.max(1, Math.round(base + offset));
}
// Core cache-aside with stampede protection
async function get<T>(
key: string,
fetcher: () => Promise<T>,
options: {
tier?: TTLTier;
ttl?: number;
tags?: string[];
swr?: { freshTtl: number; staleTtl: number };
} = {}
): Promise<T> {
const { tier = "MODERATE", tags } = options;
const baseTtl = options.ttl ?? TTL[tier];
const cacheKey = `c:${key}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
const parsed = JSON.parse(cached);
recordHit();
return parsed as T;
} catch {
await redis.del(cacheKey);
}
}
recordMiss();
// Acquire lock to prevent stampede
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (!acquired) {
// Another process is fetching — wait briefly and retry cache
await new Promise((r) => setTimeout(r, 150));
const retried = await redis.get(cacheKey);
if (retried) return JSON.parse(retried) as T;
}
try {
const result = await fetcher();
const ttl = ttlWithJitter(baseTtl);
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
// Store tag associations
if (tags) {
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, cacheKey);
pipeline.expire(`tag:${tag}`, ttl + 3600);
}
}
await pipeline.exec();
return result;
} finally {
await redis.del(lockKey);
}
}
// Invalidation
async function invalidate(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
await redis.del(...keys.map((k) => `c:${k}`));
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}
// Metrics
function recordHit(): void {
redis.hincrby("metrics:cache", "hits", 1).catch(() => {});
}
function recordMiss(): void {
redis.hincrby("metrics:cache", "misses", 1).catch(() => {});
}
async function stats(): Promise<{
hits: number;
misses: number;
hitRate: string;
}> {
const raw = await redis.hgetall("metrics:cache");
const hits = parseInt(raw.hits || "0", 10);
const misses = parseInt(raw.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "N/A",
};
}
export const cache = {
get,
invalidate,
invalidateByTag,
stats,
redis,
TTL,
};Utilisation à travers l'application :
import { cache } from "@/lib/cache";
// Simple cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
tier: "VOLATILE",
tags: ["entity:products"],
});
// With custom TTL
const config = await cache.get("app:config", fetchAppConfig, {
ttl: 43200, // 12 hours
});
// After updating a product
await cache.invalidateByTag("entity:products");
// Check health
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);Les erreurs courantes que j'ai faites (pour que tu n'aies pas à les faire)#
1. Ne pas définir maxmemory. Redis utilisera joyeusement toute la mémoire disponible jusqu'à ce que l'OS le tue. Définis toujours une limite.
2. Utiliser KEYS en production. Ça bloque le serveur. Utilise SCAN. J'ai appris ça quand un appel KEYS * depuis un script de monitoring a causé 3 secondes d'indisponibilité.
3. Cacher trop agressivement. Tout n'a pas besoin d'être caché. Si ta requête de base de données prend 2 ms et est appelée 10 fois par minute, le caching ajoute de la complexité pour un bénéfice négligeable.
4. Ignorer les coûts de sérialisation. J'ai une fois caché un blob JSON de 2 Mo et j'étais perplexe que les lectures en cache soient lentes. Le surcoût de sérialisation était plus important que la requête de base de données qu'il était censé économiser.
5. Pas de dégradation gracieuse. Quand Redis tombe, ton app devrait toujours fonctionner — juste plus lentement. Enveloppe chaque appel au cache dans un try/catch qui se rabat sur la base de données. Ne laisse jamais une défaillance du cache devenir une erreur visible par l'utilisateur.
async function resilientGet<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
try {
return await cache.get(key, fetcher);
} catch (err) {
console.error(`[Cache] Degraded mode for ${key}:`, err);
return fetcher(); // Bypass cache entirely
}
}6. Ne pas surveiller les évictions. Si Redis évince des clés, tu es soit sous-dimensionné soit tu caches trop. Dans les deux cas, tu dois le savoir.
7. Partager une instance Redis entre cache et données persistantes. Utilise des instances séparées (ou au moins des bases de données séparées). Une politique d'éviction de cache qui supprime les entrées de ta file d'attente de jobs est une mauvaise journée pour tout le monde.
Conclusion#
Le caching Redis n'est pas compliqué, mais il est facile de le faire mal. Commence par le cache-aside, ajoute le TTL jitter dès le premier jour, surveille ton ratio de hit, et résiste à l'envie de tout cacher.
La meilleure stratégie de caching est celle sur laquelle tu peux raisonner à 3 h du matin quand quelque chose casse. Garde ça simple, garde ça observable, et souviens-toi que chaque valeur cachée est un mensonge que tu as raconté à tes utilisateurs sur l'état de tes données — ton travail est de garder ce mensonge aussi petit et éphémère que possible.