数据缓存
一、缓存的适用场景
| 场景 | 收益 | 风险 |
|---|---|---|
| 热点只读数据(文章列表、分类) | 大幅降低数据库压力 | 数据短暂不一致 |
| 计算代价高的聚合查询 | 节省 CPU 时间 | 同上 |
| 外部 API 响应(汇率、天气) | 降低第三方调用费用 | 同上 |
| Session / Token 存储 | 快速读写 | 需持久化(重启不丢) |
| 限流计数器 | 亚毫秒级读写 | — |
不适合缓存: 强一致性要求的数据(库存、余额)、写多读少的数据。
二、安装配置
bash
npm install @nestjs/cache-manager cache-manager
# Redis 存储(多实例部署必须)
npm install cache-manager-redis-yet ioredis内存缓存(单实例开发)
typescript
// app.module.ts
import { CacheModule } from '@nestjs/cache-manager';
@Module({
imports: [
CacheModule.register({
isGlobal: true,
ttl: 5 * 60 * 1000, // 默认 5 分钟(ms)
max: 1000, // 最多缓存 1000 条
}),
],
})Redis 缓存(生产推荐)
typescript
import { redisStore } from 'cache-manager-redis-yet';
CacheModule.registerAsync({
isGlobal: true,
inject: [ConfigService],
useFactory: async (config: ConfigService) => ({
store: redisStore,
url: config.get('REDIS_URL', 'redis://localhost:6379'),
ttl: 5 * 60 * 1000,
// 连接重试
socket: {
reconnectStrategy: (retries: number) => Math.min(retries * 100, 3000),
},
}),
})三、自动缓存(CacheInterceptor)
最简单的用法——自动以 URL 作为 key 缓存响应:
typescript
import { CacheInterceptor, CacheTTL, CacheKey } from '@nestjs/cache-manager';
import { UseInterceptors } from '@nestjs/common';
@UseInterceptors(CacheInterceptor)
@Controller('posts')
export class PostsController {
// GET /posts → 自动缓存,key = /posts,TTL = 全局默认
@Get()
findAll() {
return this.postsService.findAll();
}
// 自定义 TTL(30 秒)
@CacheTTL(30 * 1000)
@Get('trending')
findTrending() {
return this.postsService.findTrending();
}
// 自定义 key(不受 URL 参数影响)
@CacheKey('popular-posts')
@CacheTTL(10 * 60 * 1000)
@Get('popular')
findPopular() {
return this.postsService.findPopular();
}
}⚠️ CacheInterceptor 仅对 GET 请求自动缓存。POST/PUT/DELETE 不会缓存。
全局缓存拦截器
typescript
// app.module.ts
providers: [
{
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor, // 所有 GET 路由自动缓存
},
],四、手动缓存控制(推荐)
比自动缓存更精细,适合业务逻辑复杂的场景:
typescript
// posts/posts.service.ts
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
@Injectable()
export class PostsService {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
@InjectRepository(Post) private postRepo: Repository<Post>,
) {}
// ── 读:Cache-Aside 模式 ──────────────────────
async findOne(id: number): Promise<Post> {
const cacheKey = `post:${id}`;
// 1. 先查缓存
const cached = await this.cacheManager.get<Post>(cacheKey);
if (cached) {
console.log(`缓存命中: ${cacheKey}`);
return cached;
}
// 2. 缓存未命中,查数据库
const post = await this.postRepo.findOne({
where: { id },
relations: { author: true, tags: true },
});
if (!post) throw new NotFoundException(`文章 #${id} 不存在`);
// 3. 写入缓存
await this.cacheManager.set(cacheKey, post, 10 * 60 * 1000); // 10 分钟
return post;
}
// ── 写:更新时清除相关缓存 ────────────────────
async update(id: number, dto: UpdatePostDto): Promise<Post> {
const post = await this.postRepo.save({ id, ...dto });
// 清除该文章的缓存
await this.cacheManager.del(`post:${id}`);
// 清除列表缓存(因为内容变了)
await this.cacheManager.del('posts:list');
await this.cacheManager.del('posts:trending');
return post;
}
async remove(id: number): Promise<void> {
await this.postRepo.softDelete(id);
await this.cacheManager.del(`post:${id}`);
await this.invalidateListCache();
}
private async invalidateListCache() {
// 按模式批量删除(需要 Redis store 支持)
const store = this.cacheManager.store as any;
if (typeof store.keys === 'function') {
const keys = await store.keys('posts:*');
await Promise.all(keys.map((k: string) => this.cacheManager.del(k)));
}
}
}五、缓存分层策略
typescript
@Injectable()
export class PostsService {
async findAll(query: PaginationDto) {
const cacheKey = `posts:list:${JSON.stringify(query)}`;
// L1:内存缓存(极快,但多实例不共享)
const l1 = this.localCache.get<Post[]>(cacheKey);
if (l1) return l1;
// L2:Redis 缓存(共享,稍慢)
const l2 = await this.cacheManager.get<{ items: Post[]; total: number }>(cacheKey);
if (l2) {
this.localCache.set(cacheKey, l2, 30 * 1000); // 回填 L1(30 秒)
return l2;
}
// L3:数据库
const result = await this.postRepo.findAndCount({...});
const response = { items: result[0], total: result[1] };
await this.cacheManager.set(cacheKey, response, 5 * 60 * 1000); // Redis 5 分钟
this.localCache.set(cacheKey, response, 30 * 1000); // 内存 30 秒
return response;
}
}六、缓存穿透、击穿、雪崩防护
缓存穿透(查询不存在的数据)
typescript
async findOne(id: number) {
const cacheKey = `post:${id}`;
const cached = await this.cacheManager.get(cacheKey);
// 区分"未缓存"和"缓存为 null"
if (cached !== undefined) return cached; // null 也是有效缓存值
const post = await this.postRepo.findOne({ where: { id } });
// 缓存空结果(防止穿透),TTL 短一些
await this.cacheManager.set(cacheKey, post ?? null, 60 * 1000); // 1 分钟
if (!post) throw new NotFoundException();
return post;
}缓存击穿(热点 key 过期瞬间大量并发)
typescript
// 用互斥锁防止缓存重建风暴
private rebuilding = new Map<string, Promise<any>>();
async findOneWithLock(id: number) {
const cacheKey = `post:${id}`;
const cached = await this.cacheManager.get(cacheKey);
if (cached) return cached;
// 同一个 key 只允许一个协程重建缓存
if (!this.rebuilding.has(cacheKey)) {
const promise = this.postRepo.findOne({ where: { id } }).then(async (post) => {
await this.cacheManager.set(cacheKey, post, 5 * 60 * 1000);
this.rebuilding.delete(cacheKey);
return post;
});
this.rebuilding.set(cacheKey, promise);
}
return this.rebuilding.get(cacheKey);
}缓存雪崩(大量 key 同时过期)
typescript
// 在基础 TTL 上加随机抖动,避免同时过期
function ttlWithJitter(baseTtl: number): number {
const jitter = Math.random() * 0.2 * baseTtl; // ±20% 随机抖动
return baseTtl + jitter;
}
await this.cacheManager.set(key, value, ttlWithJitter(5 * 60 * 1000));七、缓存与数据库一致性
Write-Through(写透):
typescript
// 写数据库的同时写缓存(强一致,写性能稍差)
async update(id: number, dto: UpdatePostDto) {
const post = await this.postRepo.save({ id, ...dto });
await this.cacheManager.set(`post:${id}`, post, TTL); // 同步更新
return post;
}Cache-Aside(旁路缓存,最常用):
typescript
// 读时填充,写时删除(弱一致,删缓存比更新简单)
async update(id: number, dto: UpdatePostDto) {
const post = await this.postRepo.save({ id, ...dto });
await this.cacheManager.del(`post:${id}`); // 删除,下次读时重建
return post;
}Write-Behind(异步写,高性能,复杂):
typescript
// 先写缓存,异步刷新数据库(最高性能,实现最复杂)
// 适合写入频繁的计数类数据(点赞数、浏览量)
async incrementViewCount(id: number) {
const key = `post:${id}:views`;
const count = await this.redis.incr(key);
// 每累计 100 次或 1 分钟后,批量写入数据库
if (count % 100 === 0) {
await this.postRepo.increment({ id }, 'views', 100);
await this.redis.decrby(key, 100);
}
return count;
}可运行 Demo:
practice/07-extensions— Cache-Aside + 穿透防护 Demo,接口:GET /cache/products/:id
常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
cacheManager.get() 返回 undefined 和 null 混淆 | undefined = 未命中,null = 已缓存不存在 | 用 if (cached !== undefined) 而非 if (cached) 判断命中 |
| 缓存更新后旧数据持续返回 | 更新数据库但未删除缓存 | 更新操作后调用 cacheManager.del(key) |
| Redis 缓存键命名冲突 | 多个服务使用相同键名 | 用模块前缀:users:findOne:${id},posts:list:${page} |
@CacheInterceptor 对 POST 请求不生效 | 拦截器默认只缓存 GET | POST 请求改用手动 cacheManager.get/set |