5.2 / 5.3 把读缓存架起来了,TTL 抖动也顺带做了——那防的是运行中的大批 key 同时过期。但还有一个更隐蔽的时刻:服务刚启动、缓存全空的那一刻。洪峰第一波请求全部 miss、全部回源、全部回写——缓存不但没挡住流量,反而给数据库来了一次"回写风暴"。这一篇讲缓存预热:为什么它是运营动作而不是服务行为,冷启动、雪崩、击穿三个词到底谁是谁,以及 5.5 落地成代码的预热端点长什么样。配套代码 POST /api/cache/warmscripts/cache-warm.sh)在 5.5 已落地。

本文是「秒杀系统(C++ / Drogon)」系列第五章第五篇。配套代码:SeckillService::warmCachesrc/main.cc/api/cache/warm 路由、scripts/cache-warm.sh。代码对应阶段二 v0.2.x

一、冷启动:是什么、坑在哪、本质一句话

  • 是什么:进程刚 run() 起来时,Redis 里没有任何 seckill:sku:* key。此时用户洪峰正好杀到,每一个读请求都是 miss
  • 坑(回写风暴):miss 意味着回源 MySQL + 回写 Redis。平时 83.8% 的命中率挡掉了绝大多数读;冷启动时命中率是 0%,100% 的请求都变成"DB 查询 + 缓存回写"两连击。缓存从"加速层"临时变成了"放大器"——每个请求都多了一次 SETEX。更糟的是回写是异步的、洪峰期间 Redis 连接被大量 SETEX 占着,连正常的读命令都在排队。
  • 本质一句话缓存的价值曲线是"填充率越高越值钱",冷启动是这条曲线上最差的一个点——预热就是把这个点移到流量进来之前。

二、先分清三个"崩":穿透、击穿、雪崩

缓存层的灾难名词总被混用,其实分得很清:

图 1:穿透 / 击穿 / 雪崩——打的对象完全不同 ① 缓存穿透 查【不存在的 key】 缓存里没有 → 每次都穿透 到 DB 查空结果 对策:空值哨兵 / 布隆(5.6 / 5.7) ② 缓存击穿 【单个热点 key】失效瞬间 上万人同时回源同一个 sku 打的是单行主键读 对策:互斥重建 / 逻辑过期(暂缓) ③ 缓存雪崩 【大批 key 同时失效】 同秒集体过期 → 集体回源 冷启动是它的特例(全失效) 对策:TTL 抖动 + 预热(本文)
  • 雪崩:大批 key 同一秒过期,所有请求瞬间集体回源。5.2 / 5.3 已用 TTL 随机抖动摊平(实际 TTL = 基准 + [0, jitter)),把"同一秒"变成"一个时间窗"。
  • 击穿:单个热点 key 失效的那一瞬间并发回源。本项目暂缓处理——回源的是同一个 sku 的单行主键查询(共享读,不抢行锁),MySQL 扛得住,一次慢查询不值得为它上互斥锁。
  • 冷启动:可以看作雪崩的极端版——所有 key 同时"不存在"。抖动救不了它(不是过期时间的问题,是压根没填过),只能靠预热。

三、预热为什么必须是"运营动作",而不是服务启动自预热

直觉方案:服务 run() 之后自己把 DB 数据全量灌进缓存。但它立刻撞上三个问题:

问题 说明
预热多少 全量 20 万 sku 都灌?灌前 1000 个?"该热"的是运营心里那批爆品,不是服务能自己判断的
要不要等它完成 如果不等,预热还没完流量就来了,等于白热;如果等,服务启动要阻塞几十秒,发布流程被拖死
谁来触发 活动 10:00 开始、9:55 才确认最终商品清单——预热必须发生在"清单定了之后",服务启动在"更早之前"

这些问题说明:预热是编排问题,不是服务问题。真实秒杀系统里,预热属于活动运营脚本的一环(确认清单 → 预热 → 开放入口)。所以 5.5 落地成 HTTP 端点 + shell 脚本,谁需要谁调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SeckillService::warmCache —— 一次 SQL 两用:前 100 条重建列表缓存,每条回写详情缓存
void SeckillService::warmCache(int limit, std::function<void(bool, int)> &&callback) {
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 ?",
[cb, this](const drogon::orm::Result &result) {
Json::Value listArr(Json::arrayValue);
int warmed = 0;
for (const auto &row : result) {
Json::Value item; /* ...组装 item... */
if (listArr.size() < 100) listArr.append(item); // 列表只取前 100
if (cache_) {
cache_->setDetail(item["id"].asInt64(), toCompactJson(item));
++warmed;
}
}
if (cache_ && !listArr.empty())
cache_->setList(toCompactJson(listArr)); // 重建列表缓存
cb(true, warmed);
}, ...);
}

调用方式:

1
2
3
4
5
# 运营/压测前:预热前 2000 个 sku 的详情 + 重建列表缓存(幂等,可重复调)
bash scripts/cache-warm.sh 2000
# 等价于:
curl -s -X POST localhost:8080/api/cache/warm \
-H 'Content-Type: application/json' -d '{"limit":2000}'

两个设计细节值得说:

  1. 列表缓存也在这里重建,而不是等第一个读请求来填——省掉压测冷启动的第一下回源,口径更干净。
  2. 回写全部 fire-and-forgetSETEX 异步、失败只记日志):预热是"尽力把缓存填上",不填满也不影响正确性(顶多多几次 miss 回源)。warmCache 只等一条查询 SQL 的结果,然后立刻返回 {"warmed": N},两千个 SETEX 在后台陆续落地——预热不该成为 HTTP 请求的阻塞点。

四、动态 TTL:什么时候才需要"读时续期"

预热解决"流量进来前缓存是空的";还有一个相关的优化叫动态 TTL:命中时检查剩余 TTL,快过期就自动续期,让热点 key 永不过期(避免它过期瞬间变成击穿)。本项目不落地它,理由是本项目的数据形态:

数据 变不变 靠什么保持新鲜
商品名 / 上下架状态 / 活动时间 基本不变 固定 TTL + 抖动足够
stock(库存) 强实时、写频繁 下单成功后主动 DEL(5.2~5.4),不指望 TTL
动态 TTL 的适用场景 读极热、写极少、TTL 到期即"灭顶"的 key 它把"到期失效"改成"快到期就续"

秒杀列表 / 详情缓存的"新鲜度"由主动失效(下单 DEL)承担,TTL 只是兜底;给一个本来 60 秒就失效的 key 做续期,续的是"兜底"而非"新鲜"——收益趋零。动态 TTL 真正有价值的场景是"数据几乎不变、但读量大到不容忍任何一次回源"(比如配置表、字典),本项目商品的读量大归大,单行回源扛得住(击穿一节已论证)。结论:不是动态 TTL 没用,是它的使用前提(热点 + 几乎不变 + 回源昂贵)在本项目不成立。 先量清楚再优化。

五、小结与验证

环节 手段 落地状态
雪崩(运行中集体过期) TTL 随机抖动(基准 + [0,jitter)) ✅ 5.2 / 5.3 已随缓存落地
冷启动(全空) 预热端点 + 脚本 ✅ 5.5:POST /api/cache/warm + scripts/cache-warm.sh
击穿(单热点过期) 互斥重建 / 逻辑过期 ⏳ 本项目暂缓(单行回源扛得住,先量后做)
动态 TTL 读时续期 ⏳ 本项目不落地(新鲜度靠 DEL,TTL 只是兜底)

🐾 核心三句话:缓存雪崩防"同时过期",用抖动摊平;冷启动是雪崩的极端版(全空),抖动救不了只能预热;预热是"清单定稿后、流量进来前"的运营动作——做成端点而不是启动自预热,是因为"热什么、热多少、何时热"是编排问题,不是服务问题。

验证:服务起来后先别压测,bash scripts/cache-warm.sh 2000redis-cli --scan --pattern 'seckill:sku:v1:item:*' | wc -l 应 ≈ 2000、redis-cli EXISTS seckill:sku:v1:list 应为 1。预热之后 5.6 / 5.7 连讲"防穿透"——空值哨兵(已随 5.3 落地)和布隆过滤器(5.7 落地)——把"查不存在的东西"也挡在数据库门外。