阶段一我们打出基线 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/listGET /api/seckill/{skuId})的结果是重复、允许短暂陈旧、不参与正确性判断的——商品列表一秒内被查一万次,返回的也还是那 100 行。这类请求是缓存的最佳对象。
  • :若不加缓存,洪峰下 MySQL 会把同一条 SQL 执行成千上万遍,连接池和行锁被读流量占满,反而连累本就脆弱的写链路。
  • 本质一句话缓存只加在读接口;写接口(下单)缓存替代不了真相源,它只多一个动作——事务提交成功后让缓存失效。下单必须落在 MySQL 上拿行锁做原子扣减,缓存挡不了超卖。

读接口的特点决定了它的缓存是「加速层」而非「真相源」:缓存坏了、慢了、不存在,最坏结果只是「多查一次库」,没有任何正确性损失。这一点直接决定了后面所有的失败策略(fail-open)。

二、Key 设计规范:六条规则及其代价

缓存出问题时,最先要回答的是「到底有哪些 key、长什么样、能不能安全删」。如果 key 散落在各业务代码里用字符串硬拼,想清理只能 KEYS *sku*(生产禁用)或整库 FLUSHDB(误伤登录态)。所以本项目把 key 拼接收敛到一个文件——CacheKeys.h 就是缓存的「目录」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/service/CacheKeys.h —— Key 的唯一构造入口(四段式,冒号分隔)
class CacheKeys {
public:
// 商品列表(全量,不分页)
std::string list() const { return base() + ":list"; }
// 单个商品详情
std::string item(int64_t skuId) const {
return base() + ":item:" + std::to_string(skuId);
}
// 库存计数(阶段四 7.2 启用,当前只占位不读写)
std::string stock(int64_t skuId) const {
return base() + ":stock:" + std::to_string(skuId);
}
// 运维通配:给 SCAN 用(生产禁用 KEYS)
std::string itemPattern() const { return base() + ":item:*"; }

private:
std::string base() const { return prefix_ + ":sku:" + version_; }
};

模板就是 <prefix>:<biz>:<version>:<type>[:<id>],落到实际 key:

1
2
3
seckill:sku:v1:list          # 商品列表(全量)
seckill:sku:v1:item:1001 # 单个商品详情
seckill:sku:v1:stock:1001 # 库存计数(阶段四预留)

规范六条,违反任何一条都会在某个时刻变成事故:

# 规则 违反之后会发生什么
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)。

图 1:LIMIT 100 把列表缓存锁死在 13KB,不随总量爆炸 商品总量 20 → 20 万 SQL LIMIT 100 列表缓存 value 恒 ≈ 13KB 安全区 非大 key 详情 key 基数随总量到 20 万(命中率/内存才测得出来),但单 key 体积恒定 ≈130B 防大 key 是「写死上限」而非「约定」,攻击者无法用随机 page 打爆内存

四、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 秒的窗口里。

图 2:TTL 随机抖动把「集体失效」摊平成窗口 无抖动:同一秒失效 全部 key 在 t 瞬间回源 → 雪崩 有抖动:失效铺开成窗口 失效点散在 [base, base+jitter],回源被摊平 jitter=30s → 30s 窗口内逐步重建,DB 压力均摊

实现用 thread_local std::mt19937:Drogon 的 handler 跑在多个 IO 线程上,共享一个 rand() / 全局 mt19937 会构成数据竞争(UB),每线程一份则无锁无竞争:

1
2
3
4
5
6
7
// src/service/SkuCache.cc —— 每线程一个随机数发生器,无锁无竞争
int SkuCache::ttlWithJitter(int baseSeconds) const {
if (cfg_.jitterSeconds <= 0) return baseSeconds;
static thread_local std::mt19937 rng{std::random_device{}()};
std::uniform_int_distribution<int> dist(0, cfg_.jitterSeconds);
return baseSeconds + dist(rng); // 实际 TTL = 基准 + [0, jitter)
}

五、接口基线压测:两量级对照(关键结论)

方法论:同一套流量跑两轮,只改 cache.enabled 一个变量(脚本自动改配置、重启、清缓存、预热、采集)。off 轮是接口基线,on 轮是缓存成果。

1
2
3
4
mysql -h127.0.0.1 -useckill -pseckill seckill < sql/seed_sku.sql   # 默认 20 万条商品
# 纯 JMeter 干净环境(避免 WSL 代理变量把 Java 采样器路由到代理端口导致 0 样本)
wsl -e env LANG=C.UTF-8 JMETER_BIN=/opt/apache-jmeter-5.6.3/bin/jmeter \
bash scripts/read-bench.sh 100 60 # 列表 60 / 详情 40 并发 × 60s

小数据量级(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 落地详情接口(含空值哨兵防穿透)。