Fait partie de notre série Performance & Scalability
Lire le guide completModèles de mise en cache Redis pour les applications Web
Les requêtes de base de données constituent le goulot d'étranglement de performances le plus courant dans les applications Web. Une requête mal indexée qui prend 200 ms, appelée à chaque chargement de page pour 1 000 utilisateurs simultanés, génère 200 secondes de CPU de base de données par seconde – une spirale mortelle. Redis est l'antidote : un stockage en mémoire inférieur à la milliseconde qui absorbe la charge de lecture qui autrement frapperait votre base de données à plusieurs reprises pour des données identiques.
Mais la mise en cache ne consiste pas simplement à « mettre des choses dans Redis ». Un mauvais modèle provoque des bugs de données obsolètes, des désastres tonitruants ou une croissance illimitée de la mémoire. Ce guide couvre les quatre modèles de mise en cache canoniques, les stratégies d'invalidation du cache, la prévention des bousculades et la mise en œuvre de NestJS en production afin que vous puissiez mettre en cache correctement dès le début.
Points clés à retenir
- Le cache-aside (chargement paresseux) est la valeur par défaut la plus sûre : ne cache que ce qui est réellement demandé.
- L'écriture directe maintient le cache et la base de données synchronisés mais ajoute une latence d'écriture - à utiliser pour les données fréquemment lues et rarement écrites.
- La bousculade du cache (troupeau tonitruant) détruit les bases de données lorsqu'une clé populaire expire - utilisez une expiration anticipée probabiliste ou des verrous mutex
- Le TTL doit être défini sur chaque clé : la croissance illimitée de Redis est un incident qui attend de se produire
- L'invalidation du cache est difficile : préférez les TTL courts et l'invalidation basée sur les événements plutôt que d'essayer d'invalider parfaitement
- Les clés de cache doivent inclure toutes les dimensions de la requête :
contacts:{orgId}:{page}:{limit}:{search}:{sortField}:{sortDir}- Utilisez les espaces de noms et les modèles de clés Redis pour activer l'invalidation groupée
- Surveiller le taux de réussite du cache ; en dessous de 80 % signifie que vos durées de vie sont trop courtes ou que votre structure de clé est erronée
Configuration de Redis dans NestJS
pnpm add ioredis @nestjs-modules/ioredis
// src/modules/cache/cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
@Injectable()
export class CacheService {
private readonly logger = new Logger(CacheService.name);
constructor(@InjectRedis() private readonly redis: Redis) {}
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key);
if (!value) return null;
return JSON.parse(value) as T;
}
async set<T>(key: string, value: T, ttlSeconds = 300): Promise<void> {
await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
}
async del(key: string): Promise<void> {
await this.redis.del(key);
}
// Use SCAN instead of KEYS to avoid blocking Redis on large key spaces
async invalidatePattern(pattern: string): Promise<void> {
let cursor = '0';
do {
const [nextCursor, keys] = await this.redis.scan(
cursor,
'MATCH', pattern,
'COUNT', 100
);
cursor = nextCursor;
if (keys.length > 0) {
await this.redis.del(...keys);
}
} while (cursor !== '0');
}
async remember<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>
): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== null) return cached;
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
}
}
// src/modules/cache/cache.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-modules/ioredis';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheService } from './cache.service';
@Global()
@Module({
imports: [
RedisModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'single',
options: {
host: config.get('REDIS_HOST', 'localhost'),
port: config.get<number>('REDIS_PORT', 6379),
password: config.get('REDIS_PASSWORD'),
db: config.get<number>('REDIS_DB', 0),
keyPrefix: 'ecosire:',
},
}),
}),
],
providers: [CacheService],
exports: [CacheService],
})
export class AppCacheModule {}
Modèle 1 : Cache-Aside (chargement paresseux)
Le modèle le plus courant. Lire depuis le cache ; en cas d'échec, lisez à partir de la base de données et remplissez le cache.
// contacts.service.ts
async findAll(orgId: string, page: number, limit: number, search?: string) {
const cacheKey = `contacts:${orgId}:${page}:${limit}:${search ?? 'all'}`;
return this.cache.remember(cacheKey, 300, async () => {
const results = await this.db.query.contacts.findMany({
where: and(
eq(contacts.organizationId, orgId),
search
? or(
ilike(contacts.name, `%${search}%`),
ilike(contacts.email, `%${search}%`)
)
: undefined
),
limit,
offset: (page - 1) * limit,
orderBy: desc(contacts.createdAt),
});
return results;
});
}
Avantages : Simple ; le cache ne contient que ce qui est réellement demandé. Inconvénients : la première requête est toujours lente (manque de cache) ; les données peuvent être périmées jusqu’à l’expiration de la durée de vie.
Quand invalider : après la création/mise à jour/suppression, supprimez toutes les clés correspondant à contacts:{orgId}:*.
Modèle 2 : écriture directe
Mettez à jour le cache simultanément avec la base de données à chaque écriture. Les métiers écrivent la latence pour la cohérence de la lecture.
async update(orgId: string, contactId: string, dto: UpdateContactDto) {
// 1. Update the database
const [updated] = await this.db
.update(contacts)
.set({ ...dto, updatedAt: new Date() })
.where(
and(
eq(contacts.id, contactId),
eq(contacts.organizationId, orgId)
)
)
.returning();
// 2. Update the individual contact cache immediately (write-through)
const singleKey = `contact:${orgId}:${contactId}`;
await this.cache.set(singleKey, updated, 3600);
// 3. Invalidate list caches (they now contain stale order/counts)
await this.cache.invalidatePattern(`contacts:${orgId}:*`);
return updated;
}
Quand l'utiliser : enregistrements fréquemment lus et rarement rédigés (profils utilisateur, paramètres, catalogue de produits).
Modèle 3 : Prévention du cache Stampede
Lorsqu’une clé de cache populaire expire, des centaines de requêtes simultanées manquent toutes simultanément le cache et atteignent toutes la base de données – le « troupeau tonitruant ». La base de données s'effondre sous la charge soudaine.
Modèle de verrouillage mutex
Une seule requête reconstruit le cache ; d'autres attendent et réessayent :
// cache.service.ts — mutex-protected cache fetch
async getWithLock<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>
): Promise<T> {
// Try cache first
const cached = await this.get<T>(key);
if (cached !== null) return cached;
const lockKey = `lock:${key}`;
const lockOwner = `${process.pid}-${Date.now()}`;
// Try to acquire lock: NX = only set if not exists, EX = expire in 30s
const acquired = await this.redis.set(lockKey, lockOwner, 'NX', 'EX', 30);
if (acquired === 'OK') {
try {
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
} finally {
// Atomic check-and-delete: only release if we still own the lock
// Uses Lua defined with defineCommand for atomicity
const currentOwner = await this.redis.get(lockKey);
if (currentOwner === lockOwner) {
await this.redis.del(lockKey);
}
}
} else {
// Another process is rebuilding — wait 100ms and retry
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 100);
// Store timer reference so Node.js does not block on it
timer.unref?.();
});
return this.getWithLock(key, ttlSeconds, factory);
}
}
Expiration anticipée probabiliste (XFetch)
Commencez à reconstruire le cache de manière probabiliste avant son expiration, en répartissant la charge de recalcul :
async getWithEarlyExpiration<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>,
beta = 1 // Higher = more aggressive early recomputation
): Promise<T> {
const metaKey = `${key}:meta`;
const meta = await this.get<{ createdAt: number; ttl: number }>(metaKey);
const value = await this.get<T>(key);
if (value !== null && meta) {
const elapsed = Date.now() / 1000 - meta.createdAt;
const remaining = meta.ttl - elapsed;
// XFetch: trigger early recomputation probabilistically
const shouldRecompute = -Math.log(Math.random()) * beta > remaining;
if (!shouldRecompute) return value;
}
const newValue = await factory();
const now = Date.now() / 1000;
await this.set(key, newValue, ttlSeconds);
await this.set(metaKey, { createdAt: now, ttl: ttlSeconds }, ttlSeconds + 60);
return newValue;
}
Modèle 4 : Invalidation du cache basée sur les balises
Regroupez les clés de cache par balises logiques et invalidez une balise entière à la fois :
// Tag-based invalidation using Redis sets
async setWithTags(
key: string,
value: unknown,
ttlSeconds: number,
tags: string[]
): Promise<void> {
await this.set(key, value, ttlSeconds);
// Add key to each tag's member set using a pipeline
const pipeline = this.redis.pipeline();
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, key);
pipeline.expire(`tag:${tag}`, ttlSeconds + 60);
}
await pipeline.exec();
}
async invalidateByTag(tag: string): Promise<void> {
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length > 0) {
const pipeline = this.redis.pipeline();
for (const cacheKey of keys) {
pipeline.del(cacheKey);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
}
}
// Usage
await this.setWithTags(
`contacts:${orgId}:page:1`,
contacts,
300,
[`org:${orgId}`, 'contacts']
);
// After any contact mutation in org_123:
await this.invalidateByTag(`org:${orgId}`);
Session et mise en cache d'authentification
Redis est idéal pour le stockage de sessions d'authentification : éliminez les recherches dans la base de données à chaque requête :
// Cache user session data (role, permissions, orgId) for JWT validation
async cacheUserSession(userId: string, session: UserSession): Promise<void> {
const key = `session:${userId}`;
await this.redis.hset(key,
'id', session.id,
'email', session.email,
'role', session.role,
'organizationId', session.organizationId,
'tokenVersion', String(session.tokenVersion)
);
await this.redis.expire(key, 900); // 15 minutes — matches access token TTL
}
async getUserSession(userId: string): Promise<UserSession | null> {
const data = await this.redis.hgetall(`session:${userId}`);
if (!data || Object.keys(data).length === 0) return null;
return {
...data,
tokenVersion: parseInt(data.tokenVersion, 10),
} as UserSession;
}
async invalidateUserSession(userId: string): Promise<void> {
await this.redis.del(`session:${userId}`);
}
Stratégie TTL par type de données
| Type de données | Durée de vie recommandée | Stratégie d'invalidation |
|---|---|---|
| Profil utilisateur | 15 minutes | Lors de la mise à jour du profil |
| Paramètres de l'organisation | 1 heure | Lors du changement des paramètres |
| Catalogue de produits | 24 heures | Sur la création/mise à jour du produit |
| Liste des articles de blog | 10 minutes | En post-publication |
| Compteur de limite de débit API | Par fenêtre (années 60) | Expiration automatique |
| Session d'authentification | 15 minutes | Lors de la déconnexion ou de la révocation du jeton |
| Résultats de recherche | 5 minutes | TTL uniquement |
| Métriques agrégées | 1 minute | TTL uniquement |
| Codes à usage unique (authentification) | 60 secondes | Après utilisation ou expiration |
| Requête de liste paginée | 5 minutes | Sur toute mutation dans l'organisation |
Surveillance des performances du cache
// Add hit/miss tracking to CacheService
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key);
if (value) {
await this.redis.incr('metrics:cache_hits');
return JSON.parse(value) as T;
}
await this.redis.incr('metrics:cache_misses');
return null;
}
// Report hit rate every 5 minutes
@Cron('*/5 * * * *')
async reportCacheMetrics(): Promise<void> {
const [hits, misses] = await this.redis.mget(
'metrics:cache_hits',
'metrics:cache_misses'
);
const h = parseInt(hits ?? '0', 10);
const m = parseInt(misses ?? '0', 10);
const total = h + m;
const hitRate = total === 0 ? 1 : h / total;
if (hitRate < 0.8) {
this.logger.warn(`Low cache hit rate: ${(hitRate * 100).toFixed(1)}%`);
}
// Reset counters
await this.redis.set('metrics:cache_hits', '0');
await this.redis.set('metrics:cache_misses', '0');
}
Métriques Redis clés à surveiller via redis-cli INFO stats :
keyspace_hits/keyspace_misses— taux de réussite global (objectif supérieur à 80 %)used_memory— surveillez une croissance illimitéeevicted_keys— clés expulsées par la politique maxmemory (doivent être proches de 0 pour le cache-aside)connected_clients— utilisation du pool de connexionsinstantaneous_ops_per_sec— débit actuel
Questions fréquemment posées
Quelle stratégie de mémoire maximale dois-je utiliser pour un cache ?
Utilisez allkeys-lru (expulsez les clés les moins récemment utilisées de toutes les clés) ou volatile-lru (expulsez les clés LRU qui ont un TTL défini). Pour une charge de travail de cache pur, allkeys-lru est standard : Redis expulse automatiquement les clés froides lorsque la mémoire est pleine. N'utilisez jamais noeviction pour le cache - il renvoie des erreurs lorsque la mémoire est pleine au lieu d'expulser les anciennes données.
Comment éviter de stocker des données sensibles dans Redis ?
Ne mettez jamais en cache les mots de passe bruts, les clés privées, les données de carte de paiement ou les SSN. Pour les sessions d'authentification, mettez en cache les données minimales : userId, role, OrganizationId, tokenVersion – et non les charges utiles JWT complètes ou les informations d'identification de l'API. Activez Redis AUTH et TLS en production. Si votre instance Redis est compromise, seules les métadonnées sont exposées lorsque vous suivez cette discipline.
Quelle est la différence entre SCAN et KEYS pour la suppression de modèle ?
KEYS pattern est une opération O(n) bloquante qui met en pause toutes les commandes Redis pendant l'analyse - cela peut entraîner des secondes de temps d'arrêt sur de grands espaces clés. SCAN est non bloquant, itérant en petits morceaux avec un curseur. Utilisez toujours SCAN pour la suppression du modèle de production. Le compromis est que SCAN peut ne pas renvoyer toutes les clés si elles sont ajoutées ou supprimées pendant l'analyse – ce qui est acceptable pour l'invalidation du cache.
Dois-je utiliser une instance Redis distincte pour la mise en cache, la limitation du débit et les sessions ?
Pour la plupart des applications, une instance Redis avec différents préfixes de clé convient. Des instances distinctes ont du sens lorsque : le cache a besoin d'un maxmemory-policy différent de celui du magasin de limites de débit (cache : allkeys-lru, limites de débit : noeviction), ou lorsque vous avez besoin d'une mise à l'échelle et d'un basculement indépendants. À grande échelle, utilisez Redis Cluster avec des clusters distincts par type de charge de travail.
Comment gérer l'invalidation du cache pour les requêtes de liste paginée ?
Les caches de listes paginées sont délicats : l'ajout d'un contact sur la page 1 déplace tous les contacts sur les pages 2+. La solution pragmatique : utilisez des TTL courtes (2 à 5 minutes) et invalidez toutes les pages de l'organisation à chaque écriture à l'aide de l'invalidation de modèle (contacts:{orgId}:*). Pour les grandes organisations avec des volumes d'écriture importants, évitez complètement la pagination de la mise en cache et comptez plutôt sur l'optimisation au niveau de la base de données (index appropriés, index de couverture).
Comment tester le comportement du cache dans Vitest ?
Moquez le CacheService dans les tests unitaires avec vi.fn() renvoyant les valeurs appropriées pour les scénarios hit/miss. Pour les tests d'intégration, utilisez une véritable instance Redis (Docker) et utilisez redis.flushdb() dans beforeEach pour démarrer le nettoyage. Testez explicitement le chemin d’accès au cache (pas d’appel à la base de données) et le chemin d’échec du cache (base de données appelée + cache rempli).
Prochaines étapes
La mise en cache Redis est l'un des investissements en termes de performances avec le retour sur investissement le plus élevé dans une application Web. La différence entre une requête de base de données de 200 ms et un accès Redis de 2 ms est 100x — à grande échelle, cela se traduit directement par des économies de coûts d'infrastructure et une amélioration de l'expérience utilisateur.
ECOSIRE implémente la mise en cache Redis avec mise en cache, invalidation en écriture, prévention des bousculades et surveillance du taux de réussite du cache sur chaque projet NestJS. Découvrez nos services d'ingénierie backend pour découvrir comment nous concevons des API performantes et évolutives.
Rédigé par
ECOSIRE Research and Development Team
Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.
Articles connexes
API Rate Limiting: Patterns and Best Practices
Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
NestJS 11 Enterprise API Patterns
Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.
Plus de Performance & Scalability
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
Nginx Production Configuration: SSL, Caching, and Security
Nginx production configuration guide: SSL termination, HTTP/2, caching headers, security headers, rate limiting, reverse proxy setup, and Cloudflare integration patterns.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Odoo vs Acumatica: Cloud ERP for Growing Businesses
Odoo vs Acumatica compared for 2026: unique pricing models, scalability, manufacturing depth, and which cloud ERP fits your growth trajectory.
Testing and Monitoring AI Agents in Production
A complete guide to testing and monitoring AI agents in production environments. Covers evaluation frameworks, observability, drift detection, and incident response for OpenClaw deployments.
Compliance Monitoring Agents with OpenClaw
Deploy OpenClaw AI agents for continuous compliance monitoring. Automate regulatory checks, policy enforcement, audit trail generation, and compliance reporting.