秒杀系统高并发优化实战(C++ / Drogon):5.1 缓存 Key 设计规范与接口基线压测
阶段一我们打出基线 QPS≈371 / 0 超卖,根因钉死在 seckill_sku 单行锁串行化——那是写接口(下单)的问题。但同一时刻还有两个读接口在被反复调用:一万个用户同时刷新列表,MySQL 就把同一条 SELECT 执行一万遍,返回一模一样的结果。读请求和写请求是两种完全不同的动物,这一章我们专门收拾读接口。
本文是「秒杀系统(C++ / Drogon)」系列的第五章第一篇。配套资产:
docs/CACHE-DESIGN.md(缓存规范全文)、src/service/CacheKeys.h(Key 唯一构造入口)、src/service/SkuCache.*(Redis 异步封装)、scripts/read-bench.sh+jmeter/read-baseline.jmx(读接口基线压测)。代码对应阶段二v0.2.x。真实编译与压测在 WSL(i7-14650HX + 本地 MySQL + 本地 Redis)完成。
一、为什么现在才加缓存,加在哪
- 是什么:读接口(
GET /api/seckill/list、GET /api/seckill/{skuId})的结果是重复、允许短暂陈旧、不参与正确性判断的——商品列表一秒内被查一万次,返回的也还是那 100 行。这类请求是缓存的最佳对象。 - 坑:若不加缓存,洪峰下 MySQL 会把同一条 SQL 执行成千上万遍,连接池和行锁被读流量占满,反而连累本就脆弱的写链路。
- 本质一句话:缓存只加在读接口;写接口(下单)缓存替代不了真相源,它只多一个动作——事务提交成功后让缓存失效。下单必须落在 MySQL 上拿行锁做原子扣减,缓存挡不了超卖。
读接口的特点决定了它的缓存是「加速层」而非「真相源」:缓存坏了、慢了、不存在,最坏结果只是「多查一次库」,没有任何正确性损失。这一点直接决定了后面所有的失败策略(fail-open)。
二、Key 设计规范:六条规则及其代价
缓存出问题时,最先要回答的是「到底有哪些 key、长什么样、能不能安全删」。如果 key 散落在各业务代码里用字符串硬拼,想清理只能 KEYS *sku*(生产禁用)或整库 FLUSHDB(误伤登录态)。所以本项目把 key 拼接收敛到一个文件——CacheKeys.h 就是缓存的「目录」:
1 | // src/service/CacheKeys.h —— Key 的唯一构造入口(四段式,冒号分隔) |
模板就是 <prefix>:<biz>:<version>:<type>[:<id>],落到实际 key:
1 | seckill:sku:v1:list # 商品列表(全量) |
规范六条,违反任何一条都会在某个时刻变成事故:
| # | 规则 | 违反之后会发生什么 |
|---|---|---|
| 1 | 必须带业务前缀 | 同 Redis 上还住着 sess:/sms:/login:/reg:ip:,无前缀既没法按类清理,排查时也认不出归属 |
| 2 | 必须带结构版本号 | JSON 结构改了不升版本,发布瞬间旧结构被新代码读到 → 解析全崩。升 v2 则新旧 key 并存到自然过期,不需要清库(清库 = 主动制造一次缓存雪崩) |
| 3 | 全小写、尽量短 | key 本身占内存;100 万个 key 每个多 10 字节就是 10MB |
| 4 | 绝不放易变维度 | 把 TTL、时间戳、userId 拼进 key,命中率直接趋零、内存无上限增长——新手最常踩的一条 |
| 5 | 不分页就不带分页参数 | 列表缓存是全量(LIMIT 100);真要分页再开 :list:{page},且必须限死最大页数,否则攻击者可打爆内存 |
| 6 | 冒号分段 | 运维可 SCAN 0 MATCH seckill:sku:v1:item:* 精确定位一类 key |
历史包袱要说清:前 8 个 key(
sess:/sms:/login:/reg:ip:)是第三章登录模块引入的,没有seckill:前缀、没有版本号。这是既有事实,本次不改动它们(改了要同时动会话、验证码、限流四个模块,收益为零、风险不小)。规矩是从第五章起新增的 key 一律遵守新规范,历史 key 在阶段四统一迁移。
三、Value 设计:紧凑 JSON 字符串,不存 Hash,且不长大 key
存紧凑 JSON 字符串(不是 Hash):
| 方案 | 读 | 写 | 为什么不选 |
|---|---|---|---|
| String(紧凑 JSON) ✅ | 1 次 GET 拿到完整对象 |
整值覆盖 / DEL |
— |
Hash(HGETALL) |
1 次 HGETALL 再拼装 |
可字段级更新 | 商品详情是整体失效语义,字段级更新会造出"改了 name 忘了改 stock"的半更新态;且 HGETALL 对大 hash 同样是 O(n) |
紧凑(indentation="")而不是带缩进:20 条商品的列表带缩进约 4KB,紧凑后约 2.6KB,省 35% 内存与网络字节。
大 key 边界:列表接口 LIMIT 100 写在 SQL 里(不是靠约定),把列表 value 锁死在 ~13KB——无论总量 20 条还是 20 万条,列表缓存体积恒定,永远不会长成 string 大 key(Redis「大 key」通常指 string > 100KB)。
四、TTL 与抖动:没有抖动就是缓存雪崩
| Key | 基准 TTL | 抖动 | 为什么是这个数 |
|---|---|---|---|
list |
30s | +[0,30)s | 列表含全部商品的 stock,是聚合值,陈旧容忍度最低用短 TTL 补 |
item |
60s | +[0,30)s | 单品,且下单后会主动 DEL,TTL 可以放宽 |
| 空值 | 60s | +[0,30)s | 见 §5.3 空值哨兵 |
抖动是必须的,不是可选优化。没有抖动时,缓存预热起来的那批 key 会在同一秒集体失效,全部请求瞬间回源数据库——这就是缓存雪崩。抖动把「同一秒」摊平到一个 30 秒的窗口里。
实现用 thread_local std::mt19937:Drogon 的 handler 跑在多个 IO 线程上,共享一个 rand() / 全局 mt19937 会构成数据竞争(UB),每线程一份则无锁无竞争:
1 | // src/service/SkuCache.cc —— 每线程一个随机数发生器,无锁无竞争 |
五、接口基线压测:两量级对照(关键结论)
方法论:同一套流量跑两轮,只改 cache.enabled 一个变量(脚本自动改配置、重启、清缓存、预热、采集)。off 轮是接口基线,on 轮是缓存成果。
1 | mysql -h127.0.0.1 -useckill -pseckill seckill < sql/seed_sku.sql # 默认 20 万条商品 |
小数据量级(20 条,仅验证逻辑):本地 MySQL 一次 SELECT <1ms,对照组太强,缓存只动 QPS:
| 接口 | 缓存 | QPS | 提升 | 命中率 |
|---|---|---|---|---|
| list | off | 1463.4 | — | 100%(miss=3) |
| list | on | 1526.7 | ×1.04(+4%) | 同上 |
| detail | off | 1472.7 | — | 100%(miss=3) |
| detail | on | 1539.7 | ×1.05(+5%) | 同上 |
真实数据量级(20 万条,JMeter 5.6.3 引擎,权威结论):
| 接口 | 缓存 | QPS | avg(ms) | 提升 |
|---|---|---|---|---|
| list | off | 7544.2 | 6.95 | — |
| list | on | 10556.9 | 3.84 | ×1.40(+40%) |
| detail | off | 5591.2 | 6.22 | — |
| detail | on | 8023.5 | 3.23 | ×1.44(+44%) |
- 命中率 83.8%(hit=934466 / miss=180538 / err=0);off 轮 60s 内
788k 读全打 DB,on 轮同窗口仅 180k 回源 → DB 读负载降约 77%,同时总 QPS +4044%,平均延迟砍半。 - 数据量级决定缓存收益的「能见度」:20 条小量级里缓存把「1ms 查询」换成「亚毫秒 Redis GET」,单请求省的时间被
threads_num=4的 IO 线程摊平,QPS 只动 4~5%;量级一上去,83.8% 的读被挡在 Redis 门外,缓存才显出乘数级提升。真实部署里 MySQL 在远端、有连接池上限、洪峰读放大会先打满 DB——缓存挡掉的不是 4% 的 QPS,是 83.8% 可能压垮 DB 的读流量。 - 脏数据警示:run 1/run 2 曾出现「on 比 off 还低 20%、命中率仅 58%」的异常——那是 JMeter 因 WSL 代理 0 样本、回退 curl harness 后又另起进程抢 8080 端口的错配产物,不可信。「缓存提升反直觉为负」一定是压测链路错配,不是代码问题。
六、功能抉择
① Cache-Aside 而非 Read-Through / Write-Through
本项目的读路径是「读缓存 → 命中返回 → 未命中回源 → 回写 → 返回」,写路径是「提交后 DEL」。没有用框架级的 Read/Write-Through,因为秒杀的写是「事务提交后才允许缓存失效」,这个时序必须亲手挂在 commit 回调上,通用抽象反而藏不住它。
② 缓存 fail-open,会话 fail-close——同一个系统两种相反反应
缓存模块(SkuCache)对「Redis 挂了」的反应是当未命中、回源 DB;而登录模块的会话校验(SessionStore)是拒绝。这不是双标,是代价结构不同:会话校验错放一次 = 被封禁的 token 能继续下单(安全事件,不可逆);缓存错放一次 = 这个请求多查一次库(慢一点,无正确性损失)。判据只有一句:误放的代价 vs 误杀的代价,哪个更大就防哪个。
③ 紧凑 JSON 而非 Hash
详情是整体失效语义,Hash 的字段级更新会造出半更新态;且我们只 DEL 不更新,String 一次 GET 拿到完整对象,网络字节也更省。
小结
- 缓存只加读接口;写接口(下单)缓存替代不了真相源,只多一个「提交成功后失效」动作。🐾
- Key 四段式
<prefix>:<biz>:<version>:<type>[:<id>],六条规则防的是「未来某天的事故」;Key 拼接收敛到CacheKeys.h一个文件。 - Value 存紧凑 JSON 字符串(非 Hash),
LIMIT 100写死上限把列表锁在 ~13KB,防大 key。 - TTL 带
[0,30)s随机抖动防雪崩;thread_local mt19937规避多线程数据竞争。 - 接口基线(20 万量级 JMeter):list/detail 在 on 轮 ×1.40 / ×1.44(+40%/+44%),命中率 83.8%,DB 读负载降约 77%——量级决定缓存收益能见度。
- 下一篇 5.2 落地列表接口的缓存读路径,5.3 落地详情接口(含空值哨兵防穿透)。

