跳转到主要内容

Nest 使用笔记

第十三章:Redis——浏览量计数、排行榜与缓存的三种用法

打开 learnhub 的 RankingService,在真实代码里讲透「浏览量走 Redis 不走 MySQL」的写法(INCR 增量 + 原子 getdel flush + Cron 刷库)、Sorted Set 日/月/年榜单(ZUNIONSTORE 把 12 个月榜合并成年榜),再补上 Redis 的

  • Nest
  • Redis
  • 缓存

第八章讲 findOne 的时候留了一个扣:浏览量 +1 走的是 Redis,不是直接 UPDATE post SET view_count = view_count + 1。这一章就把这个扣解开——把 Redis 接进 Nest,打开 learnhub 真实在用的 RankingService,看一个生产项目是怎么用 Redis 做计数、排行榜、缓存这三件事的。和「装个 redis 包、跑通 set/get」不同,这一章讲的是真活:为什么浏览量要先攒在 Redis 再批量刷库、为什么 flush 必须用 getdel 不能用 get + delZUNIONSTORE 是怎么把月榜拼成年榜的,以及 Redis 当缓存挡在 MySQL 前面时,穿透/击穿/雪崩这三种经典故障要怎么防。

先搞懂:Redis 是什么 / 为什么需要 / 企业级怎么用

Redis 是什么:基于内存的键值存储,单机几万到十几万 QPS,比 MySQL 快一两个数量级。不止是「缓存」——它内置 string / hash / list / set / zset 五种结构,能干计数、排行榜、限流、分布式锁、轻量队列、发布订阅一堆事。后端项目里它通常不是主库,而是挡在 MySQL 前面的高速辅助层。

为什么需要它:MySQL 负责可靠地存数据,但有些活它干不动或者干起来代价太大。三种典型场景:(1) 高频读热点——首页配置、热门帖子详情,每次都查 MySQL 拖慢接口;(2) 高频写——每次刷页面浏览量 +1,QPS 一高就把这一行的行锁热了,并发排队、CPU 飙;(3) 临时数据——验证码、登录状态、限流计数,本来就该带过期时间。这三种压 Redis 是用它的强项,压 MySQL 是把 MySQL 的弱项放大。判断口径:数据高频 / 临时 / 丢了能接受 → 放 Redis;核心 / 强一致 / 要持久 → 留 MySQL

企业级怎么用(下面每一条,这一章都会在 learnhub 里真做一遍,不是空头支票):

  • Redis 客户端直接用 ioredis 而不是 Nest 自带的 cache-manager——拿原生 client 才能用上 ZSET、pipeline、lua 脚本;本章第一步讲取舍;
  • Redis 连接封成 @Global() 模块 + useFactory + token provider,任意模块直接注入 RedisService,配置走 env——本章第二步落地;
  • 高频计数(浏览量)写 Redis 不写 MySQL,定时 @Cron 批量刷库,避开 MySQL 热行锁——本章第四到六步落地;
  • 排行榜用 Sorted Set + ZUNIONSTORE,日/月/年榜一套结构搞定——本章第五步落地;
  • 缓存旁路(cache-aside)是 Redis 的 #1 用途,但 learnhub 用 Redis 是做计数/榜单、不是查库缓存——本章第七步老老实实把 cache-aside 模式讲一遍,并说清 learnhub 为什么没用;
  • Redis 挡在 MySQL 前面就会有 穿透 / 击穿 / 雪崩 三种故障——本章第八步把这三种故障的代码模式(缓存空值 / 布隆过滤器 / SETNX 互斥 / TTL 抖动)讲透。

这一章你会做出什么

  • 用 Docker 起 Redis,在 Nest 里封装成全局 RedisModule / RedisService(基于 ioredis,不是 cache-manager)。
  • 打开 learnhub 的 ranking.service.ts,吃透三件事:浏览量计数recordView 一次写三个 key)、日/月/年榜单zincrby + zunionstore)、原子 flushgetdel + @Cron 刷回 MySQL)。
  • 学一遍 cache-aside(get-or-set)的正规写法,知道为什么 learnhub 没用它。
  • 把穿透 / 击穿 / 雪崩三种故障模式连同代码模式讲明白——这是 Redis 上线前必须过的关。

前置:learnhub 的 Redis 在跑(cd learnhub/docker && docker compose up redis),第八章的 PostService.findOne 已经读过。

第一步:起 Redis,选 ioredis 不选 cache-manager

cd learnhub/docker && docker compose up -d redis   # 起 Redis,默认 6379 端口
docker exec -it learnhub-redis redis-cli ping      # PONG 就通了

Nest 生态里 Redis 有两种接法,差很多:

  • @nestjs/cache-manager + CacheModule:Nest 官方文档主推的写法。它把 Redis 包了一层缓存抽象,注册后用 cacheManager.get(key) / .set(key, val, ttl),还能挂个 @CacheInterceptor('xxx') 装饰器自动缓存接口返回值。优点是简单、跨缓存后端(同一个抽象能换 Redis / 内存 / memcached)。缺点是只能干「缓存」这一件事——拿到的是 Cache 接口,不是 Redis client,ZSETINCRpipelineluapub/sub 全用不了。
  • 直接用 ioredis:拿到的是原生 Redis client,所有 Redis 命令都能调,计数、排行榜、限流、锁、队列都做得出来。代价是要自己封一层 service。

learnhub 选的是 ioredis。原因就是这一章要做的事——浏览量 INCR、榜单 ZINCRBY / ZUNIONSTORE、原子 flush GETDEL——全是 cache-manager 抽象层干不了的。「缓存」只是 Redis 的一小部分能力,把它当万能缓存抽象会浪费一大半。装包:

cd learnhub && npm install ioredis

思考:什么场景反过来选 cache-manager 更合适?——业务只需要给接口返回值加一层缓存(比如 GET /posts/:id 详情缓存 60 秒),完全用不到 ZSET/计数/锁,用 CacheModule + @CacheInterceptor 几行配完、零样板,比手封 ioredis 划算。本章第九步会再展开对比。

第二步:全局 RedisModule——@Global + useFactory + token provider

回忆第四章讲过的全局模块动态模块:全局模块 @Global() 注册一次,全应用任意模块都能直接注入它的 export;动态模块用 useFactory 异步构造 provider,能拿到 ConfigService 之类的依赖。learnhub 的 RedisModule 两件一起用:

// learnhub/src/modules/redis/redis.module.ts
@Global()
@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: REDIS_CLIENT,             // 字符串 token,不是类
      inject: [ConfigService],
      useFactory: (config: ConfigService) =>
        new Redis({
          host: config.get<string>('redis.host') || '127.0.0.1',
          port: config.get<number>('redis.port') || 6379,
          retryStrategy: (times) => Math.min(times * 500, 3000),
          maxRetriesPerRequest: null,
          enableOfflineQueue: true,
        }),
    },
    RedisService,
  ],
  exports: [RedisService],
})
export class RedisModule {}

几个要点:

  • provide: REDIS_CLIENT 用的是字符串 token'REDIS_CLIENT'),不是类。原因是 ioredisRedis 类不是我们自己写的、它身上没有 Nest 的装饰器,没法直接当 provider。用 token 把它「挂」到 IoC 容器里,注入侧用 @Inject(REDIS_CLIENT) 显式拿。token 定义放在 redis.constants.ts,避免魔术字符串散落。
  • useFactory + inject: [ConfigService]:连接参数要从 ConfigService 拿,forRoot 这种静态写法拿不到,必须用工厂。这和第八章 TypeORM 的 forRootAsync 是同一个套路。
  • retryStrategy + maxRetriesPerRequest: null + enableOfflineQueue: true:这是生产里 ioredis 的健壮性三件套。Redis 暂时挂了不要无限重试刷屏(retryStrategy 限到 3 秒封顶)、不要让在途命令直接报错(maxRetriesPerRequest: null)、连接没好时命令进队列等连上再发(enableOfflineQueue)。配合下面 service 里的 .catch(() => undefined) 降级,learnhub 的设计是「Redis 挂了不阻断主流程」——你看不了帖子详情、发不了帖才是大事,浏览量少计一次没事。
  • @Global()RedisService 在任意模块直接注入,不用每个用 Redis 的模块都 imports: [RedisModule]。但要注意:全局的 export 只对 service / 常量生效REDIS_CLIENT 这个 token 没 export,外部模块直接 @Inject(REDIS_CLIENT) 是拿不到的——强制大家走 RedisService 这一层封装,不让原生 client 散落到业务代码里。

配置在 configuration.ts 里,走 env:

// learnhub/src/config/configuration.ts
redis: {
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: parseInt(process.env.REDIS_PORT || '6379', 10),
},

第三步:RedisService——把 ioredis 包成业务语义

直接把 ioredis client 撒到业务里也能用,但每个调用方都要懂 Redis 命令、key 命名规则、错误处理,散得很。learnhub 包了一层 RedisService,只暴露业务语义方法:

// learnhub/src/modules/redis/redis.service.ts
@Injectable()
export class RedisService implements OnModuleDestroy {
  constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) {}

  /** 应用关闭时断开连接,避免测试/停机时连接泄漏 */
  async onModuleDestroy(): Promise<void> {
    await this.client.quit().catch(() => undefined);
  }

  getClient(): Redis {
    return this.client;
  }

  // —— String ——
  async incr(key: string): Promise<number> {
    return this.client.incr(key);
  }
  async incrBy(key: string, increment: number): Promise<number> {
    return this.client.incrby(key, increment);
  }
  async get(key: string): Promise<string | null> {
    return this.client.get(key);
  }
  /** 原子地取并删(Redis 6.2+),用于 flush 时取出累计值并清零 */
  async getdel(key: string): Promise<string | null> {
    return this.client.getdel(key);
  }

  // —— Sorted Set(排行榜主力)——
  async zincrby(key: string, incr: number, member: string): Promise<number> {
    const r = await this.client.zincrby(key, incr, member);
    return Number(r);
  }
  async ztopWithScores(key: string, n: number): Promise<[string, string][]> {
    const arr = await this.client.zrevrange(key, 0, n - 1, 'WITHSCORES');
    const pairs: [string, string][] = [];
    for (let i = 0; i < arr.length; i += 2) pairs.push([arr[i], arr[i + 1]]);
    return pairs;
  }
  async zunionstore(destination: string, keys: string[]): Promise<number> {
    return (await this.client.call('ZUNIONSTORE', destination, keys.length, ...keys)) as number;
  }

  async keys(pattern: string): Promise<string[]> {
    return this.client.keys(pattern); // 学习项目可用 KEYS;生产请用 SCAN
  }
  async del(...keys: string[]): Promise<number> {
    return this.client.del(...keys);
  }
}

几点要说:

  • OnModuleDestroy:模块销毁时调 client.quit() 把连接关掉。Nest 的生命周期钩子(第四章讲过)——不写这行,热重载或停机时连接会泄漏,跑几次测试 Redis 上就一堆 idle 连接。
  • getdel 单独拎出来注释:Redis 6.2 加的命令,一次操作取值并删 key。下面第六步 flush 浏览量它是关键——比 getdel 安全得多。
  • ztopWithScores 自己拼对:ioredis 的 zrevrange(..., 'WITHSCORES') 返回的是 [m1, s1, m2, s2, ...] 扁平数组,业务想要的是 [[m, s], ...],所以 service 里循环重排成对。这种「把 Redis 的返回结构翻译成业务友好结构」就是 service 层该干的事。
  • zunionstoreclient.call:ioredis 对 ZUNIONSTORE 的 TypeScript 签名在不同版本里不稳定,用 call 直接走原始命令绕开签名差异。call 是 ioredis 的逃生舱,遇到签名缺失或版本不一致都能用。
  • getClient() 留了个口子:业务偶尔需要 service 没封装的命令(比如一次性跑个 HGETALL),不至于为了加一个方法就改 service。但要克制——大量用 getClient() 就说明 service 封得不够、该补方法了。

第四步:浏览量计数——recordView 的写侧

这是这一章的核心。先看 PostService 怎么触发浏览量计数:

// learnhub/src/modules/post/post.service.ts
async findOne(id: number): Promise<Post> {
  const post = await this.postRepo.findOne({
    where: { id },
    relations: { author: true, tags: true, comments: { author: true } },
  });
  if (!post) throw new NotFoundException('帖子不存在');
  // 浏览量 +1 走 Redis(增量),由 RankingService 定时 flush 进库
  // Redis 暂不可用时降级:不阻断详情查看
  this.ranking.recordView(id).catch(() => undefined);
  return post;
}

注意三件事:

  • recordViewawait:它是个 fire-and-forget,发完就 return post,不等 Redis 写完。看帖子详情不该被 Redis 拖慢,更不该因为 Redis 挂了就 500。
  • .catch(() => undefined):Redis 真挂了,Promise reject 被 swallow,详情接口照常返回。这就是前面说的「Redis 挂了不阻断主流程」。代价是这次浏览没被记上——业务能接受。
  • PostService 不直接调 RedisService,而是调 RankingService:因为浏览量不仅是计数,还要进榜单。RankingService 把这两件事一起做了,业务方调一个方法就行。

recordView 的实现:

// learnhub/src/modules/ranking/ranking.service.ts
const NS = 'learnhub';
const viewDeltaKey = (id: number | string) => `${NS}:post:view:${id}`;
const dayKey = (d = new Date()) => { /* 拼成 learnhub:rank:view:2026-07-19 */ };
const monthKey = (d = new Date()) => { /* 拼成 learnhub:rank:view:2026-07 */ };

async recordView(postId: number): Promise<void> {
  await Promise.all([
    this.redis.incr(viewDeltaKey(postId)),       // 增量计数:String INCR
    this.redis.zincrby(dayKey(), 1, String(postId)),  // 日榜:ZSET ZINCRBY
    this.redis.zincrby(monthKey(), 1, String(postId)), // 月榜:ZSET ZINCRBY
  ]);
}

一次浏览、三条 Redis 命令并发发出去(Promise.all),分别写三个 key:

  • learnhub:post:view:<id> 是 String,INCR 累加。这是「待 flush 进 MySQL 的增量」,第六步的 @Cron 任务会把它读出来累加到 post.view_count 列、然后清零。注意它存的是自上次 flush 以来的增量,不是累计浏览量——累计值以 MySQL 为准。
  • learnhub:rank:view:2026-07-19 是 Sorted Set,ZINCRBY 给 member(帖子 id)加 1 分。这就是当天的日榜。
  • learnhub:rank:view:2026-07 同理,是当月月榜。

key 命名规则:项目名:业务:维度:时间NS = 'learnhub' 是项目前缀,防止一个 Redis 给多个项目共用时 key 撞;时间精度决定榜单周期。

思考:为什么要攒在 Redis、定时刷库,而不是每次 findOne 直接 UPDATE post SET view_count = view_count + 1 WHERE id = ??——根因和第八章 findOne 思考里讲的是同一个:MySQL 的行锁。详情页是高并发接口,热门帖一分钟能被刷上千次,每次都 UPDATE 同一行就是把这一行锁热——并发 UPDATE 排队、锁等待、连接池占满、CPU 飙。加上写放大:一次浏览写一次 binlog、写一次 InnoDB buffer pool、可能触发一次 fsync。把增量攒在 Redis(内存操作,单条几万 QPS 不在话下),每分钟批量 flush 一次(N 次 Redis 写 → 1 次 MySQL 写),MySQL 的写压力降一两个数量级。Redis 在这里不是「缓存」,是「写缓冲」——把高频小写聚合成低频批量写。

第五步:日/月/年榜单——ZSET + ZUNIONSTORE

recordView 已经把日榜、月榜维护起来了。年榜呢?learnhub 没有单独维护年榜 key,而是查的时候临时把 12 个月榜合并出来:

// learnhub/src/modules/ranking/ranking.service.ts
const monthKeysOfYear = (year: number) =>
  Array.from({ length: 12 }, (_, i) => `${NS}:rank:view:${year}-${String(i + 1).padStart(2, '0')}`);

async top(period: RankPeriod, limit = 10): Promise<{ postId: number; score: number }[]> {
  let key: string;
  if (period === 'day') key = dayKey();
  else if (period === 'month') key = monthKey();
  else {
    const year = new Date().getFullYear();
    key = `${NS}:rank:view:year:${year}`;
    // 用 ZUNIONSTORE 把当年 12 个月榜合并成年榜
    await this.redis.zunionstore(key, monthKeysOfYear(year));
  }
  const pairs = await this.redis.ztopWithScores(key, limit);
  return pairs.map(([member, score]) => ({ postId: Number(member), score: Number(score) }));
}

ZUNIONSTORE destination numkeys key1 key2 ... 把多个 Sorted Set 的分数按 member 求和后存到 destination2026-012026-022026-12 这 12 个月榜里,同一个 postId 在不同月份的浏览量被加起来,结果就是年榜。

为什么要「查时合并」而不是「写入时同时维护年榜」?两点:

  • 写放大的反面recordView 每次只写日、月两个 key 就够便宜了;如果再加一个年榜 key,每次浏览多一次 ZINCRBY,QPS 高时也是成本。
  • ZUNIONSTORE 是 O(N) 的批处理:合并一次比维护一整年便宜得多——尤其考虑到年榜其实不常被查(前端排行榜页面用户多半在看日榜、月榜)。

注意ZUNIONSTORE覆盖 destination——每次跑都把年榜 key 整个重算重写。所以这里 destination 和源 key 是不同的(year:2026 vs 2026-01 这种月 key),如果重名就会自吞自。另外这个写法没设 destination 的 TTL——年榜 key 不会自动过期,跨年之后就成僵尸 key。生产里要么 EXPIRE 一下(比如年榜 key 一年后过期),要么在 monthKeysOfYear 之外加个清理任务。learnhub 是学习项目,留着这个口子方便后续练习。

ztopWithScores 取 Top N 用的是 ZREVRANGE key 0 N-1 WITHSCORES——ZREVRANGE 按分数从高到低,取前 N 个就是 Top N。Sorted Set 自带按分数排序、去重(member 唯一),所以做排行榜天然合适,不用 ORDER BY + LIMIT 那套 SQL。

第六步:原子 flush——getdel + @Cron

浏览量攒在 Redis 的 learnhub:post:view:<id>,最终要进 MySQL 的 post.view_count 列(详情页拿累计浏览量还是从 MySQL 读的)。这是定时任务干的事:

// learnhub/src/modules/ranking/ranking.service.ts
@Cron(CronExpression.EVERY_MINUTE)
async flushViewCounts(): Promise<void> {
  let keys: string[] = [];
  try {
    keys = await this.redis.keys(`${NS}:post:view:*`);
  } catch (e) {
    this.logger.warn(`flush 跳过(Redis 不可用):${(e as Error).message}`);
    return;
  }
  if (!keys.length) return;

  let flushed = 0;
  for (const key of keys) {
    const deltaStr = await this.redis.getdel(key);   // 原子取并清零
    const delta = Number(deltaStr ?? 0);
    if (!delta) continue;
    const postId = Number(key.split(':').pop());
    try {
      await this.postRepo.increment({ id: postId }, 'viewCount', delta);
      flushed++;
    } catch (e) {
      // 失败则把增量加回去,避免丢数据
      await this.redis.incrBy(key, delta);
      this.logger.warn(`flush post ${postId} 失败:${(e as Error).message}`);
    }
  }
  if (flushed) this.logger.log(`flush 浏览量:${flushed} 篇`);
}

这是这一章最容易写坏的一段,逐行讲:

  • @Cron(CronExpression.EVERY_MINUTE)@nestjs/schedule 提供的定时任务装饰器,每分钟跑一次。Cron 任务要在某个 @ModuleimportsScheduleModule.forRoot() 一下才能用。生产可改成 5–10 分钟,频率越低、聚合度越高、MySQL 压力越小,但浏览量延迟也越大——这是个 trade-off,按业务能接受的「浏览量延迟显示」上限来定。
  • KEYS learnhub:post:view:*:扫出所有待 flush 的 key。学习项目用 KEYS 没问题,生产必须换 SCAN——KEYS 是阻塞扫,key 多了会卡 Redis 几秒。注释里也写明了。
  • getdel 而不是 get + del:这是这一步的关键。如果是 get 然后 del,两步之间如果另一个请求进来又 INCR 了,那个增量就被这次 del 吞掉了——丢了。getdel 是原子的「取值并删除」,Redis 把这两步合成一条命令,两步之间没有任何空隙能插进别的命令。Redis 是单线程的,单条命令天然原子;多条命令之间才需要 pipeline / lua / 事务来保原子。
  • postRepo.increment({ id }, 'viewCount', delta):TypeORM 的 increment 生成 UPDATE post SET view_count = view_count + ? WHERE id = ?——注意是相对增量(+ delta),不是绝对值覆盖。这一点配合 getdel 的「取出即清零」语义,整个链路是精确一次:Redis 攒的增量,要么完整地进 MySQL(成功),要么因为失败被加回去(incrBy(key, delta)),不会丢、不会重。
  • 失败回滚try/catch 包住 MySQL 写,失败时把 delta 加回 Redis。这是「至少一次」语义的兜底——MySQL 写失败时不丢数据,下分钟再 flush。注意它不保证「至多一次」:极小概率下 getdel 成功了、MySQL 写完了、incrBy 回滚前服务挂了,下一次就少这个增量。但这种边界极少触发,业务能接受。

注意:整个 flush 不是一个事务。每条 key 独立 try/catch,一条失败不影响其他。如果想做更严格的「整批要么全成要么全失败」,可以把循环改成先 getdel 全部、拿到所有 delta 后开 MySQL 事务批量 update、失败全部回滚——但 learnhub 没这么做,因为 flush 是高频任务(每分钟),独立失败、独立重试比事务回滚更合理。

至此,Redis 当写缓冲的完整闭环就清楚了:详情接口 findOnerecordView 写 Redis 三 key → @Cron 每分钟 getdel 取增量 → increment 进 MySQL。MySQL 上的 post.view_count 是真相源,列表页和详情页拿到的累计浏览量都来自它;Redis 上的 learnhub:post:view:<id> 只是临时增量缓冲。

第七步:cache-aside——Redis 的 #1 用途(learnhub 没用,但要会)

前三步讲的是 Redis 当写缓冲。Redis 在大多数项目里更常见的角色是读缓存,模式叫 cache-aside(旁路缓存):读请求先查 Redis,命中直接返回;没命中查 MySQL,再把结果写回 Redis 带 TTL,下次同样的请求就走缓存。

learnhub 的查询接口(findOnefindMany没有走 cache-aside——列表查询每次都打 MySQL,详情查询也是。原因前面说过:learnhub 用 Redis 是做计数和榜单,没拿它缓存查询结果。但 cache-aside 是 Redis 的 #1 用法,绕不过去,这一步把它讲清楚。

标准写法(伪代码,learnhub 没用、做对照):

// 对照写法,learnhub 未使用 cache-aside,这里只演示模式
async findOneWithCache(id: number): Promise<Post> {
  const cacheKey = `post:detail:${id}`;
  // 1. 先查 Redis
  const cached = await this.redis.get(cacheKey);
  if (cached) return JSON.parse(cached) as Post;
  // 2. 没命中,查 MySQL
  const post = await this.postRepo.findOne({ where: { id }, relations: { author: true, tags: true } });
  if (!post) throw new NotFoundException('帖子不存在');
  // 3. 回写 Redis,带 TTL(必填!)
  await this.redis.set(cacheKey, JSON.stringify(post), 60);
  return post;
}

三个要点:

  • TTL 是必填项,不是可选。无 TTL 的缓存是定时炸弹——MySQL 改了、Redis 不刷新,用户永远看旧数据。set 一定要带过期时间(learnhub 封的 set(key, val, ttl) 也是这思路)。注意:永远不要写「先 set、再 expire」两步——两步之间挂了就是无 TTL key。用 Redis 的 SET key value EX seconds 一条命令搞定。
  • 回写要在查到数据之后。如果先 set 占位、再查库,并发请求拿到占位就返回错了。把「查 MySQL」放在「写 Redis」之前,保证缓存里始终是真实数据。
  • 缓存的是序列化后的字符串JSON.stringify(post))。Redis 的 String 结构就是字符串,存对象要序列化;如果要存更紧凑或带索引,可以用 Hash。relations 带出来的关联数据要不要一起缓存要想清楚——缓存越大、网络传输越慢;缓得太细、命中率又低。

写一致性怎么办?cache-aside 的标准答案:先更新 MySQL、再删 Redis(不是「更新 Redis」)。下次读的时候自然 cache miss、从 MySQL 重载。为什么不直接「更新 Redis」?因为 MySQL 写和 Redis 写没法做原子——并发场景下「先写 Redis 后写 MySQL」和「先写 MySQL 后写 Redis」都可能产生短暂不一致,「删」比「更新」更安全(删幂等、更新不幂等)。配合 TTL,即使删失败,过期后也会自愈。

第八步:穿透 / 击穿 / 雪崩——三种故障的代码模式

这是从课程总览(index.md)里搬过来的一节,那一篇只讲了概念,这里把每种故障连同代码模式讲清楚。Redis 挡在 MySQL 前面就意味着:一旦 Redis 没挡住流量,压力就突然落到 MySQL 上。三种「没挡住」的典型情况就是穿透、击穿、雪崩。

故障为什么出现典型场景主要后果代码模式
缓存穿透请求的数据 Redis 没有、MySQL 也没有攻击者请求不存在的 id每次都打到 MySQL缓存空值 / 布隆过滤器
缓存击穿一个热 key 过期,瞬间大量并发请求秒杀商品、爆款帖子详情过期一波请求同时打 MySQLSETNX 互斥锁重建缓存
缓存雪崩大量 key 同时过期批量灌缓存时统一 TTL大面积请求打 MySQLTTL 加随机抖动

穿透:请求的数据根本不存在

攻击者拿 /posts/99999999 这种不存在的 id 一直刷。cache-aside 的标准逻辑里,MySQL 查不到就不写 Redis,所以下一次同一个 id 还是 cache miss、还是查 MySQL。每次都打到库。

两种修法:

缓存空值——MySQL 查不到时也写 Redis,写一个特殊标记(比如 "NULL" 或空字符串),TTL 设短(几分钟):

// 对照写法:缓存空值防穿透
const cached = await this.redis.get(cacheKey);
if (cached === 'NULL') return null;          // 上次查过、确实没有
if (cached) return JSON.parse(cached);
const post = await this.postRepo.findOneBy({ id });
if (!post) {
  await this.redis.set(cacheKey, 'NULL', 60); // 把"没有"也缓存 60 秒
  return null;
}
await this.redis.set(cacheKey, JSON.stringify(post), 300);
return post;

简单、立竿见影,缺点是攻击者换不同 id 还是要打 MySQL(每个不存在的 id 都得先缓存一次空值)。

布隆过滤器——在 Redis 前面挡一层,预先记下所有可能存在的 id。请求来了先问布隆过滤器「这个 id 可能存在吗」,可能不存在直接拒绝、不查 Redis 不查 MySQL。Redisson(Java)/ bloom-filters(npm)有现成实现。布隆过滤器的特点是有假阳性、无假阴性:说「可能存在」要再查库确认,说「不存在」就一定不存在。代价是要维护过滤器(新增 id 时往里加)。learnhub 没用布隆——它的帖子 id 自增、攻击者要枚举也容易,更靠「参数校验 + 接口限流」兜底。

击穿:热 key 过期瞬间被打穿

某个爆款帖子被缓存了,TTL 一到、key 失效,正好这一瞬间一万个人同时访问——全部 cache miss、全部去查 MySQL。这就是击穿。

修法是互斥锁重建缓存:cache miss 后,只让一个请求查 MySQL 重建缓存,其他请求等着或返回旧值。Redis 的 SET key value NX EX seconds(不存在才 set、带过期)天然适合做这把锁:

// 对照写法:SETNX 互斥锁重建缓存,防击穿
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);

const lockKey = `${cacheKey}:lock`;
const lockAcquired = await this.redis.set(lockKey, '1', 5); // SET NX EX 5(封装里用 SET ... NX)
if (lockAcquired) {
  try {
    // 双重检查:拿到锁后再查一次缓存,可能别人已经重建好了
    const recheck = await this.redis.get(cacheKey);
    if (recheck) return JSON.parse(recheck);
    const post = await this.postRepo.findOneBy({ id });
    if (post) await this.redis.set(cacheKey, JSON.stringify(post), 300);
    return post;
  } finally {
    await this.redis.del(lockKey); // 释放锁
  }
}
// 没拿到锁:等几十毫秒重试,或直接返回旧值 / 降级数据
await new Promise((r) => setTimeout(r, 50));
return this.findOneWithCache(id);

四个细节,全是踩坑换来的:

  • SET NX EX 一条命令搞定「加锁 + 设过期」,不要写「SETNX + EXPIRE」两步——两步之间挂了就是死锁。这是和「set + expire」两步同理的坑。
  • 锁一定要带过期:拿到锁的请求挂了(OOM、进程被杀),锁不释放就成了永久死锁。过期时间要略大于「查 MySQL + 写 Redis」的最坏耗时,几秒级合适。
  • finally 里释放锁:保证正常 / 异常 / 超时都释放。
  • 双重检查:拿到锁后再查一次缓存。原因是有可能锁等待期间别人已经重建好了——直接用现成的,省一次 MySQL 查询。

注意:上面用 del 释放锁其实有个边界问题——如果锁的 holder 挂了、过期后另一个请求拿到锁,第一个请求恢复过来执行 del 会把新锁删了。严格做法是加锁时 value 写一个唯一 id、释放时用 lua 脚本「比对 value 一致才删」。生产里要这么严,学习项目用 del 够了。

雪崩:大量 key 同时过期

批量灌缓存时如果 TTL 都设成一样的(比如都是 3600 秒),一小时后这一批 key 同时失效,瞬间所有请求都打到 MySQL——这就是雪崩。和击穿的区别是规模:击穿是一个热 key、雪崩是一批 key 同时挂。

修法很直接——TTL 加随机抖动

// 对照写法:TTL 加随机抖动,防雪崩
const baseTtl = 3600;
const jitter = Math.floor(Math.random() * 300); // 0~300 秒随机
await this.redis.set(cacheKey, JSON.stringify(post), baseTtl + jitter);

把 TTL 从「3600 秒」变成「3600~3900 秒之间随机」,失效时间被均匀打散到几分钟区间内,不会出现「整批同时挂」。注意:TTL 抖动是写缓存时的标配,cache-aside 的封装方法默认就该带随机,不该留给调用方记。

雪崩还有一种成因——Redis 整体挂了。这种只能靠高可用(主从 + 哨兵 / Cluster)+ 限流降级(Redis 挂了时本地降级返回兜底数据、不让流量全压 MySQL)来防。这是运维和架构层面的事,不在代码层面。

第九步:cache-manager 作为替代——便利 vs 控制权

前面用 ioredis 手封了 RedisService,写了 cache-aside、互斥锁、TTL 抖动。Nest 还有另一条路:@nestjs/cache-manager + CacheModule。它把缓存抽象成 Cache 接口,注册后挂个拦截器就能给接口自动缓存:

// 对照写法:cache-manager 自动缓存(learnhub 未使用)
import { CacheModule } from '@nestjs/cache-manager';
import { Controller, Get } from '@nestjs/common';

@Module({
  imports: [CacheModule.register({ ttl: 60, isGlobal: true })],
})
export class AppModule {}

@Controller('posts')
export class PostController {
  @Get(':id')
  @UseInterceptors(CacheInterceptor)          // 自动缓存返回值
  @CacheKey('post_detail')
  @CacheTTL(60)
  detail(@Param('id') id: number) { /* ... */ }
}

便利是显而易见的:不用手写「查 Redis → 查 MySQL → 回写」、不用手封 service、不用记 TTL。代价也明显:

  • 拿到的是 Cache 抽象,不是 Redis clientINCR / ZSET / pub/sub / pipeline / lua 全用不了——一旦有计数、排行榜、锁、限流这种活,还得绕开它再单独引一个 ioredis,结果就是项目里两套 Redis 客户端并存,配置和连接都重复。
  • 拦截器级缓存的 key 自动生成,做精细 key 控制(比如「带用户 id 的个性化缓存」「分页参数入 key」)反而麻烦。
  • 缓存失败模式(穿透/击穿/雪崩)的修法都得自己叠,cache-manager 不替你想这些。

判断口径很简单:业务只需要「给接口返回值加一层缓存」,用 cache-manager;要用 Redis 当计数器、排行榜、锁、队列、发布订阅,用 ioredis 手封。learnhub 一上来就要计数和榜单,所以选了 ioredis。一个项目里两种同时用也常见——cache-manager 做查询缓存、ioredis 做计数——只要别让它们读同一批 key 互相打架就行。

这一章的成果

  1. @Global() + useFactory + 字符串 token 把 ioredis 封成全局 RedisModule / RedisService,配置走 env,理解了「拿原生 client vs 拿缓存抽象」的取舍。
  2. 吃透了 Redis 当写缓冲的完整闭环:recordView 一次写三个 key(增量 String INCR + 日月榜 ZINCRBY)、@Cron 每分钟 getdel 原子取增量、TypeORM increment 累加进 MySQL、失败回滚。
  3. 学会了 Sorted Set 排行榜的套路:ZINCRBY 累加、ZREVRANGE ... WITHSCORES 取 Top N、ZUNIONSTORE 把 12 个月榜合并成年榜。
  4. 走了一遍 cache-aside 模式(先 Redis、未命中查 MySQL 回写、先更库再删缓存),并理解了 learnhub 为什么没用它做查询缓存。
  5. 把**穿透(缓存空值 / 布隆过滤器)、击穿(SETNX 互斥锁重建)、雪崩(TTL 抖动)**三种故障连同代码模式讲透,知道 cache-aside 上线前该过哪些关。

常见问题

  • 为什么 learnhub 的查询接口不加 cache-aside? 详情/列表查询直接打 MySQL,是因为现阶段数据量没到要靠缓存扛的程度。一旦详情页 QPS 上来(比如某帖被全网转)、或者列表查询慢,就该上 cache-aside——但先有指标、再加缓存,没数据依据就加缓存是过度设计。
  • getdelget + del 有什么区别? getdel 是 Redis 6.2+ 的一条原子命令,取值同时删除;get + del 是两条命令,中间有间隙,并发下会丢增量。flush 这种「取出即清零」的场景必须 getdel
  • KEYS 真的不能用吗? 学习项目(几千个 key)能用,生产(几百万个 key)绝对不行——KEYS 阻塞 Redis 主线程,扫一次能卡几秒,期间所有别的命令都排队。生产用 SCAN 游标式扫,每次返回一小批、不阻塞。
  • Redis 数据会丢吗? Redis 默认异步持久化(RDB 快照 + AOF 日志),重启可能丢最近一小段时间数据。浏览量、验证码这种丢了能接受;要是拿 Redis 存了「下单成功」这种不能丢的数据,必须开 AOF + appendfsync everysec,甚至更好——这种关键数据本来就该进 MySQL。
  • 浏览量延迟一分钟才显示,用户会不会觉得 bug? 会。所以前端显示浏览量要么直接显示 MySQL 里的累计值(接受一分钟延迟)、要么显示「MySQL 累计值 + Redis 实时增量」(currentViews 方法就是干这个的、读增量 key 不 flush)。展示和存储分开想,别让存储的延迟暴露给用户。
  • ZUNIONSTORE 跑得慢吗? 取决于源 ZSET 的大小。learnhub 一年几个帖子的话没问题;如果月榜里有几十万 member、12 个这样的月榜合并,就是 O(几十万 × 12) 的操作,会卡一会儿。生产大数据量榜单更常见的做法是写入时同时维护年榜(写放大换读取性能),或者在年榜 key 上加锁 + 后台任务定期重算 + EXPIRE

下一章讲 Swagger——给接口自动生成可交互的 API 文档,前端不用再问「这个接口要传什么」,直接看文档甚至在线调试。