秒杀系统高并发优化实战(C++ / Drogon):5.6 防缓存穿透:空值策略——把"查无此物"也缓存下来
缓存防的是"重复查同一份数据"。但如果用户查的根本不是数据呢?商品详情接口的 {skuId} 是用户可控的,拿一个不存在的 id 狂刷,缓存里永远没有这个 key——每一次请求都穿透到 MySQL 查一个空结果。这一篇把缓存穿透讲完整:攻击者怎么打、空值策略为什么有效、TTL 定多长是安全与一致性的折中、以及它和 5.7 布隆过滤器怎么分工。代码(SkuCache::setNull 空值哨兵)早在 5.3 就随详情缓存一起落地了,本篇是把"为什么这么做"补全。
本文是「秒杀系统(C++ / Drogon)」系列第五章第六篇。配套代码:
src/service/SeckillService.cc::queryDetailFromDb(查无此物 →cache_->setNull)、src/service/SkuCache.cc::setNull(__nil__哨兵 + 60s TTL)。代码对应阶段二v0.2.x。
一、缓存穿透:是什么、坑在哪、本质一句话
- 是什么:攻击者(或手滑的用户)请求一个数据库中不存在的资源,比如
/api/seckill/999999。缓存没有这个 key → 每次都回源 DB → DB 查不到 → 返回 404。循环往复。 - 坑:缓存穿透时,缓存层对这部分流量完全失效——它防的是"重复读",而穿透流量里每个 id 都是"新的不存在",缓存机制从根上不适用。用随机 id 打,每秒可以打穿几十上百次;如果是并发地打,DB 的连接和 CPU 都被"查空结果"吃光,而正常商品请求反而被挤掉。
- 本质一句话:缓存穿透是"缓存只给存在的东西兜底、对不存在的东西毫无办法"——解法不是让缓存更聪明,而是让"不存在"这件事本身也可缓存。
二、打穿透的三种姿势(以及它们各自该防在哪)
| 姿势 | 例子 | 杀伤 | 对策 |
|---|---|---|---|
| 同一个不存在 id 反复打 | 刷 /api/seckill/999999 |
每次穿透到 DB | 空值哨兵(本篇):查过一次、确认不存在,就缓存这个结论,TTL 内不再回源 |
| 海量随机不存在 id 打 | 循环打 1..10^6 里的随机数 | 每个 id 只打一次,但总量巨大 | 布隆过滤器(5.7):内存里维护"真实存在 id 的集合",集合外直接 404,连 Redis 都不打 |
| 已下架/已删除 id 打 | 打活动结束下架的商品 | 查得到"曾经存在"但当下不存在 | 本项目无下架机制(集合静态);真实系统里通常合并进布隆集合的"失效重建"或空值 TTL 短档 |
空值哨兵和布隆不矛盾、是互补:空值哨兵挡"重复打同一个不存在的 id",布隆挡"海量随机不存在的 id"。秒杀商品少(几十个)时,空值哨兵一个就够;20 万量级、id 可被随机枚举时,才需要布隆在更前面兜。
三、空值策略的落地:一个不可能是合法 JSON 的哨兵值
详情接口回源 DB 后查无此行的分支,5.3 是这样写的:
1 | // src/service/SeckillService.cc :: queryDetailFromDb —— 查不到也要缓存(空值占位) |
1 | // src/service/SkuCache.cc :: setNull —— 空值哨兵本体 |
读侧命中哨兵时直接短路,连 JSON 解析都省了:
1 | // SeckillService::detailSku —— 命中 __nil__ = 没打 DB 就知道不存在 |
效果:同一个不存在 id,TTL 内第一次打穿到 DB,之后全部被哨兵接住——每分钟最多穿透一次。
四、哨兵 TTL 定多长:安全与一致性的一笔交易
空值哨兵的 TTL 是最需要斟酌的参数,它是一笔双向交易:
| TTL | 防穿透强度 | 一致性代价 |
|---|---|---|
| 太长(如 10 分钟) | 强(每分钟才放一次) | 新上架的商品最久 10 分钟查不到——运营上了新 sku,用户刷新仍 404 |
| 太短(如 1 秒) | 弱(每秒都能穿透一次) | 几乎没有(新商品秒级可见) |
本项目的选择:60 秒(null_ttl_seconds,含抖动)。理由:秒杀商品由运营一次性导入、活动期不新增——60 秒的"新商品延迟可见"完全可接受;而攻击者用随机 id 打库,每个 id 每分钟最多穿透一次,足够把"打穿"变成"打不穿"。真实系统里如果有频繁上下架,就把空值 TTL 调短,把成本从一致性换到 DB 上——又是那句老话:这是显式开关,不是悄悄写死。
五、为什么空值哨兵在 20 万量级也成立(已实测的旁证)
5.3 的 20 万量级压测里,顺带验证了空值哨兵在大 key 空间下不漏:商品连续 id 1..200000,用 200001(必超范围)打一次,Redis 里出现 seckill:sku:v1:item:200001 = __nil__,TTL ≈ 84s(60s + 抖动)。也就是说:数据量级涨到 20 万,哨兵逻辑不需要任何改动——它不关心总共有多少商品,只关心"这个 id 查没查过、查出来是不是空"。这正是它和布隆(5.7,需要知道全集)的另一个区别:空值哨兵无状态、按需生长,是任何量级都能先上的第一道防线。
1 | # 手动验证(WSL): |
六、小结
| 项 | 结论 |
|---|---|
| 穿透的本质 | 缓存只给"存在"兜底,对"不存在"无解;解法是把"不存在"也缓存 |
| 空值哨兵 | DB 查无 → SETEX key "__nil__" 60s;读侧命中哨兵直接 404,不解析不回源 |
| 哨兵取值 | 裸 __nil__ 字符串,类型层面不可能与 JSON 合法载荷混淆 |
| TTL | 60s:防穿透强度与"新商品可见延迟"的折中,随上下架频率可调 |
| 适用量级 | 任何量级都成立(无状态、按需生长);20 万量级实测不漏 |
| 与布隆分工 | 哨兵挡"重复打同一个不存在 id";布隆挡"海量随机不存在 id"(5.7) |
🐾 核心三句话:穿透是"查不存在",缓存天然不防,所以要把"不存在"变成可缓存的状态;空值哨兵的 TTL 是安全与一致性的交易,秒杀商品集合静态、60 秒足够;它无状态、按需生长、任何量级先上,而"海量随机 id"这种更凶的打法,留给 5.7 的布隆过滤器在更前面挡。

