缓存预热搞不好比冷启动还慢——三种加载策略的耗时曲线和雪崩风险,压测数据摆出来聊聊
经常有人把“缓存预热”当成万能解药,觉得只要提前把数据怼进缓存,就能避开冷启动的坑。但压测数据摆在那里:预热没做好,TTL 集中失效的那一刻,系统崩得比纯冷启动还要快、还要难恢复。
我们拿三种常见的加载策略——全量加载、增量加载、懒加载——在同一套环境里跑了启动阶段的耗时曲线,顺带模拟了大规模 key 过期时的雪崩风险。先直接抛结论:全量加载启动最慢但瞬时流量抗性最强,懒加载对后端最友好但雪崩风险最高,增量加载卡在中间,需要额外的调度逻辑才能稳住。下面把三种策略掰开细说。
全量加载:启动慢,但跑起来稳
全量加载的核心代价在启动阶段,但一旦加载完成,缓存命中率直接拉到 100%,瞬时流量不会穿透到数据库。
压测环境是我们内部的一套广告投放系统,Redis 6.2.7,30 万条广告计划数据,每条结构体大约 2KB,后端是 MySQL 8.0,常规的读写分离。全量加载的逻辑很简单:服务启动时,先不注册到服务发现,而是起一个 goroutine 遍历 MySQL 中所有有效计划,批量写入 Redis,写完之后再标记“就绪”。
启动阶段耗时数据是这样的:
- 30 万条数据全量加载到 Redis,单线程顺序写入,耗时 47 秒。
- 如果开 10 个并发写,耗时降到 11 秒,但 Redis CPU 冲到 95%,期间其他请求基本不可用。
- 服务就绪前,所有请求被挡在网关层,返回 503,相当于这 11-47 秒是完全不可用窗口。
从耗时曲线看,全量加载是一条陡峭的上升线,然后瞬间拉平——一旦加载完成,缓存命中率 100%,后端 QPS 直接降到 0。这在流量洪峰来临时是很大的优势:没有渐进式的“预热期”,缓存一就绪就是满血状态。
但全量加载的雪崩风险不在启动阶段,而在“集体过期”。如果这 30 万条 key 都设置了相同的 TTL——比如 3600 秒——那么在过期瞬间,30 万个读请求同时穿透到 MySQL,数据库连接池瞬间打满。我们模拟过一次:在 TTL 到期前 5 秒,用 wrk 以 20000 QPS 持续压测,过期那一刻 MySQL 的 Threads_running 从 10 飙到 400+,触发了我们设置的连接上限,大量请求直接超时,RT 从 3ms 涨到 12 秒,服务基本不可用。这个恢复时间比冷启动那 47 秒长得多——因为数据库被打残后,即使缓存重新加载完成,数据库本身也需要时间消化积压的查询,我们在那次模拟里等了近 4 分钟才完全恢复。
所以全量加载如果要用,必须配合 TTL 打散策略。我们后来在写入时给每个 key 加了一个随机偏移量,TTL 分布在 3000 到 4200 秒之间,再模拟同样的过期场景,数据库 QPS 峰值从 20000+ 降到了 2000 左右,在承受范围内。
懒加载:启动最快,但雪崩来的时候挡不住
懒加载启动成本几乎为零,但第一次流量高峰会把数据库打穿,而且在缓存大面积失效时没有自保能力。
懒加载的逻辑是:服务启动后直接注册到服务发现,请求进来时先查 Redis,miss 了就去 MySQL 查,然后回填缓存。启动阶段完全没有任何预加载操作,所以服务从启动到接受流量的时间几乎为零。
但压测数据很诚实:我们以 5000 QPS 的流量打到一个刚启动的懒加载实例上,前 30 秒的缓存命中率为 0%,所有请求全部穿透到 MySQL。MySQL 的 CPU 从 idle 的 5% 直接飙到 85%,RT 从 3ms 涨到 400ms。30 万条数据被逐步加载到 Redis,耗时大约 3 分钟才让命中率稳定到 95% 以上。这 3 分钟的“预热期”里,数据库一直处于高负载状态。
这个场景还不是最糟糕的。更致命的是雪崩场景:假设缓存里的数据因为某种原因大面积失效——比如 Redis 内存满了触发了 allkeys-lru 淘汰,或者一次意外的 flushall——懒加载没有任何缓冲手段。请求穿透到数据库的那一刻,就变成了数据库的生死考验。我们模拟了一次 80% 缓存 key 同时失效的场景,在 10000 QPS 下,数据库连接池在 3 秒内耗尽,服务整体 RT 涨到 20 秒,网关超时断连,雪崩形成。
懒加载唯一的“优势”是启动快,但这个优势在雪崩面前不值一提。如果没有限流和熔断兜底,懒加载就是雪崩的放大器。
增量加载:中庸之道,但实现细节决定成败
增量加载试图在启动速度和数据库压力之间找平衡,但“增量”的粒度和触发时机如果不精细控制,效果可能还不如懒加载。
增量加载的做法是:服务启动时只加载一部分“热点数据”,剩余数据要么按需懒加载,要么通过异步任务逐步补全。我们测试的版本是:启动时从 MySQL 查出最近 7 天有变更的广告计划(约 3 万条,占总量的 10%),先加载到 Redis,然后服务注册上线,后台再起一个任务以每秒 500 条的速率把剩余数据补齐。
启动阶段的耗时:
- 3 万条热点数据加载耗时 5 秒(单线程),服务在 5 秒后即可接受流量。
- 启动后前 30 秒,缓存命中率约 60%,有 40% 的请求穿透到 MySQL——比懒加载的 100% 好不少,但对数据库仍然有冲击。
- 后台补齐任务耗时约 9 分钟才把全部 30 万条数据加载完,期间 Redis 和 MySQL 的负载都处于中等水平。
从耗时曲线看,增量加载是一条先陡后缓的曲线:前 5 秒不可用,之后命中率从 60% 逐渐爬升到 100%,整个爬升过程约 9 分钟。这种曲线比全量加载的 47 秒完全不可用更容易接受,比懒加载的 3 分钟数据库高负载也更温和。
但增量加载的雪崩风险取决于你的“热点识别”准不准。如果热点数据集本身选错了——比如最近 7 天有变更的数据并不是流量真正命中的热点——那启动后的实际命中率可能远低于预期,数据库照样被打。我们在一次事故里就发现,广告计划的变更频率和流量热度并不正相关,有些常年不变的“常青”计划占了 30% 的流量,但不在增量加载的范围内,导致启动后数据库压力依然很大。
另外,增量加载的 TTL 管理比全量加载更复杂。因为数据是分批加载的,如果每批数据都设相同的 TTL,就会形成“阶梯式过期”——一批一批地集体失效,每一批失效都会对数据库造成一次冲击。我们后来给每批数据都加了随机 TTL 偏移,并且把后台补齐任务的速率和当前数据库负载挂钩:监控到 MySQL Threads_running 超过 20 时,自动降速到每秒 100 条,低于 10 时恢复到 500 条。这个自适应逻辑让增量加载在数据库压力大的时候不会雪上加霜。
三种策略的耗时曲线对比
我把压测数据整理成了一张表,方便直观对比:
| 策略 | 启动不可用时间 | 缓存命中率爬坡时间 | 启动阶段数据库峰值 QPS | 雪崩恢复时间(80% 失效) |
|---|---|---|---|---|
| 全量加载 | 11-47 秒 | 0(加载完即 100%) | 0 | 4 分钟(无 TTL 打散)/ 30 秒(有 TTL 打散) |
| 懒加载 | 0 秒 | 约 3 分钟 | 5000(等同流量) | 不可自动恢复,需人工介入 |
| 增量加载 | 5 秒 | 约 9 分钟 | 2000(40% 流量) | 2 分钟(有自适应限速) |
从曲线形态看:全量加载是一条“先直上再拉平”的线,懒加载是“从零缓慢爬升”的线,增量加载是“先跳一截再缓慢爬升”的线。哪种曲线更适合你的业务,取决于你的容忍度在“启动延迟”还是“数据库压力”上。
怎么选?看业务的真实瓶颈
如果你的业务能接受几十秒的启动延迟,全量加载 + TTL 打散是最稳妥的方案。
我们现在的广告系统就是这套方案:启动时全量加载,TTL 随机分布在 3000-4200 秒,同时加了一个主动刷新机制——在 TTL 剩余 600 秒时,后台异步刷新缓存,避免 key 真正过期。这套组合跑了半年,没再出过雪崩事故。
如果启动延迟完全不能接受,懒加载必须配限流和熔断。
我们另一套用户画像服务就是懒加载,但加了几个硬性保护:网关层对单实例限流 3000 QPS,缓存 miss 率超过 50% 时自动触发熔断,直接返回兜底数据而不是穿透到数据库。这套机制在缓存全部失效时,至少能保证服务不崩,只是数据新鲜度会暂时下降。
增量加载适合那种“热点数据比较明确、可提前识别”的业务。
比如电商的商品详情页,销量排名前 20% 的商品占了 80% 的流量,启动时只加载这 20% 的商品数据,性价比很高。但如果热点识别不准,增量加载的优势就荡然无存了。
常见问题
全量加载的 47 秒启动时间太长了,怎么缩短?
可以从几个方向优化:一是增加并发写入,但要注意 Redis 的单线程瓶颈,并发数不宜超过 10-15;二是用 Redis 的 MASS INSERT 模式,把数据拼成 Redis 协议格式的文本文件,通过管道一次性灌入,我们试过 30 万条数据用管道写入只需要 4 秒;三是如果数据量实在太大,考虑分片,每个实例只加载自己负责的那部分数据。
TTL 打散的随机范围设多大合适?
我们一般设 TTL 的 20%-30% 作为随机偏移量。比如基准 TTL 是 3600 秒,就在 2800-3600 秒之间随机。偏移太小起不到打散效果,太大会让缓存刷新逻辑变得复杂。关键是结合你的主动刷新机制来调:如果你在 TTL 剩余 600 秒时就开始刷新,那 TTL 分布的下限不应该低于 600 秒,否则有些 key 还没等到刷新就已经过期了。
增量加载的热点数据怎么识别?
最直接的方式是从访问日志里统计。我们每 10 分钟跑一个离线任务,统计过去 1 小时访问量最高的 key,写入一个“热点列表”。服务启动时读这个列表,优先加载。如果没有离线任务的条件,也可以用 Redis 的 LFU 淘汰策略间接识别——被频繁访问的 key 在内存里自然存活得更久,启动时做一次 RDB 快照恢复,热点数据就已经在缓存里了。但 RDB 恢复本身也要时间,30 万条数据大概 8-10 秒,算是一种变相的“全量加载”。
雪崩发生了,最快的止血手段是什么?
第一件事不是修缓存,是限流。在网关层把 QPS 压到数据库能承受的水平——比如你已知数据库的稳定吞吐是 2000 QPS,那就先把入口限到 1500,给数据库喘息空间。第二步是降级,把非核心的缓存查询直接返回兜底数据,减少穿透量。第三步才是重新预热缓存,此时要注意预热速率不能太快,否则刚恢复的数据库又被打趴下。我们一般先以每秒 200 条的速率灌数据,等数据库负载稳定后再提速。