Skip to content

数据缓存

一、缓存的适用场景

场景收益风险
热点只读数据(文章列表、分类)大幅降低数据库压力数据短暂不一致
计算代价高的聚合查询节省 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() 返回 undefinednull 混淆undefined = 未命中,null = 已缓存不存在if (cached !== undefined) 而非 if (cached) 判断命中
缓存更新后旧数据持续返回更新数据库但未删除缓存更新操作后调用 cacheManager.del(key)
Redis 缓存键命名冲突多个服务使用相同键名用模块前缀:users:findOne:${id}posts:list:${page}
@CacheInterceptor 对 POST 请求不生效拦截器默认只缓存 GETPOST 请求改用手动 cacheManager.get/set

NestJS 深度学习体系