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 + del、ZUNIONSTORE 是怎么把月榜拼成年榜的,以及 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)、原子 flush(getdel+@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,ZSET、INCR、pipeline、lua、pub/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'),不是类。原因是ioredis的Redis类不是我们自己写的、它身上没有 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 浏览量它是关键——比get再del安全得多。ztopWithScores自己拼对:ioredis 的zrevrange(..., 'WITHSCORES')返回的是[m1, s1, m2, s2, ...]扁平数组,业务想要的是[[m, s], ...],所以 service 里循环重排成对。这种「把 Redis 的返回结构翻译成业务友好结构」就是 service 层该干的事。zunionstore用client.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;
}
注意三件事:
recordView没await:它是个 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 求和后存到 destination。2026-01、2026-02…2026-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 任务要在某个@Module的imports里ScheduleModule.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 当写缓冲的完整闭环就清楚了:详情接口 findOne → recordView 写 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 的查询接口(findOne、findMany)没有走 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 过期,瞬间大量并发请求 | 秒杀商品、爆款帖子详情过期 | 一波请求同时打 MySQL | SETNX 互斥锁重建缓存 |
| 缓存雪崩 | 大量 key 同时过期 | 批量灌缓存时统一 TTL | 大面积请求打 MySQL | TTL 加随机抖动 |
穿透:请求的数据根本不存在
攻击者拿 /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 client。INCR/ZSET/pub/sub/pipeline/lua全用不了——一旦有计数、排行榜、锁、限流这种活,还得绕开它再单独引一个 ioredis,结果就是项目里两套 Redis 客户端并存,配置和连接都重复。 - 拦截器级缓存的 key 自动生成,做精细 key 控制(比如「带用户 id 的个性化缓存」「分页参数入 key」)反而麻烦。
- 缓存失败模式(穿透/击穿/雪崩)的修法都得自己叠,cache-manager 不替你想这些。
判断口径很简单:业务只需要「给接口返回值加一层缓存」,用 cache-manager;要用 Redis 当计数器、排行榜、锁、队列、发布订阅,用 ioredis 手封。learnhub 一上来就要计数和榜单,所以选了 ioredis。一个项目里两种同时用也常见——cache-manager 做查询缓存、ioredis 做计数——只要别让它们读同一批 key 互相打架就行。
这一章的成果
- 用
@Global()+useFactory+ 字符串 token 把 ioredis 封成全局RedisModule/RedisService,配置走 env,理解了「拿原生 client vs 拿缓存抽象」的取舍。 - 吃透了 Redis 当写缓冲的完整闭环:
recordView一次写三个 key(增量 String INCR + 日月榜 ZINCRBY)、@Cron每分钟getdel原子取增量、TypeORMincrement累加进 MySQL、失败回滚。 - 学会了 Sorted Set 排行榜的套路:
ZINCRBY累加、ZREVRANGE ... WITHSCORES取 Top N、ZUNIONSTORE把 12 个月榜合并成年榜。 - 走了一遍 cache-aside 模式(先 Redis、未命中查 MySQL 回写、先更库再删缓存),并理解了 learnhub 为什么没用它做查询缓存。
- 把**穿透(缓存空值 / 布隆过滤器)、击穿(SETNX 互斥锁重建)、雪崩(TTL 抖动)**三种故障连同代码模式讲透,知道 cache-aside 上线前该过哪些关。
常见问题
- 为什么 learnhub 的查询接口不加 cache-aside? 详情/列表查询直接打 MySQL,是因为现阶段数据量没到要靠缓存扛的程度。一旦详情页 QPS 上来(比如某帖被全网转)、或者列表查询慢,就该上 cache-aside——但先有指标、再加缓存,没数据依据就加缓存是过度设计。
getdel和get+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 文档,前端不用再问「这个接口要传什么」,直接看文档甚至在线调试。