4.1 我们把商品列表接口(GET /api/seckill/list)跑通了,但那时它每次都直连 MySQL。到了第五章,第一件事就是给这个被刷新最频繁的接口套一层 Redis 缓存。它的 key seckill:sku:v1:list 是「全量商品」的聚合值,是热点中的热点——值得专门写一篇讲清楚它和详情缓存在取舍上的不同。

本文是「秒杀系统(C++ / Drogon)」系列的第五章第二篇。配套代码:src/service/SeckillService.cc::listSkus/queryListFromDbsrc/service/SkuCache.*src/main.cc 缓存配置解析。代码对应阶段二 v0.2.x。Key 规范与 TTL 抖动见上一篇 5.1。

一、列表缓存:是什么、坑在哪、本质一句话

  • 是什么:列表接口返回全部商品(LIMIT 100),结果对所有用户一致、允许短暂陈旧——教科书级的缓存对象。
  • :列表是一个全量聚合 key,任意一件 sku 成交都改变了它的内容。如果「写后失效」策略选错(把列表也一起删),在写 QPS 高时这个 key 会被反复删掉,命中率直接趋零——缓存等于不存在。
  • 本质一句话列表缓存是「整体失效」语义,删比改安全;而「写后删哪些 key」是一个必须显式抉择的开关,不能悄悄写死

列表页的「库存还剩几件」对下单决策没有意义(真实秒杀里列表本就该静态化),所以默认只删详情、让列表保持高命中率,是这个接口的核心权衡。

二、读路径:Cache-Aside

读路径顺序固定为「读缓存 → 命中返回 → 未命中回源 → 回写 → 返回」。缓存不承担任何正确性责任,它坏了/慢了都只是「多查一次库」:

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
// src/service/SeckillService.cc —— 5.2 列表缓存读路径
void SeckillService::listSkus(
std::function<void(bool, const Json::Value &)> &&callback) {
auto cb = std::move(callback); // 先 move 进 cb,后续各路径【拷贝】使用(阶段一踩过二次 move 的 core)

// 缓存读:命中则连 DB 都不碰
if (cache_ && cache_->enabled()) {
cache_->getList([cb, this](bool hit, const std::string &value) {
if (hit) {
Json::Value arr;
if (fromJson(value, arr) && arr.isArray()) {
cb(true, arr);
return;
}
// 缓存值非法(结构升版 / 被人工改坏):按未命中处理并顺手删掉,
// 留着只会让每次读都白解析一次
SK_LOG_WARN << "CACHE_LIST_CORRUPTED size=" << value.size();
cache_->invalidateList();
}
queryListFromDb(cb, /*writeCache=*/true); // 未命中 → 回源并回写
});
return;
}
queryListFromDb(cb, /*writeCache=*/false); // 缓存没开 → 直连 DB
}

void SeckillService::queryListFromDb(..., bool writeCache) {
// LIMIT 100 同时是缓存的自我保护:列表是一个 key,行数越多 value 越大,
// 锁死 100 条(紧凑 JSON 约 15KB)就不会长成 string 大 key
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 ORDER BY id ASC LIMIT 100",
[cb, writeCache, this](const drogon::orm::Result &result) {
Json::Value arr(Json::arrayValue);
for (const auto &row : result) { /* 拼 Json::Value */ }
if (writeCache && cache_) cache_->setList(toCompactJson(arr)); // 回写 fire-and-forget
cb(true, arr);
}, ...);
}

SkuCache::getList 底层是 GET,Redis 异常时回调 hit=false(fail-open),调用方自然回源 DB——服务不会因为 Redis 没装就整站 500:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/service/SkuCache.cc —— 缓存读永远表现为「命中 / 未命中」两态,绝不抛错
void SkuCache::get(const std::string &key, GetCallback &&cb) {
if (!cfg_.enabled || !redis_) { cb(false, std::string()); return; }
redis_->execCommandAsync(
[this, cb](const drogon::nosql::RedisResult &r) {
std::string v;
try { v = r.asString(); } catch (const std::exception &) { v.clear(); }
if (v.empty()) { miss_.fetch_add(1, ...); cb(false, std::string()); return; }
hit_.fetch_add(1, ...); cb(true, v);
},
[this, cb, key](const std::exception &e) { // Redis 挂了
err_.fetch_add(1, ...);
SK_LOG_ERROR << "CACHE_GET_FAILED key=" << key << " err=" << e.what();
cb(false, std::string()); // fail-open:当未命中,回源 DB
},
"GET %s", key.c_str());
}
图 1:列表接口 Cache-Aside 读路径 请求 SkuCache Redis MySQL ① GET list ② 命中 ③ 直接返回(DB 全程没碰) ④ 未命中 → 回源 MySQL ⑤ SETEX 回写 Redis 异常时 ①④ 走 fail-open:当作未命中回源,服务不 500

三、写侧:下单成功后让列表失效(顺序不能反)

缓存要「新」,靠的是写操作后让旧值失效。本项目把 DEL 挂在事务的 setCommitCallback 里,天然保证「数据库先落定」再删缓存——反序(先删缓存再更新库)会让并发读把旧值回填进缓存,且要等 TTL 到期才自愈,是最常见的缓存不一致来源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/service/SeckillService.cc —— doSeckill 提交回调里让缓存失效
tx->execSqlAsync(/* INSERT ... ON DUPLICATE KEY UPDATE */,
[tx, userId, skuId, cb, token](const drogon::orm::Result &r2) {
if (r2.affectedRows() == 0) { tx->rollback(); cb(false, "DUPLICATE_ORDER"); return; }
// 提交路径:lambda 返回后 tx 析构触发自动 commit → setCommitCallback
}, ...);
// 在 setCommitCallback 里:
[cb, token, this, skuId](bool committed) {
if (committed) {
// committed 之后才删缓存,保证「DB 已落定」先于「缓存作废」
if (cache_) cache_->invalidateOnOrder(skuId); // 按配置决定删哪些 key
cb(true, "OK");
} else { cb(false, "DB_ERROR: commit failed"); }
});

「删哪些 key」是一个显式开关SkuCache::InvalidateOnOrder),把取舍收在一处,业务代码不写 if

1
2
3
4
5
6
7
8
// src/service/SkuCache.cc —— 三种失效策略的代价交换
void SkuCache::invalidateOnOrder(int64_t skuId) {
switch (cfg_.invalidateOnOrder) {
case InvalidateOnOrder::Item: invalidateItem(skuId); break; // 只删详情(默认)
case InvalidateOnOrder::ItemAndList: invalidate(skuId); break; // 详情 + 列表都删
case InvalidateOnOrder::None: break; // 谁都不删,等 TTL
}
}
策略 配置值 命中率 库存新鲜度 适用
只删详情 item默认 详情页准,列表页允许陈旧 真实秒杀:列表本就该静态化,"列表里还剩几件"对下单没意义
详情 + 列表都删 all 趋零 最准 不推荐:列表是全量聚合 key,任意 sku 成交都会删它,写 QPS 高时等于不存在
谁都不删 none 最高 最差 仅"库存展示完全不敏感"时用;售罄后用户最长 TTL 秒内仍看到"还有货"
图 2:先提交 DB 再 DEL(顺序不能反) ① 提交 DB ② DEL 缓存 ③ 读请求命中新值 DB 一致 反序(先 DEL 再更新 DB)的事故:DEL 后、DB 更新前来读 → 读到旧值写回缓存 → 脏值等 TTL 自愈 本项目 DEL 挂在 setCommitCallback(committed==true),天然顺序正确

四、功能抉择

① 列表缓存「删」而非「改」
列表 value 含全部 sku 的 stock,要改就得重算整个 JSON,不如交给下次读重建;且「改」要回答"我手上的新库存来自哪个事务",时序一乱就把旧值盖回去。DEL 是幂等的、与时序无关——任意一次 DEL 之后到达的读请求必然 miss 重建。

invalidate_on_order 默认 item 的权衡
列表是全量聚合 key,任意 sku 成交都改变它。若选 all(详情+列表都删),写 QPS 一高列表缓存就被反复删、命中率趋零。真实秒杀里「列表还剩几件」对下单决策无意义,列表本就该静态化/CDN 化,所以默认只删详情、保列表高命中率。这个开关存在的意义是把取舍显式化,而不是悄悄写死一个行为让后人猜。

③ 配置集中解析,Redis 不可用则强制关缓存
main.ccconfig.jsoncache 块读进 SkuCache::Config;若 Redis 客户端没连上,enabled 强制置 false——否则每个读请求都会去打一个空客户端,反而更慢。

五、效果回顾

列表接口接入缓存后,在 20 万条商品量级(JMeter 引擎)下的压测结果(完整两量级对照见 5.1):

接口 off QPS on QPS 提升 avg
list 7544.2 10556.9 ×1.40(+40%) 6.95 → 3.84ms

命中率 83.8%、DB 读负载降约 77%——列表这个「全量热点 key」被缓存挡在 Redis 门内,洪峰下 MySQL 几乎只服务写链路。

小结

  • 列表接口读路径 Cache-Aside:命中则连 DB 都不碰;Redis 异常走 fail-open 回源,服务不 500。🐾
  • LIMIT 100 写死在 SQL 里,把列表缓存 value 锁在 ~13KB,列表 key 绝不长成大 key。
  • 写侧 DEL 挂在事务 setCommitCallback(committed==true),先提交 DB 再删缓存,顺序天然正确。
  • 「删哪些 key」用 invalidate_on_order 显式开关:默认 item(只删详情)保列表高命中率;all 会让列表命中率趋零,不推荐。
  • 列表缓存「删」而非「改」:DEL 幂等、与时序无关,比重算整值安全。
  • 下一篇 5.3 落地详情接口缓存,并引入空值哨兵防缓存穿透。