秒杀系统高并发优化实战(C++ / Drogon):5.5 缓存预热与雪崩:冷启动的回写风暴,和流量进来前该做的事
5.2 / 5.3 把读缓存架起来了,TTL 抖动也顺带做了——那防的是运行中的大批 key 同时过期。但还有一个更隐蔽的时刻:服务刚启动、缓存全空的那一刻。洪峰第一波请求全部 miss、全部回源、全部回写——缓存不但没挡住流量,反而给数据库来了一次"回写风暴"。这一篇讲缓存预热:为什么它是运营动作而不是服务行为,冷启动、雪崩、击穿三个词到底谁是谁,以及 5.5 落地成代码的预热端点长什么样。配套代码 POST /api/cache/warm(scripts/cache-warm.sh)在 5.5 已落地。
本文是「秒杀系统(C++ / Drogon)」系列第五章第五篇。配套代码:
SeckillService::warmCache、src/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 占着,连正常的读命令都在排队。
- 本质一句话:缓存的价值曲线是"填充率越高越值钱",冷启动是这条曲线上最差的一个点——预热就是把这个点移到流量进来之前。
二、先分清三个"崩":穿透、击穿、雪崩
缓存层的灾难名词总被混用,其实分得很清:
- 雪崩:大批 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 | // SeckillService::warmCache —— 一次 SQL 两用:前 100 条重建列表缓存,每条回写详情缓存 |
调用方式:
1 | # 运营/压测前:预热前 2000 个 sku 的详情 + 重建列表缓存(幂等,可重复调) |
两个设计细节值得说:
- 列表缓存也在这里重建,而不是等第一个读请求来填——省掉压测冷启动的第一下回源,口径更干净。
- 回写全部 fire-and-forget(
SETEX异步、失败只记日志):预热是"尽力把缓存填上",不填满也不影响正确性(顶多多几次 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 2000→redis-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 落地)——把"查不存在的东西"也挡在数据库门外。

