第 23 章:缓存
学习目标
- 用 Redis 做生产级缓存
- 掌握 CacheModule + 写后清
- 避开 3 个缓存坑(穿透/击穿/雪崩)
一、生产只有一种缓存:Redis
内存缓存、二级缓存、社区装饰器——都不写。生产项目几乎只用 Redis。
bash
pnpm add @nestjs/cache-manager cache-manager cache-manager-redis-yet redis二、Redis 配置
typescript
// app.module.ts
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-yet';
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true, // 全局不用每个模块引
useFactory: async () => ({
store: await redisStore({
socket: { host: '127.0.0.1', port: 6379 },
password: 'secret',
db: 0,
}),
ttl: 60_000, // 全局默认 TTL:60 秒
}),
}),
],
})
export class AppModule {}三、注入使用(读)
typescript
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User) private readonly repo: Repository<User>,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
) {}
async findAll() {
// ① 查缓存
const cached = await this.cache.get<User[]>('users:all');
if (cached) return cached;
// ② 没命中 → 查 DB
const users = await this.repo.find();
// ③ 写回缓存(set 第 3 参数是毫秒 TTL)
await this.cache.set('users:all', users, 60_000);
return users;
}
}标准三步走:读缓存 → 命中返回;未命中查 DB → 写回缓存 → 返回。
四、写后清缓存(关键)
数据更新必须清缓存,否则用户看老数据。
typescript
async update(id: number, dto: UpdateUserDto) {
await this.repo.update(id, dto);
// 关键:写后清
await this.cache.del('users:all'); // 清列表
await this.cache.del(`user:${id}`); // 清单条
}
async remove(id: number) {
await this.repo.delete(id);
await this.cache.del('users:all');
await this.cache.del(`user:${id}`);
}⚠️ 坑 1:更新数据不
cache.del→ 用户看到旧数据。这是缓存 bug 的头号来源。
TypeORM .cache() vs cache-manager:不冲突,是不同层面的缓存。
cache-manager(业务层) | TypeORM .cache()(ORM 层) | |
|---|---|---|
| 谁触发 | Service 手写 get/set | Repository 查询链上 .cache(true) |
| Key 怎么定 | 你定义(user:1 / product:hot) | TypeORM 自动用 SQL 当 key |
| 灵活度 | 高(写后清、穿透防护、空值缓存) | 低(只用 TTL 自动失效) |
| 典型用法 | 用户/订单/支付等核心业务 | 后台管理、报表、长尾查询 |
实战两个都用:
typescript
// ✅ 核心业务 → cache-manager(精细控制)
const user = await this.cache.get('user:1');
if (!user) {
user = await this.repo.findOne({ where: { id: 1 } });
await this.cache.set('user:1', user, 60_000);
}
// ✅ 后台/报表 → TypeORM .cache()(省事)
const logs = await this.logRepo
.createQueryBuilder('log')
.where('log.createdAt > :d', { d: yesterday })
.cache(true) // 一行搞定
.getMany();五、缓存穿透/击穿/雪崩(必懂)
| 问题 | 表现 | 解决 |
|---|---|---|
| 穿透 | 查不存在的 id 一直打 DB | 空值也缓存(短 TTL) |
| 击穿 | 热点 key 过期瞬间并发打 DB | 加锁(singleflight) |
| 雪崩 | 一批 key 同时过期 | TTL 加随机值 |
不是所有缓存都要 3 个全加,看场景:
| 场景 | 穿透 | 击穿 | 雪崩 |
|---|---|---|---|
| 后台管理列表 | - | - | ✅ |
| 普通商品查询 | ✅ | - | ✅ |
| 热门商品详情 | ✅ | ✅ | ✅ |
| 排行榜 / Feed | ✅ | ✅ | ✅ |
判断标准:
typescript
// ① 穿透:查不存在的 id?
// 有可能(id 越界 / 用户乱填) → 加
// id 是内部确定生成 → 不用
// ② 击穿:有"热点"数据吗?
// 有(爆款商品 / 热门文章) → 必须加单飞锁
// 没有热点(每个 key 访问均匀) → 不用
// ③ 雪崩:一批 key 同时过期?
// 总是 → 必须加(随机 TTL)
// ⚠️ 这是通用规则,任何缓存都建议加实战最低必加 = 雪崩防护(一行代码,几乎零成本):
typescript
async setCache(key: string, value: any, baseTTL = 60_000) {
const ttl = baseTTL + Math.random() * baseTTL * 0.2; // ±20% 抖动
await this.cache.set(key, value, ttl);
}5.1 穿透(空值缓存)
typescript
async findOne(id: number) {
const key = `user:${id}`;
const cached = await this.cache.get(key);
if (cached !== undefined && cached !== null) return cached; // 有值直接返回
if (cached === null) return null; // null 表示"查过了真没有"
const user = await this.repo.findOne({ where: { id } });
await this.cache.set(key, user ?? null, 30_000); // null 也缓存
return user;
}5.2 雪崩(TTL 随机化)
typescript
// 错:固定 TTL → 一批 key 同时过期 → 雪崩
await this.cache.set(key, value, 60_000);
// ✅ 对:加 ±20% 随机抖动
const ttl = 60_000 + Math.random() * 12_000; // 60s ~ 72s
await this.cache.set(key, value, ttl);5.3 击穿(单飞锁)
typescript
async findOne(id: number) {
const key = `user:${id}`;
const cached = await this.cache.get(key);
if (cached) return cached;
// 加分布式锁(setNX):只有第一个请求去打 DB
const lockKey = `lock:user:${id}`;
const locked = await this.redis.set(lockKey, '1', 'NX', 'EX', 10); // 锁 10s
if (!locked) {
await new Promise(r => setTimeout(r, 100)); // 等 100ms 重试
return this.findOne(id);
}
try {
const user = await this.repo.findOne({ where: { id } });
await this.cache.set(key, user ?? null, 30_000);
return user;
} finally {
await this.redis.del(lockKey); // 释放锁
}
}实战通常用 async-lock 库简化。
六、Redis Cluster(生产推荐)
typescript
// 集群模式
useFactory: async () => ({
store: await redisStore({
cluster: [
{ host: '127.0.0.1', port: 7000 },
{ host: '127.0.0.1', port: 7001 },
{ host: '127.0.0.1', port: 7002 },
],
}),
ttl: 60_000,
}),⚠️ 坑 2:Redis 单点挂了缓存全没 → 生产用 Redis Cluster 或 Sentinel(主从 + 自动故障转移)。
为什么需要 Redis Cluster / Sentinel?
单点 Redis 挂了 → 缓存系统整个雪崩 → 所有请求穿透到 DB → 全站 500。
两种高可用方案:
| 方案 | 节点 | 适合 |
|---|---|---|
| Sentinel(主从 + 自动故障转移) | 1 主 + N 从 | 中小项目,数据量不大 |
| Cluster(分布式分片) | 至少 6 节点(3 主 3 从) | 大数据量,高并发 |
实战口诀:数据量 < 10GB 用 Sentinel,大数据用 Cluster,99% 项目直接买云托管 Redis(自带高可用 + 监控 + 备份)。
使用代码完全不用改:迁移只动 CacheModule.registerAsync 配置:
diff
// app.module.ts
CacheModule.registerAsync({
useFactory: async () => ({
store: await redisStore({
- socket: { host: '127.0.0.1', port: 6379 }, // 单 Redis
+ sentinels: [ // Sentinel
+ { host: 'sentinel-1', port: 26379 },
+ { host: 'sentinel-2', port: 26379 },
+ ],
+ name: 'mymaster',
}),
ttl: 60_000,
}),
}),typescript
// 业务代码:零修改
const cached = await this.cache.get<User[]>('users:all'); // ✅ 一样
await this.cache.set('users:all', users, 60_000); // ✅
await this.cache.del('users:all'); // ✅cache-manager / ioredis 自动屏蔽底层差异,业务调的还是同一个 Cache.get/set/del。
唯一例外:Cluster 模式批量 key 要加 hash tag:
typescript
'user:{1}:profile' ← {1} 强制落同一节点
'user:{1}:settings'
// 单 Redis / Sentinel 模式不用管实战 99% 项目零修改,只是部署配置动一下。
七、缓存 + 业务一致性(高级)
Cache Aside 模式(事实标准):
读:
1. 先读 cache
2. 命中 → 返回
3. 未命中 → 读 DB → 写 cache → 返回
写:
1. 写 DB
2. 清 cache(不是更新 cache!)为什么是"清"不是"更新"?
场景:两个并发请求同时改 user.id = 1
线程 A:写 DB users.name = 'Tom' → cache.set('user:1', 'Tom') → 顺序 ② 后到 → 旧值覆盖新值
线程 B:写 DB users.name = 'Jerry' → cache.set('user:1', 'Jerry')
如果都 "set",最终 cache 是 'Tom'(旧值),DB 是 'Jerry' → 不一致
如果都是 "del":
A:DB.name = 'Tom' → cache.del('user:1')
B:DB.name = 'Jerry' → cache.del('user:1')
下次读触发 cache miss → 读最新 DB 值 → OK八、实战:商品详情缓存
typescript
async getDetail(id: number) {
const key = `product:${id}`;
const cached = await this.cache.get<Product>(key);
if (cached) return cached;
const p = await this.repo.findOne({
where: { id },
relations: ['skus'],
});
if (!p) throw new NotFoundException();
// TTL 加随机抖动,防雪崩
await this.cache.set(key, p, 300_000 + Math.random() * 60_000);
return p;
}
async update(id: number, dto: UpdateProductDto) {
await this.repo.update(id, dto);
await this.cache.del(`product:${id}`);
}九、本章小结
| 要点 | 关键 |
|---|---|
| 存储 | Redis(不用内存缓存) |
| API | cache.get/set/del,set 第 3 参数 = TTL(毫秒) |
| 写后清 | cache.del 不是 cache.set(更新场景) |
| 穿透 | 空值也缓存,短 TTL |
| 击穿 | 分布式锁(singleflight) |
| 雪崩 | TTL 加随机值 |
| 一致性 | Cache Aside 模式(读时填,写时清) |