如何在 Spring 启动过程中做缓存预热?

#Java#面试八股

如何在 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 配合",既能真的热起来,又不会把发布变成事故。

笔记加载中…