如何在 Spring 启动过程中做缓存预热?
结论先行:缓存预热的本质是"在流量进来之前,把热点数据搬进缓存,并把首次访问的抖动挪到启动阶段"。Spring 提供了从早到晚一串扩展点:@PostConstruct/InitializingBean(Bean 级)、SmartInitializingSingleton(所有单例就绪后)、ContextRefreshedEvent(容器刷新完)、ApplicationRunner/CommandLineRunner(容器就绪、run() 返回前)、ApplicationReadyEvent(应用完全就绪)。选择依据是"此时依赖是否可用、能否阻塞启动"。
一、扩展点时序
refresh()
├─ finishBeanFactoryInitialization
│ ├─ @PostConstruct / afterPropertiesSet ← 依赖可能还没全部就绪
│ └─ SmartInitializingSingleton#afterSingletonsInstantiated ← 单例全就绪
├─ finishRefresh → 发布 ContextRefreshedEvent
└─ SpringApplication.run 继续
├─ ApplicationRunner / CommandLineRunner ← 阻塞启动,异常可中断启动
└─ 发布 ApplicationReadyEvent ← 已可接流量
| 扩展点 | 触发时机 | 是否阻塞启动 | 适用 |
|---|---|---|---|
@PostConstruct | 单个 Bean 初始化后 | 是 | 自身依赖的小数据 |
SmartInitializingSingleton | 所有单例实例化后 | 是 | 需要跨 Bean 协调的数据 |
ContextRefreshedEvent 监听 | 容器刷新完成 | 是 | 通用预热,注意可能触发多次(父子容器) |
ApplicationRunner | 容器就绪后、run 返回前 | 是 | 推荐,异常能阻止应用"假启动" |
ApplicationReadyEvent | 完全就绪后 | 否 | 允许失败的旁路预热 |
| 定时任务/懒加载 | 运行期 | 否 | 不适合首屏热点 |
二、代码:可降级的预热
@Component
public class HotDataWarmer implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(HotDataWarmer.class);
private final ProductMapper productMapper;
private final RedisTemplate<String, Object> redis;
private final ExecutorService pool = Executors.newFixedThreadPool(4);
public HotDataWarmer(ProductMapper productMapper, RedisTemplate<String, Object> redis) {
this.productMapper = productMapper;
this.redis = redis;
}
@Override
public void run(ApplicationArguments args) {
long start = System.currentTimeMillis();
// 只预热"确定是热点"的那部分:榜单、首页、配置
List<Long> hotIds = productMapper.selectHotIds(500);
List<CompletableFuture<Void>> tasks = hotIds.stream()
.map(id -> CompletableFuture.runAsync(() -> warmOne(id), pool)
.exceptionally(e -> { // 单条失败不影响整体启动
log.warn("预热失败 id={}", id, e);
return null;
}))
.toList();
try {
// 给预热设上限,避免启动被拖死
CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0]))
.get(30, TimeUnit.SECONDS);
} catch (Exception e) {
log.error("预热超时,降级为懒加载", e); // 失败即降级,不阻断启动
} finally {
pool.shutdown();
}
log.info("预热完成 {} 条,耗时 {} ms", hotIds.size(), System.currentTimeMillis() - start);
}
private void warmOne(Long id) {
Product p = productMapper.selectById(id);
if (p != null) {
redis.opsForValue().set("product:" + id, p, Duration.ofHours(2));
}
}
}
三、多实例与失败处理
K8s 滚动发布,3 个 Pod 同时启动:
Pod1 ─┐
Pod2 ─┼─▶ 都去查库 + 写 Redis → 缓存击穿式重复预热,DB 瞬时压力 ×3
Pod3 ─┘
解法:
① 分布式锁:SETNX("warm:lock", podName, 60s),抢到的实例预热,其余等待或跳过
② 预热任务与就绪探针分离:readinessProbe 指向 /actuator/health/readiness,
预热期间不接流量(Spring Boot 的 readiness 状态可用于实现)
③ 大批量数据分批写,避免单次 MGET/MSET 过大
# 预热耗时长时,把就绪探针的容忍时间调大,避免被 K8s 提前杀掉
management:
endpoint:
health:
probes:
enabled: true
health:
readiness-state:
enabled: true
四、常见追问
- 问:
ApplicationRunner抛异常会怎样? 启动失败,进程退出(SpringApplication.run抛出),适合"预热是硬要求"的场景;若要"预热失败也能启动",就在内部 catch 并降级。 - 问:
ContextRefreshedEvent为什么可能触发两次? 存在父子容器(如 Spring MVC + 根容器)时各自 refresh 一次,需要判空event.getApplicationContext().getParent() == null。 - 问:预热和懒加载冲突吗? 不冲突。预热是"提前把数据放进缓存",懒加载是"Bean 延迟创建",两者解决不同层面的启动耗时。
- 问:本地缓存(Caffeine)怎么预热? 同理,在
ApplicationRunner里cache.put(...),注意容量上限与过期策略,别把全表塞进堆内存。 - 问:预热会不会拖慢发布? 会,所以要设超时、限流、分批,并监控"启动到 ready"的时长指标。
小结
记住一张时序图与一条原则:越晚的扩展点依赖越完整。生产实践是"ApplicationRunner + 并行 + 超时降级 + 分布式锁 + readiness 配合",既能真的热起来,又不会把发布变成事故。