Skip to content
第 23 章 后端 ⏱ 9 分钟阅读

第 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/setRepository 查询链上 .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(不用内存缓存)
APIcache.get/set/del,set 第 3 参数 = TTL(毫秒)
写后清cache.del 不是 cache.set(更新场景)
穿透空值也缓存,短 TTL
击穿分布式锁(singleflight)
雪崩TTL 加随机值
一致性Cache Aside 模式(读时填,写时清)

本站基于 VitePress 构建 · 由 StackHub 团队维护