秒杀系统高并发优化实战(C++ / Drogon):5.3 加 Redis 缓存:商品详情接口(含空值哨兵防穿透)
4.2 把商品详情接口(GET /api/seckill/{skuId})跑通了,主键点查、直连 MySQL。这一篇给它套缓存,并顺手落地一个常被放到 5.6 才讲的能力——空值哨兵防缓存穿透。原因很实际:详情接口的 {skuId} 是用户可控的,攻击者拿随机 id 狂打,缓存层若不做处理,会退化成「每次都穿透到 DB」,等于没缓存。所以防穿透和详情缓存是同一道题的正反两面,一起讲。
本文是「秒杀系统(C++ / Drogon)」系列的第五章第三篇,也是 5.1/5.2/5.3 读缓存三篇的收口。配套代码:
src/service/SeckillService.cc::detailSku/queryDetailFromDb、src/service/SkuCache.*(setNull/kNullValue)。代码对应阶段二v0.2.x。Key 规范、TTL 抖动、列表缓存见 5.1/5.2。
一、详情缓存 + 空值哨兵:是什么、坑在哪、本质一句话
- 是什么:详情接口按
skuId主键点查,是「单品」语义,天然适合做 key=seckill:sku:v1:item:{id}的缓存;命中后连 DB 都不碰。 - 坑(缓存穿透):用户拿一个不存在的 id(随机整数、已下架商品)狂刷,缓存里没有这个 key → 每次都穿透到 DB 查「空结果」。攻击者用随机 id 打库,每个 id 每分钟能穿透几十上百次,缓存层对这种攻击完全失效。
- 本质一句话:正常命中把读挡在 Redis 门内,空值哨兵把「查无此物」也缓存下来——两者合起来,DB 才真正只服务「有价值的读」。
二、读路径:Cache-Aside + 空值哨兵
详情读路径和列表同构(命中返回),多了一环:命中后先判是不是空值哨兵,是则直接当「不存在」返回,连 JSON 解析都省了:
1 | // src/service/SeckillService.cc —— 5.3 详情缓存读路径(+ 空值占位防穿透) |
三、空值哨兵:为什么安全、为什么 TTL 要短、为什么不是布隆
空值哨兵就是把「DB 里确实没有这个 sku」也缓存成一个特殊值,让后续穿透请求在 Redis 层就被挡下:
1 | // src/service/SkuCache.h / SkuCache.cc —— 空值哨兵 |
为什么 __nil__ 不会和真实数据混淆:业务 value 只可能是 JSON 对象或数组,一个裸的 __nil__ 字符串不可能是合法 JSON。读路径命中后先判 == kNullValue 即可,不需要额外的类型标记字段。
为什么不用布隆过滤器(5.7 才展开):布隆过滤器解决的是「id 集合海量且基本不变」的穿透;秒杀商品是几十到几百个、还会上下架,空值缓存性价比更高且没有误判(布隆有假阳性,可能把存在的商品误判为不存在)。等商品量级真正上去再引入布隆做前置校验。
为什么在 20 万量级仍正确:哨兵验证用 NONE_ID = 总量 + 1(连续 id 下必超范围),/api/seckill/200001 在 20 万条数据里必然查无此物 → 写入 __nil__,TTL 84s(60s + 抖动)。这证明大 key 空间下穿透防护不漏。
四、写侧失效(与列表一致)
详情缓存的写后失效复用 5.2 的机制:下单事务 committed == true 后调 invalidateOnOrder(skuId),默认只删详情(item)。详情 key 是单品,被删后下次读自然重建,不影响其他 sku 的缓存——这正是「详情维度失效」相比「列表维度失效」友好的地方。
五、功能抉择
① 空值哨兵 vs 布隆过滤器
当前选空值哨兵:实现零依赖(一个特殊字符串 + 短 TTL)、无假阳性、对「几十到几百个还会上下架」的秒杀商品性价比最高。代价是「不存在的 id」也会占一个 key(内存成本),但 20 万量级下这点内存可忽略。等商品量级与 id 空间真正膨胀,再在前面加布隆做「前置否决」。
② 空值哨兵的 TTL 是安全与一致性的折中
60s 是平衡点:太长,新上架商品要等几分钟才可见(一致性损失);太短,穿透防护变弱。它不是「永久缓存不存在」,而是「用一分钟的短期代价换 DB 不被打穿」。
③ 详情维度失效而非列表维度
详情 key 彼此独立,删一个 sku 的详情不影响其他 19.9 万个 key 的命中率;列表 key 是全量聚合,反而是「谁都不删 / 只删详情」才能保住高命中率。两个接口用同一套 invalidateOnOrder 开关,但默认 item 对详情最友好。
六、效果
详情接口接入缓存 + 空值哨兵后,在 20 万条商品量级(JMeter 5.6.3 引擎) 下的压测结果(两量级完整对照见 5.1):
| 接口 | off QPS | on QPS | 提升 | avg |
|---|---|---|---|---|
| detail | 5591.2 | 8023.5 | ×1.44(+44%) | 6.22 → 3.23ms |
整体口径(list + detail 同窗口):命中率 83.8%(hit=934466 / miss=180538 / err=0),DB 读负载降约 77%,平均延迟砍半。空值哨兵验证:/api/seckill/200001 → 写入 __nil__,TTL 84s,大 key 空间下不漏。
量级洞察(贯穿 5.1~5.3):20 条小量级下本地 MySQL SELECT <1ms、对照组太强,缓存只动 QPS +4~5%;量级一上到 20 万,83.8% 的读被挡在 Redis 门外,缓存才显出 ×1.40 / ×1.44 的乘数级提升。真实部署里 MySQL 在远端、有连接池上限、洪峰读放大会先打满 DB——缓存挡掉的不是 4% 的 QPS,是 83.8% 可能压垮 DB 的读流量。
小结
- 详情接口读路径 Cache-Aside:命中 JSON 直接返回;命中空值哨兵
__nil__直接返回「不存在」,连解析都省。🐾 - 空值哨兵防缓存穿透:查无此物的 id 也缓存 60s,攻击者每个 id 每分钟最多穿透一次 → 打不穿。
__nil__是裸字符串,不可能是合法 JSON,不会与真实数据混淆;TTL 短(60s)是安全与一致性的折中。- 当前选空值哨兵而非布隆过滤器(无假阳性、零依赖),量级膨胀后再加布隆前置。
- 20 万量级实测:detail ×1.44(+44%)、命中率 83.8%、DB 读负载降约 77%;量级决定缓存收益能见度。
- 读缓存三篇(5.1 规范 / 5.2 列表 / 5.3 详情+防穿透)闭环。后续 5.4 延迟双删、5.5 预热、5.6 正文、5.7 布隆、5.8 本地 LRU 逐级深入。

