秒杀系统高并发优化实战(C++ / Drogon):5.2 加 Redis 缓存:商品列表接口
4.1 我们把商品列表接口(GET /api/seckill/list)跑通了,但那时它每次都直连 MySQL。到了第五章,第一件事就是给这个被刷新最频繁的接口套一层 Redis 缓存。它的 key seckill:sku:v1:list 是「全量商品」的聚合值,是热点中的热点——值得专门写一篇讲清楚它和详情缓存在取舍上的不同。
本文是「秒杀系统(C++ / Drogon)」系列的第五章第二篇。配套代码:
src/service/SeckillService.cc::listSkus/queryListFromDb、src/service/SkuCache.*、src/main.cc缓存配置解析。代码对应阶段二v0.2.x。Key 规范与 TTL 抖动见上一篇 5.1。
一、列表缓存:是什么、坑在哪、本质一句话
- 是什么:列表接口返回全部商品(
LIMIT 100),结果对所有用户一致、允许短暂陈旧——教科书级的缓存对象。 - 坑:列表是一个全量聚合 key,任意一件 sku 成交都改变了它的内容。如果「写后失效」策略选错(把列表也一起删),在写 QPS 高时这个 key 会被反复删掉,命中率直接趋零——缓存等于不存在。
- 本质一句话:列表缓存是「整体失效」语义,删比改安全;而「写后删哪些 key」是一个必须显式抉择的开关,不能悄悄写死。
列表页的「库存还剩几件」对下单决策没有意义(真实秒杀里列表本就该静态化),所以默认只删详情、让列表保持高命中率,是这个接口的核心权衡。
二、读路径:Cache-Aside
读路径顺序固定为「读缓存 → 命中返回 → 未命中回源 → 回写 → 返回」。缓存不承担任何正确性责任,它坏了/慢了都只是「多查一次库」:
1 | // src/service/SeckillService.cc —— 5.2 列表缓存读路径 |
SkuCache::getList 底层是 GET,Redis 异常时回调 hit=false(fail-open),调用方自然回源 DB——服务不会因为 Redis 没装就整站 500:
1 | // src/service/SkuCache.cc —— 缓存读永远表现为「命中 / 未命中」两态,绝不抛错 |
三、写侧:下单成功后让列表失效(顺序不能反)
缓存要「新」,靠的是写操作后让旧值失效。本项目把 DEL 挂在事务的 setCommitCallback 里,天然保证「数据库先落定」再删缓存——反序(先删缓存再更新库)会让并发读把旧值回填进缓存,且要等 TTL 到期才自愈,是最常见的缓存不一致来源。
1 | // src/service/SeckillService.cc —— doSeckill 提交回调里让缓存失效 |
「删哪些 key」是一个显式开关(SkuCache::InvalidateOnOrder),把取舍收在一处,业务代码不写 if:
1 | // src/service/SkuCache.cc —— 三种失效策略的代价交换 |
| 策略 | 配置值 | 命中率 | 库存新鲜度 | 适用 |
|---|---|---|---|---|
| 只删详情 | item(默认) |
高 | 详情页准,列表页允许陈旧 | 真实秒杀:列表本就该静态化,"列表里还剩几件"对下单没意义 |
| 详情 + 列表都删 | all |
趋零 | 最准 | 不推荐:列表是全量聚合 key,任意 sku 成交都会删它,写 QPS 高时等于不存在 |
| 谁都不删 | none |
最高 | 最差 | 仅"库存展示完全不敏感"时用;售罄后用户最长 TTL 秒内仍看到"还有货" |
四、功能抉择
① 列表缓存「删」而非「改」
列表 value 含全部 sku 的 stock,要改就得重算整个 JSON,不如交给下次读重建;且「改」要回答"我手上的新库存来自哪个事务",时序一乱就把旧值盖回去。DEL 是幂等的、与时序无关——任意一次 DEL 之后到达的读请求必然 miss 重建。
② invalidate_on_order 默认 item 的权衡
列表是全量聚合 key,任意 sku 成交都改变它。若选 all(详情+列表都删),写 QPS 一高列表缓存就被反复删、命中率趋零。真实秒杀里「列表还剩几件」对下单决策无意义,列表本就该静态化/CDN 化,所以默认只删详情、保列表高命中率。这个开关存在的意义是把取舍显式化,而不是悄悄写死一个行为让后人猜。
③ 配置集中解析,Redis 不可用则强制关缓存main.cc 把 config.json 的 cache 块读进 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 落地详情接口缓存,并引入空值哨兵防缓存穿透。

