如何在 Spring 启动过程中做缓存预热?
面试考察点
- Spring 生命周期理解:面试官想知道你是否清楚 Spring 容器启动过程中有哪些扩展点,能不能在正确的时机执行预热逻辑。
- 缓存实践经验:看你是否真正在项目中做过缓存预热,而不仅是纸上谈兵。比如预热的数据从哪来、预热失败怎么办、预热耗时过长怎么处理。
- 方案选型能力:这道题有多种实现方式,面试官想看你是否能根据不同场景选择合适的方案,以及说出各方案的优劣。
核心答案
Spring 启动过程中做缓存预热,常见有 4 种方式:
| 方式 | 核心接口/注解 | 执行时机 | 推荐指数 |
|---|---|---|---|
@PostConstruct | JSR-250 注解 | Bean 初始化后,依赖注入完成后 | ⭐⭐⭐ |
CommandLineRunner / ApplicationRunner | Spring Boot 接口 | Spring 容器完全启动后 | ⭐⭐⭐⭐⭐ |
ApplicationListener | Spring 事件机制 | 监听 ContextRefreshedEvent | ⭐⭐⭐⭐ |
InitializingBean | Spring 接口 | Bean 属性设置完成后 | ⭐⭐⭐ |
生产环境推荐:用 CommandLineRunner 或 ApplicationRunner,因为它们在 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("缓存预热完成");
}
}
ApplicationRunner 和 CommandLineRunner 几乎一样,区别在于参数类型:
// 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("其他实例正在执行缓存预热,本实例跳过");
}
}
面试高频追问
-
追问一:缓存预热和缓存击穿、缓存穿透有什么关系?
- 缓存预热是 "主动" 把热点数据加载到缓存,防止冷启动时大量请求直接打到数据库(本质上防的是缓存击穿)。缓存穿透是查根本不存在的数据,预热解决不了,需要布隆过滤器或空值缓存。
-
追问二:
CommandLineRunner和ApplicationRunner的区别?CommandLineRunner的参数是String... args(原始参数数组),ApplicationRunner的参数是ApplicationArguments(封装后的对象,可以方便地解析--key=value形式的参数)。执行时机完全一样,功能上没区别,看你习惯用哪个。
-
追问三:多实例部署怎么保证缓存预热不会重复执行?
- 用分布式锁(Redis 的
SET NX)、或者在 Kubernetes 中用initContainer只让一个 Pod 执行预热。最简单的方案其实是不管重复——预热本身是幂等的,多执行几次只是浪费一点资源,不会出错。
- 用分布式锁(Redis 的
常见面试变体
- "Spring Boot 启动时如何自动加载一些数据?"
- "如何避免系统冷启动时数据库被瞬间打崩?"
- "Spring 有哪些扩展点可以在启动时执行自定义逻辑?"
总结
Spring 缓存预热的方案很多,生产环境首选 CommandLineRunner,时机最安全。记住三个要点:异步预热避免拖慢启动、异常处理避免启动失败、分布式锁避免集群重复预热。面试官问这道题,不光是想听方案,更想听你在真实项目中踩过的坑。
