缓存预热策略:启动时就把热数据装进 Redis
一句话结论(30s)
缓存预热的本质是解决冷启动时「Redis 空转、100% 请求回源打挂数据库」的问题:因为重启后缓存为空、首批流量承担全部回源压力,所以在接外部流量前用启动事件 + Pipeline 批量预加载热数据,并用分布式锁防多节点重复、readinessProbe 控流量接入。
核心原理(2min)
用 Spring 的 ContextRefreshedEvent 在容器初始化完成时预热(用 getParent()!=null 过滤父子容器双触发);Redis Pipeline 把 N 次网络往返合并成 1 次(每批 500-1000 条、命令非原子、不与 MULTI/EXEC 混用)。多节点部署用 SET NX 分布式锁保证只预热一次:拿到锁后二次检查缓存是否已有数据、释放用 Lua 保证 get+del 原子。预热期间 readinessProbe 指向 /health/ready,warmedUp=false 时 Pod 不就绪不接流量,预热完成自动切换。
底层深入(5-10min)
一个线上事故的复盘
凌晨 3 点发布新版本,重启了 5 个服务节点。启动后 Redis 里空空如也——所有缓存数据因为重启丢失。早上 8 点用户涌入,每个请求都是 cache miss → 数据库瞬间被打到 100% CPU → 整个系统雪崩。
根因:只做了”缓存”,没做”缓存预热”。Redis 在启动后是空的,第一批请求承担了全部回源压力。
想一想:为什么冷启动会打挂数据库? 因为重启后 Redis 是空的,缓存命中率从 95% 瞬间掉到 0%,100% 请求回源打到数据库。平时数据库只承受 5% 的请求压力,冷启动瞬间要承受约 20 倍的流量,自然被打到 100% CPU。所以预热的本质是”在流量进来之前,先把缓存填满”,让 Redis 从一开始就是热的。
缓存预热解决什么问题?
正常运行期间:用户请求 → Redis(命中率 95%) → 数据库(只有 5% 请求打到)。数据库压力很轻。
服务重启后(冷启动):Redis 为空 → 100% 请求穿透到数据库 → 数据库被”冷启动峰值”打挂。
缓存预热的目标:在服务接收外部流量之前,把热点数据提前加载到 Redis 中,让 Redis 从”空”变成”热”。
方案一:ApplicationListener 监听启动完成
Spring 提供了 ContextRefreshedEvent 事件——当 ApplicationContext 初始化完成(所有 Bean 加载完毕)时触发。利用这个时机做预热:
@Component
public class CacheWarmer implements ApplicationListener<ContextRefreshedEvent> {
@Autowired
private ProductService productService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
// 注意:Spring 的父子容器会导致两次触发,只处理 root context
if (event.getApplicationContext().getParent() != null) {
return;
}
log.info("开始缓存预热...");
long start = System.currentTimeMillis();
// 1. 预热商品信息(热点数据)
List<Product> hotProducts = productService.findHotProducts(1000);
for (Product product : hotProducts) {
redisTemplate.opsForValue()
.set("cache:product:" + product.getId(), product, 30, TimeUnit.MINUTES);
}
// 2. 预热字典数据(全局常量)
List<Dict> dicts = dictService.findAll();
redisTemplate.opsForValue()
.set("cache:dict:all", dicts, 24, TimeUnit.HOURS);
long elapsed = System.currentTimeMillis() - start;
log.info("缓存预热完成,加载 {} 条商品,{} 条字典,耗时 {}ms",
hotProducts.size(), dicts.size(), elapsed);
}
}
注意:ContextRefreshedEvent 在父容器和子容器各触发一次(Spring MVC 有父子容器)。用 event.getApplicationContext().getParent() != null 过滤掉父容器事件,避免预热代码执行两次。
方案二:Redis Pipeline —— 1000 次网络往返变 1 次
上面方案一的代码有个性能问题:for 循环里每条 redisTemplate.opsForValue().set() 都是一次独立的网络往返。预热 1000 个 key → 1000 次网络 IO → 耗时可长达数秒。
Redis Pipeline 把多条命令打包发送,一次网络往返执行所有命令:
@Component
public class PipelineCacheWarmer implements ApplicationListener<ContextRefreshedEvent> {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
if (event.getApplicationContext().getParent() != null) return;
List<Product> hotProducts = productService.findHotProducts(1000);
// Pipeline:批量发送,一次网络 IO
stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Product product : hotProducts) {
String key = "cache:product:" + product.getId();
String value = JSON.toJSONString(product);
connection.setEx(
key.getBytes(),
1800, // TTL 30 分钟
value.getBytes()
);
}
return null;
});
}
}
Pipeline 的注意事项:
- 不要积累太多命令——建议每批 500-1000 条。过多命令占用 Redis 内存缓冲区,可能 OOM。
- Pipeline 中的命令不保证原子性——如果中间某条命令失败,前面的命令不会回滚。
- 不要和事务(MULTI/EXEC)混用——行为不确定。
想一想:为什么 Pipeline 能把 1000 次网络往返变 1 次? 因为逐条
set是”发一条、等响应、再发下一条”,每条都要付一次网络 RTT;Pipeline 把 1000 条命令打包成一批一次性发送,只付一次 RTT,服务端批量执行后一起返回。预热这种”命令之间无依赖、无顺序要求”的批量写入,正是 Pipeline 的最佳场景。
方案三:分布式锁防止多节点重复预热
生产环境通常有 3-5 个服务节点。每个节点启动时都执行预热 → 同一份数据被写入 Redis 3-5 次 → 浪费资源。
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
if (event.getApplicationContext().getParent() != null) return;
String lockKey = "cache:warmup:lock";
String lockValue = hostName + ":" + Thread.currentThread().getId();
// 尝试获取分布式锁,超时 5 秒
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 5, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 再次检查缓存是否已有数据(可能其他节点已经预热完成)
Long keyCount = redisTemplate.countExistingKeys(
Collections.singletonList("cache:product:*"));
if (keyCount > 0) {
log.info("缓存已预热,跳过");
return;
}
doWarmUp();
} finally {
// 释放锁(用 Lua 保证原子性)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), lockValue
);
}
} else {
log.info("其他节点正在预热中,本节点跳过");
}
}
这里有两个关键设计:
- 二次检查缓存:拿到锁后先查 Redis 是否已有数据——避免”A 节点预热完成释放锁,B 节点拿到锁又预热一遍”。
- Lua 释放锁:
get + delete两步必须原子——否则 A 的锁过期被删,B 拿到锁,A 的delete把 B 的锁删了。
想一想:为什么释放锁要用 Lua 保证
get + del原子? 因为如果先 get 判断”锁还是我的”、再 del 删除,这两步之间锁可能已经过期、被别的节点抢走了,此时 del 就会误删别人的锁。用 Lua 脚本把”判断 value 是否等于自己 + 删除”打包成一条原子命令执行,就杜绝了这个竞态窗口——这正是分布式锁”加锁用 SET NX、解锁用 Lua”的标准套路。
预热什么数据?
不是所有缓存都需要预热。预热的数据应该满足三个条件:
| 条件 | 说明 | 反例 |
|---|---|---|
| 访问频率高 | 系统核心热数据 | 用户个人设置(低频访问) |
| 数据量可控 | 预热 100 万条 key → 启动慢 + Redis 内存爆 | 全量商品(上百万 SKU) |
| 变化频率低 | 预热期间数据不变或变化不大 | 实时股价(预热完已过时) |
典型预热数据:
- 商品分类、品牌列表、导航菜单(字典类)
- 首页推荐商品、热门商品 Top 1000(热点类)
- 用户权限、角色配置(配置类)
- 活动规则、促销策略(规则类)
预热期间的外部流量处理
预热可能需要 10-30 秒。期间如果有用户请求进来:
private volatile boolean warmedUp = false; // 预热标记
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
// ... 预热逻辑 ...
warmedUp = true;
}
// 在层(如 Nginx / API Gateway)设置健康检查
// 接口 /health/ready → 只有 warmedUp == true 时才返回 200
// Kubernetes readinessProbe 用这个接口控制流量接入
让 Kubernetes 的 readinessProbe 指向 /health/ready:预热期间 Pod 状态为”未就绪”,不接收流量。预热完成后自动切换为”就绪”。
总结
缓存预热是”缓存-穿透-击穿-雪崩”防御体系的最后一道防线。核心三件套:
- 启动时预加载:
ContextRefreshedEvent+ Pipeline 批量写入 - 分布式锁:
SET NX保证多节点只预热一次 - 就绪检查:预热完成前不接流量(readinessProbe)
缓存设计的最高境界不是”缓存命中率 99%“,而是 “冷启动也能正常服务”。如果你的系统每次重启后都需要”暖机”几分钟才能正常工作——那不是分布式系统,那是预热系统。
章末提问
Q1:缓存预热解决什么问题?不预热会怎样? 结论先行:解决”冷启动时 Redis 空转、100% 请求回源打挂数据库”的问题;不预热会让首批流量承担全部回源压力,可能雪崩。 因为重启后缓存为空,命中率从 95% 掉到 0%,所有请求都穿透到数据库,数据库要承受平时约 20 倍的压力。预热的本质就是在接外部流量之前,把热数据提前装进 Redis,让系统”一出生就是热的”。
Q2:为什么 Pipeline 能提速?有什么注意事项? 结论先行:它把 N 次网络往返合并成 1 次,省掉 N-1 次 RTT;注意命令非原子、不宜过多、别和事务混用。 因为逐条 set 每条都要付一次网络 RTT,Pipeline 打包一次发送只付一次。但 Pipeline 里的命令不保证原子性(中途失败不回滚),命令太多会占满 Redis 缓冲区甚至 OOM(建议每批 500-1000 条),也不能和 MULTI/EXEC 事务混用。
Q3:多节点部署为什么需要分布式锁?怎么防重复预热?
结论先行:因为每个节点启动都会触发预热,需要 SET NX 锁保证只有一个节点真正预热;配合”二次检查 + Lua 释放”防重复和误删。
因为 3-5 个节点同时启动会重复写入同一份数据、浪费资源;用 setIfAbsent 抢锁,拿到锁的节点预热,并在预热前二次检查”缓存是否已有数据”避免 A 预热完 B 又预热一遍。释放锁用 Lua 保证 get+del 原子,防止锁过期后被误删。