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/queryDetailFromDbsrc/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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// src/service/SeckillService.cc —— 5.3 详情缓存读路径(+ 空值占位防穿透)
void SeckillService::detailSku(int64_t skuId,
std::function<void(bool, const Json::Value &)> &&callback) {
auto cb = std::move(callback);
if (cache_ && cache_->enabled()) {
cache_->getDetail(skuId, [cb, this, skuId](bool hit, const std::string &value) {
if (hit) {
// 空值哨兵:上一次已查过库、确认这个 sku 不存在。
// 命中它等于「没打数据库就知道不存在」——正是防穿透要的效果。
if (value == seckill::cache::SkuCache::kNullValue) {
cb(false, Json::Value());
return;
}
Json::Value item;
if (fromJson(value, item) && item.isObject()) {
cb(true, item);
return;
}
SK_LOG_WARN << "CACHE_ITEM_CORRUPTED skuId=" << skuId;
cache_->invalidate(skuId); // 坏值连带删列表,一起作废更省心
}
queryDetailFromDb(skuId, cb, /*writeCache=*/true); // 回源并回写
});
return;
}
queryDetailFromDb(skuId, cb, /*writeCache=*/false);
}

void SeckillService::queryDetailFromDb(int64_t skuId, ..., bool writeCache) {
db_->execSqlAsync(
"SELECT id, name, stock, total, "
"DATE_FORMAT(start_time, '%Y-%m-%d %H:%i:%s') AS start_time, "
"DATE_FORMAT(end_time, '%Y-%m-%d %H:%i:%s') AS end_time "
"FROM seckill_sku WHERE id = ?",
[cb, writeCache, this, skuId](const drogon::orm::Result &result) {
if (result.size() == 0) {
// 查不到也要缓存(空值占位):否则随机不存在的 id 反复请求,
// 每次都穿透到 DB —— 缓存层失效,这就是缓存穿透
if (writeCache && cache_) cache_->setNull(skuId);
cb(false, Json::Value());
return;
}
// ...拼 item...
if (writeCache && cache_) cache_->setDetail(skuId, toCompactJson(item));
cb(true, item);
}, ...);
}
图 1:详情读路径含空值哨兵(穿透被挡在 Redis 内) 请求 SkuCache Redis MySQL ① GET item ② 命中 JSON → 返回(DB 没碰) ③ 命中 __nil__ → 直接返回「不存在」 ④ 真未命中 → 回源;查到回写 JSON,查不到回写 __nil__ ①②③ 全在 Redis 内完成,DB 只服务 ④ 这一小撮「有价值的读」

三、空值哨兵:为什么安全、为什么 TTL 要短、为什么不是布隆

空值哨兵就是把「DB 里确实没有这个 sku」也缓存成一个特殊值,让后续穿透请求在 Redis 层就被挡下:

1
2
3
4
5
6
7
8
9
10
// src/service/SkuCache.h / SkuCache.cc —— 空值哨兵
static constexpr const char *kNullValue = "__nil__"; // 裸字符串,绝不可能是合法 JSON

void SkuCache::setNull(int64_t skuId) {
// 空值占位的 TTL 用最短的一档(60s):
// 设长了,刚上架的商品要等几分钟才能被看到;
// 设短了,穿透防护就弱。60s 意味着攻击者用随机 id 打库,
// 每个 id 每分钟最多穿透一次 —— 足以把「打穿」变成「打不穿」。
setex(keys_.item(skuId), kNullValue, ttlWithJitter(cfg_.nullTtlSeconds));
}

为什么 __nil__ 不会和真实数据混淆:业务 value 只可能是 JSON 对象或数组,一个裸的 __nil__ 字符串不可能是合法 JSON。读路径命中后先判 == kNullValue 即可,不需要额外的类型标记字段。

为什么不用布隆过滤器(5.7 才展开):布隆过滤器解决的是「id 集合海量且基本不变」的穿透;秒杀商品是几十到几百个、还会上下架,空值缓存性价比更高且没有误判(布隆有假阳性,可能把存在的商品误判为不存在)。等商品量级真正上去再引入布隆做前置校验。

为什么在 20 万量级仍正确:哨兵验证用 NONE_ID = 总量 + 1(连续 id 下必超范围),/api/seckill/200001 在 20 万条数据里必然查无此物 → 写入 __nil__,TTL 84s(60s + 抖动)。这证明大 key 空间下穿透防护不漏。

图 2:空值哨兵把「穿透攻击」挡在 Redis 内 攻击者 GET item/随机id Redis MySQL 命中 __nil__ → 拦下,不回源 无哨兵:随机 id 每次穿透 → DB 被打穿 有哨兵:每个 id 每分钟最多穿透一次(TTL 60s)→ 打不穿

四、写侧失效(与列表一致)

详情缓存的写后失效复用 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 逐级深入。