缓存防的是"重复查同一份数据"。但如果用户查的根本不是数据呢?商品详情接口的 {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
2
3
4
5
// src/service/SeckillService.cc :: queryDetailFromDb —— 查不到也要缓存(空值占位)
db_->execSqlAsync("SELECT ... FROM seckill_sku WHERE id = ?", ..., skuId);
// 回调里 result.size() == 0 时:
if (writeCache && cache_) cache_->setNull(skuId); // 把"不存在"也写进缓存
cb(false, Json::Value()); // 返回 404 语义
1
2
3
4
5
6
7
// src/service/SkuCache.cc :: setNull —— 空值哨兵本体
void SkuCache::setNull(int64_t skuId) {
// 空值占位:DB 里没有的商品也缓存下来,TTL 用最短的一档。
setex(keys_.item(skuId), kNullValue, ttlWithJitter(cfg_.nullTtlSeconds));
}
// kNullValue = "__nil__" —— 一个裸字符串,绝不可能是合法 JSON 对象/数组,
// 因此和真实数据永远不会混淆(判断不需要"约定",是类型层面就不可能)。

读侧命中哨兵时直接短路,连 JSON 解析都省了:

1
2
3
4
5
// SeckillService::detailSku —— 命中 __nil__ = 没打 DB 就知道不存在
if (value == seckill::cache::SkuCache::kNullValue) {
cb(false, Json::Value()); // 前端按 404 处理
return;
}

效果:同一个不存在 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
2
3
4
5
# 手动验证(WSL):
curl -s -o /dev/null localhost:8080/api/seckill/200001 # 商品数+1,必不存在
redis-cli GET seckill:sku:v1:item:200001 # __nil__
redis-cli TTL seckill:sku:v1:item:200001 # ~60~90(60 + 抖动)
# 再打第二次:命中哨兵直接 404,DB 的 QPS 不涨(stats 的 miss 不增加)

六、小结

结论
穿透的本质 缓存只给"存在"兜底,对"不存在"无解;解法是把"不存在"也缓存
空值哨兵 DB 查无 → SETEX key "__nil__" 60s;读侧命中哨兵直接 404,不解析不回源
哨兵取值 __nil__ 字符串,类型层面不可能与 JSON 合法载荷混淆
TTL 60s:防穿透强度与"新商品可见延迟"的折中,随上下架频率可调
适用量级 任何量级都成立(无状态、按需生长);20 万量级实测不漏
与布隆分工 哨兵挡"重复打同一个不存在 id";布隆挡"海量随机不存在 id"(5.7)

🐾 核心三句话:穿透是"查不存在",缓存天然不防,所以要把"不存在"变成可缓存的状态;空值哨兵的 TTL 是安全与一致性的交易,秒杀商品集合静态、60 秒足够;它无状态、按需生长、任何量级先上,而"海量随机 id"这种更凶的打法,留给 5.7 的布隆过滤器在更前面挡。