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


面试考察点

  1. Spring 生命周期理解:面试官想知道你是否清楚 Spring 容器启动过程中有哪些扩展点,能不能在正确的时机执行预热逻辑。
  2. 缓存实践经验:看你是否真正在项目中做过缓存预热,而不仅是纸上谈兵。比如预热的数据从哪来、预热失败怎么办、预热耗时过长怎么处理。
  3. 方案选型能力:这道题有多种实现方式,面试官想看你是否能根据不同场景选择合适的方案,以及说出各方案的优劣。

核心答案

Spring 启动过程中做缓存预热,常见有 4 种方式:

方式核心接口/注解执行时机推荐指数
@PostConstructJSR-250 注解Bean 初始化后,依赖注入完成后⭐⭐⭐
CommandLineRunner / ApplicationRunnerSpring Boot 接口Spring 容器完全启动后⭐⭐⭐⭐⭐
ApplicationListenerSpring 事件机制监听 ContextRefreshedEvent⭐⭐⭐⭐
InitializingBeanSpring 接口Bean 属性设置完成后⭐⭐⭐

生产环境推荐:用 CommandLineRunnerApplicationRunner,因为它们在 Spring 容器完全就绪后才执行,所有 Bean 都已经初始化完毕,不会有依赖还没准备好的问题。

深度解析

一、各方案执行时机

先搞清楚这几种方式的执行顺序,这个面试官特别爱追问:

上图的执行时序非常重要,直接决定了你选哪种方案:

  • 步骤 3(@PostConstruct:每个 Bean 自己初始化时就会触发。如果你的预热只依赖当前 Bean 注入的资源,用这个没问题。但如果需要依赖其他 Bean 的某些初始化逻辑,可能会踩坑。
  • 步骤 4(InitializingBean:和 @PostConstruct 时机接近,只不过是通过实现接口的方式,侵入性更强一些。
  • 步骤 6(ContextRefreshedEvent:所有 Bean 都初始化完成了,容器刷新完毕时触发。但如果使用 Spring MVC,父子容器可能会导致这个事件触发两次。
  • 步骤 8(CommandLineRunner:这是最晚的时机,容器完全就绪,包括 Web 服务器都启动好了。最安全,推荐用这个。

二、方式一:@PostConstruct

最简单直接的方式,适合预热逻辑比较轻量的场景:

@Service
public class DictCacheService {

    @Autowired
    private DictMapper dictMapper;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    // Bean 初始化完成后自动执行
    @PostConstruct
    public void preloadDictCache() {
        // 从数据库加载字典数据,写入 Redis 缓存
        List<Dict> dictList = dictMapper.selectAll();
        for (Dict dict : dictList) {
            String key = "dict:" + dict.getType() + ":" + dict.getCode();
            redisTemplate.opsForValue().set(key, dict.getLabel());
        }
        log.info("字典缓存预热完成,共加载 {} 条数据", dictList.size());
    }
}

注意@PostConstruct 是每个 Bean 独立触发的,执行顺序不保证。如果预热逻辑依赖了另一个 Bean 的 @PostConstruct 先执行完,就可能出问题。

三、方式二:CommandLineRunner / ApplicationRunner(推荐!)

这是 Spring Boot 提供的专用接口,在容器完全启动后执行,所有 Bean 都已就绪,最稳妥的方案

@Component
public class CachePreheatRunner implements CommandLineRunner {

    @Autowired
    private UserService userService;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void run(String... args) throws Exception {
        log.info("开始缓存预热...");

        // 预热用户信息缓存
        List<User> hotUsers = userService.getHotUsers();
        for (User user : hotUsers) {
            String key = "user:" + user.getId();
            redisTemplate.opsForValue().set(key, user, 2, TimeUnit.HOURS);
        }

        // 预热系统配置缓存
        Map<String, String> configs = userService.getSystemConfigs();
        redisTemplate.opsForHash().putAll("sys:config", configs);

        log.info("缓存预热完成");
    }
}

ApplicationRunnerCommandLineRunner 几乎一样,区别在于参数类型:

// ApplicationRunner 的参数是封装好的 ApplicationArguments 对象
// 可以方便地获取启动参数,比如 --key=value 形式的参数
@Component
public class CachePreheatRunner implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 可以通过 args 判断是否需要跳过预热
        if (args.containsOption("skip-cache-preheat")) {
            log.info("跳过缓存预热");
            return;
        }
        // 执行预热逻辑...
    }
}

还可以用 @Order 控制多个 Runner 的执行顺序:

@Component
@Order(1)  // 先预热基础数据
public class DictCacheRunner implements CommandLineRunner { ... }

@Component
@Order(2)  // 再预热业务数据
public class BizCacheRunner implements CommandLineRunner { ... }

四、方式三:ApplicationListener

通过监听 Spring 的 ContextRefreshedEvent 事件,在容器刷新完成时触发预热:

@Component
public class CachePreheatListener implements ApplicationListener<ContextRefreshedEvent> {

    @Autowired
    private ConfigService configService;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        // 注意:Spring MVC 有父子容器,这个事件可能触发两次
        // 需要判断是否是根容器,避免重复预热
        if (event.getApplicationContext().getParent() == null) {
            log.info("监听到容器刷新事件,开始缓存预热...");
            // 执行预热逻辑
            preloadCache();
        }
    }
}

五、生产环境的坑和最佳实践

缓存预热说起来简单,但生产环境中几个坑必须注意:

1. 预热耗时过长,拖慢启动速度

预热数据量大的时候,Spring 启动可能要等很久。解决方案——用异步预热:

@Component
public class CachePreheatRunner implements CommandLineRunner {

    @Autowired
    private CachePreheatService cachePreheatService;

    @Async  // 异步执行,不阻塞主线程
    @Override
    public void run(String... args) {
        cachePreheatService.preheat();
    }
}

不过异步预热意味着应用启动后,缓存可能还没加载完。这时候需要有降级策略——缓存没命中就走数据库查。

2. 预热失败不影响正常启动

预热失败不应该导致应用启动失败,必须加保护:

@Override
public void run(String... args) {
    try {
        preloadCache();
    } catch (Exception e) {
        // 预热失败只记录日志,不影响应用启动
        // 后续请求自然会通过缓存穿透的方式逐步构建缓存
        log.error("缓存预热失败,应用正常启动,后续请求将逐步构建缓存", e);
    }
}

3. 集群环境下的重复预热

如果是多实例部署,每个实例启动都会预热一遍,对数据库和 Redis 产生压力。可以考虑用分布式锁控制只有一个实例执行预热:

@Override
public void run(String... args) {
    String lockKey = "cache:preheat:lock";
    // 尝试获取分布式锁
    Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.MINUTES);
    if (Boolean.TRUE.equals(locked)) {
        try {
            preloadCache();
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        log.info("其他实例正在执行缓存预热,本实例跳过");
    }
}

面试高频追问

  1. 追问一:缓存预热和缓存击穿、缓存穿透有什么关系?

    • 缓存预热是 "主动" 把热点数据加载到缓存,防止冷启动时大量请求直接打到数据库(本质上防的是缓存击穿)。缓存穿透是查根本不存在的数据,预热解决不了,需要布隆过滤器或空值缓存。
  2. 追问二:CommandLineRunnerApplicationRunner 的区别?

    • CommandLineRunner 的参数是 String... args(原始参数数组),ApplicationRunner 的参数是 ApplicationArguments(封装后的对象,可以方便地解析 --key=value 形式的参数)。执行时机完全一样,功能上没区别,看你习惯用哪个。
  3. 追问三:多实例部署怎么保证缓存预热不会重复执行?

    • 用分布式锁(Redis 的 SET NX)、或者在 Kubernetes 中用 initContainer 只让一个 Pod 执行预热。最简单的方案其实是不管重复——预热本身是幂等的,多执行几次只是浪费一点资源,不会出错。

常见面试变体

  • "Spring Boot 启动时如何自动加载一些数据?"
  • "如何避免系统冷启动时数据库被瞬间打崩?"
  • "Spring 有哪些扩展点可以在启动时执行自定义逻辑?"

总结

Spring 缓存预热的方案很多,生产环境首选 CommandLineRunner,时机最安全。记住三个要点:异步预热避免拖慢启动、异常处理避免启动失败、分布式锁避免集群重复预热。面试官问这道题,不光是想听方案,更想听你在真实项目中踩过的坑。