属于我们的Performance & Scalability系列
阅读完整指南Web 应用程序的 Redis 缓存模式
数据库查询是 Web 应用程序中最常见的性能瓶颈。一个索引不良的查询需要 200 毫秒,在 1,000 个并发用户的每个页面加载时调用,每秒会产生 200 秒的数据库 CPU — 死亡螺旋。 Redis 是解药:一种亚毫秒级的内存存储,可以吸收读取负载,否则这些负载会重复访问数据库以获取相同的数据。
但缓存不仅仅是“把东西放到Redis里”。错误的模式会导致过时的数据错误、惊群灾难或无限制的内存增长。本指南涵盖了四种规范的缓存模式、缓存失效策略、踩踏预防和生产 NestJS 实现,以便您从一开始就正确缓存。
要点
- Cache-aside(延迟加载)是最安全的默认设置 - 只缓存实际请求的内容
- 直写使缓存和数据库保持同步,但增加了写入延迟 - 用于频繁读取、不频繁写入的数据
- 当流行密钥过期时,缓存踩踏(惊群)会破坏数据库 - 使用概率提前过期或互斥锁
- 必须在每个键上设置 TTL - Redis 无限增长是一个等待发生的事件
- 缓存失效很难:更喜欢短 TTL 和事件驱动的失效,而不是尝试完美失效
- 缓存键必须包含所有查询维度:
contacts:{orgId}:{page}:{limit}:{search}:{sortField}:{sortDir}- 使用 Redis 命名空间和键模式来启用批量失效
- 监控缓存命中率;低于 80% 意味着您的 TTL 太短或您的密钥结构错误
NestJS 中的 Redis 设置
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 {}
模式 1:Cache-Aside(延迟加载)
最常见的模式。从缓存中读取;如果未命中,则从数据库读取并填充缓存。
// 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;
});
}
优点:简单;缓存仅包含实际请求的内容。 缺点:第一个请求总是很慢(缓存未命中);在 TTL 到期之前,数据可能会过时。
何时失效:创建/更新/删除后,删除所有匹配 contacts:{orgId}:* 的键。
模式 2:直写式
每次写入时与数据库同时更新缓存。以写入延迟换取读取一致性。
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;
}
何时使用:经常读取、不经常写入的记录(用户配置文件、设置、产品目录)。
模式 3:缓存踩踏预防
当一个流行的缓存键过期时,数百个并发请求都会同时错过缓存并全部命中数据库——“惊群”。数据库在突然的负载下崩溃了。
互斥锁模式
只有一次请求重建缓存;其他人等待并重试:
// 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);
}
}
概率提前过期 (XFetch)
在缓存过期之前开始概率性地重建缓存,分散重新计算负载:
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;
}
模式 4:基于标签的缓存失效
按逻辑标签对缓存键进行分组并立即使整个标签无效:
// 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}`);
会话和身份验证缓存
Redis 是身份验证会话存储的理想选择 - 消除每个请求的数据库查找:
// 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}`);
}
按数据类型划分的 TTL 策略
| 数据类型 | 推荐TTL | 无效策略 |
|---|---|---|
| 用户简介 | 15 分钟 | 关于个人资料更新 |
| 组织设置 | 1小时 | 关于设置更改 |
| 产品目录 | 24小时 | 关于产品创建/更新 |
| 博客文章列表 | 10 分钟 | 发布后 |
| API 速率限制计数器 | 每个窗口(60 秒) | 自动过期 |
| 身份验证会话 | 15 分钟 | 注销或令牌撤销时 |
| 搜索结果 | 5 分钟 | 仅限 TTL |
| 聚合指标 | 1 分钟 | 仅限 TTL |
| 一次性代码(授权) | 60 秒 | 使用后或过期后 |
| 分页列表查询 | 5 分钟 | 关于组织中的任何突变 |
监控缓存性能
// 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');
}
通过 redis-cli INFO stats 监控的关键 Redis 指标:
keyspace_hits/keyspace_misses— 全局命中率(目标超过 80%)used_memory— 关注无限增长evicted_keys— 由最大内存策略逐出的键(对于缓存侧应该接近 0)connected_clients— 连接池利用率instantaneous_ops_per_sec— 当前吞吐量
常见问题
我应该为缓存使用什么最大内存策略?
使用 allkeys-lru (从所有键中删除最近最少使用的键)或 volatile-lru (删除设置了 TTL 的 LRU 键)。对于纯缓存工作负载,allkeys-lru 是标准的 - 当内存已满时,Redis 会自动驱逐冷键。切勿使用 noeviction 进行缓存 - 当内存已满时它会返回错误而不是逐出旧数据。
如何避免在 Redis 中存储敏感数据?
切勿缓存原始密码、私钥、支付卡数据或 SSN。对于身份验证会话,缓存最少的数据:userId、role、organizationId、tokenVersion — 不是完整的 JWT 有效负载或 API 凭据。在生产中启用 Redis AUTH 和 TLS。如果您的 Redis 实例受到威胁,当您遵循此规则时,只有元数据会被暴露。
模式删除时SCAN和KEYS有什么区别?
KEYS pattern 是一个阻塞 O(n) 操作,它会在扫描时暂停所有 Redis 命令 - 它可能会导致大型密钥空间出现数秒的停机时间。 SCAN 是非阻塞的,使用游标以小块进行迭代。始终使用 SCAN 进行生产模式删除。权衡是,如果在扫描期间添加或删除了所有键,SCAN 可能不会返回所有键 - 对于缓存失效来说是可以接受的。
我应该使用单独的 Redis 实例进行缓存、速率限制还是会话?
对于大多数应用程序来说,一个具有不同键前缀的 Redis 实例就足够了。在以下情况下,单独的实例有意义:缓存需要与速率限制存储不同的 maxmemory-policy(缓存:allkeys-lru,速率限制:noeviction),或者当您需要独立扩展和故障转移时。大规模时,将 Redis 集群与每个工作负载类型的单独集群一起使用。
如何处理分页列表查询的缓存失效?
分页列表缓存很棘手 - 在第 1 页上添加联系人会移动第 2 页以上的所有联系人。务实的解决方案:使用短 TTL(2-5 分钟),并使用模式无效 (contacts:{orgId}:*) 在任何写入时使组织的所有页面无效。对于写入量大的大型组织,完全跳过缓存分页并依赖数据库级优化(适当的索引、覆盖索引)。
如何在 Vitest 中测试缓存行为?
在单元测试中模拟 CacheService,使用 vi.fn() 返回命中/未命中场景的适当值。对于集成测试,请使用真实的 Redis 实例(Docker)并使用 beforeEach 中的 redis.flushdb() 来启动 clean。显式测试缓存命中路径(无数据库调用)和缓存未命中路径(数据库调用 + 缓存填充)。
后续步骤
Redis 缓存是 Web 应用程序中投资回报率最高的性能投资之一。 200 毫秒的数据库查询和 2 毫秒的 Redis 命中之间的差异是 100 倍——从规模上看,这直接转化为基础设施成本的节省和用户体验的改善。
ECOSIRE 在每个 NestJS 项目上实现了 Redis 缓存,包括缓存旁路、直写失效、踩踏预防和缓存命中率监控。 探索我们的后端工程服务 了解我们如何设计高性能、可扩展的 API。
作者
ECOSIRE Research and Development Team
在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。
相关文章
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.
更多来自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.