前言
在 Spring 应用开发中,我们经常需要在容器初始化过程中执行自定义逻辑。Spring 提供了多种初始化回调机制,但它们分散在容器启动的不同阶段,所处的"环境成熟度"也各不相同。本文将从执行时机 和潜在风险两个维度深入剖析这些方法,帮助你在实际开发中做出正确的选择。
一、Spring 容器启动的生命周期全景
要理解每个初始化方法的适用场景,首先需要清晰地认识 Spring 容器启动的完整过程:
应用启动
↓
┌──────────────────────────────────────────┐
│ 阶段一:Bean 定义加载 │
│ - 解析配置类、扫描组件 │
│ - 注册 BeanDefinition │
│ - 此时所有 Bean 都还是"图纸",不可用 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 阶段二:BeanFactory 后置处理 │
│ - 处理 @Configuration 中的 @Bean 方法 │
│ - 这个时候 Bean 仍未实例化 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 阶段三:Bean 逐个实例化与初始化 │
│ │
│ 对于每一个单例 Bean,依次执行: │
│ ① 构造方法 │
│ ② 依赖注入(@Autowired/@Resource) │
│ ③ Aware 接口回调 │
│ ④ BeanPostProcessor.postProcessBefore │
│ ⑤ @PostConstruct 方法 ← 第一个扩展点 │
│ ⑥ InitializingBean.afterPropertiesSet() ← 第二个扩展点 │
│ ⑦ BeanPostProcessor.postProcessAfter │
│ │
│ ⚠️ 关键特性:这是 Bean 级别的初始化 │
│ Bean A 初始化完 → Bean B 开始初始化 │
│ Bean B 初始化时,Bean A 的初始化方法已执行 │
│ 但无法保证 Bean A 的初始化在 Bean B 之前 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 阶段四:所有单例 Bean 初始化完成 │
│ - SmartLifecycle.start() ← 第三个扩展点 │
│ - 按 getPhase() 从小到大依次调用 │
│ ⚠️ 此时 Web 容器(Tomcat)尚未启动 │
│ ⚠️ HTTP 请求还无法进入系统 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 阶段五:容器刷新完成 │
│ - 发布 ContextRefreshedEvent │
│ - @EventListener 或 ApplicationListener │
│ 可以接收到这个事件 ← 第四个扩展点 │
│ ⚠️ 此时 Web 容器仍未启动 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 阶段六:Web 容器启动(Spring Boot) │
│ - 启动内嵌 Tomcat/Jetty/Undertow │
│ - 绑定端口,开始接收 HTTP 请求 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 阶段七:应用完全启动 │
│ - CommandLineRunner.run() ← 第五个扩展点 │
│ - ApplicationRunner.run() ← 第六个扩展点 │
│ - @Bean 返回 Runnable/Callable │
│ ⚠️ 此时 HTTP 请求已经可以进入系统! │
│ ⚠️ 如果 Runner 执行耗时较长, │
│ 请求可能获取到未就绪的资源! │
└──────────────────────────────────────────┘
核心分水岭:Web 容器的启动时机将整个初始化过程分为两个阶段------"启动中"和"已启动"。这个分水岭是理解各种方法适用场景的关键。
二、各初始化方法详解
2.1 @PostConstruct
执行时机:当前 Bean 完成属性注入后立即执行
所属阶段:阶段三 ------ Bean 级别初始化
执行环境特征:
| 维度 | 状态 |
|---|---|
| 当前 Bean 依赖注入 | ✅ 已完成 |
| 当前 Bean 初始化 | 🔄 进行中 |
| 其他 Bean 初始化 | ❓ 不确定,可能在之前也可能在之后 |
| 事务管理器 | ❌ 切面尚未织入,声明式事务不可用 |
| 数据库连接池 | ✅ 通常已可用(如 HikariCP 在初始化时即建立连接) |
| Redis 连接 | ✅ 通常已可用 |
| RabbitMQ 连接 | ❌ 通常不可用(默认懒加载) |
| Kafka 连接 | ❌ 通常不可用(懒初始化) |
| Web 容器 | ❌ 未启动 |
代码示例:
java
@Component
public class UserCacheInitializer {
@Autowired
private UserRepository userRepository; // ✅ 已注入,对象不为 null
@Autowired
private CacheManager cacheManager; // ✅ 已注入,对象不为 null
private Map<Long, User> localCache;
@PostConstruct
public void init() {
// ✅ 安全操作:初始化当前 Bean 的内部状态
localCache = new ConcurrentHashMap<>();
// ⚠️ 有风险的操作:调用其他 Bean 在 @PostConstruct 中初始化的资源
// 如果 cacheManager 的 init() 还没执行,cache 可能为 null
Cache cache = cacheManager.getCache("users");
// ⚠️ 有风险的操作:JPA Repository 查询
// 虽然 DataSource 通常可用,但 JPA 的 SessionFactory 可能还没就绪
// 事务也不会自动管理
List<User> users = userRepository.findAll();
// ❌ 危险操作:发送消息到 MQ
// RabbitMQ 默认懒加载,此时很可能还没建立连接
// rabbitTemplate.convertAndSend("user.queue", user);
}
}
风险总结:
| 操作 | 风险等级 | 说明 |
|---|---|---|
| 初始化当前 Bean 内部集合 | 🟢 安全 | 不依赖外部状态 |
| 访问注入的配置属性 | 🟢 安全 | 属性注入在 @PostConstruct 之前完成 |
| 使用 DataSource 获取连接 | 🟡 低风险 | 连接池通常在初始化时就建连 |
| 使用 JdbcTemplate 查询 | 🟡 低风险 | 依赖 DataSource,通常可用 |
| 使用 JPA Repository 查询 | 🟠 中风险 | SessionFactory 可能未就绪,事务不可用 |
| 访问其他 Bean 的 @PostConstruct 初始化资源 | 🟠 中风险 | 初始化顺序不确定 |
| 调用 Redis 基础读写 | 🟡 低风险 | Redis 客户端初始化即连接 |
| 发送 MQ 消息(RabbitMQ) | 🔴 高风险 | 默认懒加载,连接通常未建立 |
| 发送 Kafka 消息 | 🔴 高风险 | 默认懒加载 |
| 使用 @Transactional 方法 | 🔴 高风险 | 事务切面此时不生效 |
2.2 InitializingBean.afterPropertiesSet()
执行时机:在 @PostConstruct 方法执行之后立即调用
所属阶段:阶段三 ------ Bean 级别初始化(与 @PostConstruct 同阶段,稍晚)
执行环境特征 :与 @PostConstruct 完全相同
代码示例:
java
@Component
public class DataValidator implements InitializingBean {
@Autowired
private DataSource dataSource;
@Autowired
private ValidationRules rules; // 其他 Bean
@Override
public void afterPropertiesSet() throws Exception {
// 环境特征与 @PostConstruct 完全一致
// 风险也完全相同
// ⚠️ 同样的风险:rules 的 @PostConstruct 可能还没执行
// 如果 rules 在 @PostConstruct 中加载了验证规则
// 这里获取到的 rules 可能是不完整的状态
List<Rule> activeRules = rules.getActiveRules();
}
}
为什么还要有 InitializingBean?
主要是历史原因和场景差异:
-
InitializingBean 是 Spring 1.0 就有的机制
-
@PostConstruct 是 JSR-250 标准,在 Spring 2.5 才引入
-
InitializingBean 可以在运行时动态决定是否执行初始化
-
某些老项目或框架代码可能更倾向使用接口方式
额外注意:如果同时使用 @PostConstruct 和 InitializingBean,执行顺序为:构造方法 → @PostConstruct → afterPropertiesSet()
2.3 SmartLifecycle.start()
执行时机:所有单例 Bean 初始化完成之后
所属阶段:阶段四 ------ 容器级初始化(在 Web 容器启动之前)
执行环境特征:
| 维度 | 状态 |
|---|---|
| 所有 Bean 的依赖注入 | ✅ 已完成 |
| 所有 Bean 的 @PostConstruct | ✅ 已执行完毕 |
| 所有 Bean 的 afterPropertiesSet() | ✅ 已执行完毕 |
| 事务管理器 | ✅ 已就绪,声明式事务可用 |
| 数据库连接池 | ✅ 完全可用 |
| JPA SessionFactory | ✅ 已初始化 |
| Redis 连接 | ✅ 完全可用 |
| RabbitMQ 连接 | ⚠️ 视配置而定(默认懒加载,可能还未连接) |
| Kafka Producer | ⚠️ 视配置而定 |
| 消息监听容器 | ✅ 在这个阶段启动 |
| Web 容器 | ❌ 尚未启动 |
代码示例:
java
@Component
public class SystemDataLoader implements SmartLifecycle {
@Autowired
private UserRepository userRepository;
@Autowired
private CacheManager cacheManager;
@Autowired
private RabbitTemplate rabbitTemplate;
private volatile boolean running = false;
@Override
public void start() {
System.out.println("=== SmartLifecycle.start() 阶段 ===");
// ✅ 安全操作:JPA 查询(事务可用)
List<User> users = userRepository.findAll();
// ✅ 安全操作:访问其他 Bean 的 @PostConstruct 初始化的资源
// 所有 Bean 的 @PostConstruct 都已执行完毕
Cache cache = cacheManager.getCache("users");
// ✅ 安全操作:缓存预热
users.forEach(user -> cache.put(user.getId(), user));
// ⚠️ 仍有风险:RabbitMQ 默认懒加载,可能还没建立连接
try {
rabbitTemplate.convertAndSend("user.exchange", "loaded", users.size());
} catch (Exception e) {
System.out.println("MQ 暂不可用,将跳过消息发送: " + e.getMessage());
}
// ✅ 重要:此时 HTTP 请求还无法进入,不会有并发访问问题
running = true;
}
@Override
public boolean isAutoStartup() {
return true;
}
@Override
public void stop() {
running = false;
}
@Override
public int getPhase() {
return 0; // 数值越小越先执行,可以通过这个控制初始化顺序
}
@Override
public boolean isRunning() {
return running;
}
}
风险总结:
| 操作 | 风险等级 | 说明 |
|---|---|---|
| JPA 数据库查询 | 🟢 安全 | SessionFactory 已就绪 |
| @Transactional 数据库操作 | 🟢 安全 | 事务管理器已就绪 |
| Redis 操作 | 🟢 安全 | 连接早已建立 |
| 访问其他 Bean 的任何初始化资源 | 🟢 安全 | 所有 @PostConstruct 已执行 |
| 缓存预热 | 🟢 安全 | 无 HTTP 请求竞争 |
| RabbitMQ 发送消息 | 🟡 低风险 | 默认懒加载可能未连接(可配置强制初始化) |
| Kafka 发送消息 | 🟡 低风险 | 同上 |
| 调用外部 HTTP 服务 | 🟢 安全 | 线程池等资源已就绪 |
| 启动后台任务 | 🟢 安全 | 所有依赖都已就绪 |
SmartLifecycle 的独特优势:
-
phase 控制 :可以通过
getPhase()精确控制多个 Lifecycle 的启动顺序 -
优雅关闭 :
stop()方法在容器关闭时自动调用 -
状态感知 :
isRunning()可以让容器知道当前状态 -
Web 容器未启动:这是最后一道"防火墙",在流量进来之前完成关键初始化
2.4 ContextRefreshedEvent 监听
执行时机:容器刷新完成后发布事件
所属阶段:阶段五 ------ 容器刷新完成事件
执行环境特征 :与 SmartLifecycle.start() 基本一致,所有基础设施都已就绪,Web 容器仍未启动。
代码示例:
java
@Component
public class ApplicationInitializer {
@Autowired
private DataSource dataSource;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@EventListener(ContextRefreshedEvent.class)
public void handleContextRefresh(ContextRefreshedEvent event) {
// ⚠️ 重要:在 Spring MVC 中,ContextRefreshedEvent 会触发两次
// 一次是 Spring 根容器,一次是 Spring MVC 子容器
// 需要判断以避免重复执行
if (event.getApplicationContext().getParent() != null) {
return; // 跳过子容器的事件
}
System.out.println("=== ContextRefreshedEvent 阶段 ===");
// ✅ 安全操作:所有数据库、Redis 操作都可用
try (Connection conn = dataSource.getConnection()) {
System.out.println("数据库连接: " + conn.getMetaData().getURL());
}
// ✅ 安全操作:缓存预热
redisTemplate.opsForValue().set("app:status", "ready");
// ✅ 安全操作:执行 DDL 或数据迁移脚本
// initDatabaseSchema();
}
}
ContextRefreshedEvent vs SmartLifecycle 的选择:
| 维度 | SmartLifecycle | ContextRefreshedEvent |
|---|---|---|
| 执行顺序控制 | ✅ 通过 getPhase() 精确控制 | ⚠️ 需要 @Order + 事件监听顺序 |
| 停止回调 | ✅ stop() 方法 | ❌ 需要监听 ContextClosedEvent |
| 运行状态查询 | ✅ isRunning() | ❌ 无内置机制 |
| 父子容器问题 | ❌ 无(每个 Bean 只在一个容器中) | ⚠️ 会触发多次,需要判断 |
| 代码侵入性 | 🟡 需要实现多个方法 | 🟢 只需一个注解 |
| 适用场景 | 需要生命周期管理的组件 | 一次性初始化任务 |
2.5 CommandLineRunner 与 ApplicationRunner
执行时机:Spring Boot 应用完全启动后(Web 容器已启动)
所属阶段:阶段七 ------ 应用完全启动
执行环境特征:
| 维度 | 状态 |
|---|---|
| 所有 Bean | ✅ 完全就绪 |
| 所有中间件连接 | ✅ 完全就绪 |
| 事务管理 | ✅ 完全可用 |
| Web 容器 | ✅ 已启动,正在接收请求 |
| HTTP 请求 | ✅ 可以进入系统 |
这是最特殊的阶段:系统已经对外提供服务,但初始化代码可能还在执行。
代码示例:
java
// CommandLineRunner - 接收原始字符串参数
@Component
@Order(1)
public class DataWarmupRunner implements CommandLineRunner {
@Autowired
private CacheService cacheService;
@Autowired
private SearchIndexService searchService;
@Override
public void run(String... args) throws Exception {
System.out.println("=== CommandLineRunner 阶段 ===");
System.out.println("⚠️ 注意:此时 HTTP 请求已经可以进入系统");
// ✅ 所有组件都已可用
// ⚠️ 但耗时操作会阻塞请求对初始化资源的访问!
// 假设这个操作需要 10 秒
System.out.println("开始缓存预热...");
cacheService.warmUp(); // 耗时 5 秒
// 在这 5 秒内,如果有请求访问需要缓存数据的接口
// 可能会得到空结果或触发缓存穿透
System.out.println("开始构建搜索索引...");
searchService.rebuildIndex(); // 耗时 5 秒
System.out.println("初始化完成,总耗时约 10 秒");
}
}
// ApplicationRunner - 接收结构化的 ApplicationArguments
@Component
@Order(2)
public class TaskExecutorRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
// ApplicationArguments 提供了更好的参数处理
List<String> tasks = args.getNonOptionArgs();
if (tasks.contains("repair")) {
System.out.println("执行数据修复任务...");
// 执行修复逻辑
}
if (args.containsOption("batch-size")) {
String batchSize = args.getOptionValues("batch-size").get(0);
System.out.println("批处理大小: " + batchSize);
}
}
}
核心风险:并发访问未就绪资源
这是 Runner 最容易被忽视的风险。让我们看一个具体的问题场景:
java
@RestController
public class ProductController {
@Autowired
private ProductCache productCache;
@GetMapping("/product/{id}")
public Product getProduct(@PathVariable Long id) {
// ⚠️ 如果 DataWarmupRunner 还在执行中
// productCache 可能还是空的
Product product = productCache.get(id);
if (product == null) {
// 缓存未命中,触发数据库查询(可能造成缓存穿透)
product = loadFromDatabase(id);
}
return product;
}
}
@Component
public class DataWarmupRunner implements CommandLineRunner {
@Autowired
private ProductCache productCache;
@Override
public void run(String... args) throws Exception {
// 耗时 30 秒的热数据加载
// 在这 30 秒内,所有对 /product/{id} 的请求
// 都会遇到缓存为空的情况
List<Product> hotProducts = loadAllHotProducts();
hotProducts.forEach(p -> productCache.put(p.getId(), p));
}
}
Runner 阶段的风险总结:
| 操作 | 风险等级 | 说明 |
|---|---|---|
| 打印启动日志 | 🟢 安全 | 不影响业务 |
| 触发异步任务 | 🟢 安全 | 非阻塞初始化 |
| 加载非关键配置 | 🟡 低风险 | 需要配合降级处理 |
| 耗时缓存预热 | 🔴 高风险 | 预热期间请求获取不到数据 |
| 重建搜索索引 | 🔴 高风险 | 索引重建期间搜索结果不完整 |
| 数据修复/迁移 | 🔴 高风险 | 修改数据期间可能引发数据不一致 |
2.6 @Bean 返回 Runnable/Callable
执行时机:与 CommandLineRunner 完全相同
特点:Spring Boot 会自动将返回 Runnable 或 Callable 的 @Bean 方法包装成 CommandLineRunner
java
@SpringBootApplication
public class Application {
@Bean
public Runnable quickInitTask() {
return () -> {
// 执行时机和风险与 CommandLineRunner 完全一样
System.out.println("快速初始化任务");
};
}
}
三、参数接收机制(CommandLineRunner & ApplicationRunner)
两个 Runner 接收的参数都来自启动命令:
bash
# 命令行启动
java -jar app.jar --server.port=9090 --app.name=MyApp init sync
# IDEA 中配置
# Run → Edit Configurations → Program arguments
# 输入:--server.port=9090 --app.name=MyApp init sync
CommandLineRunner 的参数
java
@Component
public class MyCommandLineRunner implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
// args = ["--server.port=9090", "--app.name=MyApp", "init", "sync"]
// 需要手动解析,所有参数都会传入(包括 Spring Boot 的配置参数)
for (String arg : args) {
if (arg.equals("init")) {
System.out.println("执行初始化任务");
} else if (arg.startsWith("--app.name=")) {
String appName = arg.substring("--app.name=".length());
System.out.println("应用名称: " + appName);
}
}
}
}
ApplicationRunner 的参数
java
@Component
public class MyApplicationRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
// 非选项参数(不带 -- 的参数)
List<String> nonOptionArgs = args.getNonOptionArgs();
// nonOptionArgs = ["init", "sync"]
// 选项参数名称(-- 后面的 key)
Set<String> optionNames = args.getOptionNames();
// optionNames = ["server.port", "app.name"]
// Spring Boot 会自动排除自己的配置参数
// 获取特定选项的值
if (args.containsOption("app.name")) {
String appName = args.getOptionValues("app.name").get(0);
System.out.println("应用名称: " + appName);
}
}
}
四、风险等级全景图
4.1 各组件在不同阶段的可用性
这是实践中最容易被忽视的问题。不是所有 Bean 被注入后就完全可用了,"注入完成"≠"功能可用":
| 组件/操作 | @PostConstruct | SmartLifecycle | ContextRefreshed | Runner |
|---|---|---|---|---|
| DataSource 连接 | ✅ | ✅ | ✅ | ✅ |
| JdbcTemplate 查询 | ✅ | ✅ | ✅ | ✅ |
| JPA Repository 查询 | ⚠️ | ✅ | ✅ | ✅ |
| @Transactional 事务 | ❌ | ✅ | ✅ | ✅ |
| MyBatis Mapper | ⚠️ | ✅ | ✅ | ✅ |
| Redis 基础操作 | ✅ | ✅ | ✅ | ✅ |
| Redis Cluster | ⚠️ | ✅ | ✅ | ✅ |
| RabbitMQ 发送消息 | ❌ | ⚠️ | ✅ | ✅ |
| Kafka 发送消息 | ❌ | ⚠️ | ✅ | ✅ |
| MQ 消息监听 | ❌ | ✅ | ✅ | ✅ |
| Elasticsearch | ✅ | ✅ | ✅ | ✅ |
| MongoDB | ✅ | ✅ | ✅ | ✅ |
| 访问其他 Bean 的初始化资源 | ❌ | ✅ | ✅ | ✅ |
| HTTP 请求进入系统 | ❌ | ❌ | ❌ | ✅ |
图例:✅ 安全 ⚠️ 有风险 ❌ 不可用
4.2 为什么会有这些差异?
各组件的初始化方式不同,决定了它们在容器启动过程中的可用时机:
// 1. HikariCP 连接池 —— 饥渴初始化,@PostConstruct 时就可使用
// HikariDataSource 在创建时就建立初始连接
// 因为连接池的初始化是在 Bean 的构造或 afterPropertiesSet 中完成的
// 2. RabbitMQ CachingConnectionFactory —— 默认懒加载
// 连接在第一次实际使用时才建立
// 可以设置 factory.setCacheMode(CacheMode.CONNECTION) 改变行为
// 3. Kafka Producer —— 懒初始化
// 实际的网络连接在第一次 send 时才建立
// 4. JPA SessionFactory —— 初始化较晚
// 需要扫描实体、解析映射关系,在专门的 BeanPostProcessor 中完成
// 5. 事务切面 —— 需要 AOP 代理就绪
// @Transactional 依赖 AOP 代理,在 BeanPostProcessor 阶段织入
// 所以 @PostConstruct 时事务切面通常还未生效
五、实战选择决策树
需要初始化吗?
│
├─ 是 → 初始化需要依赖其他 Bean 吗?
│ │
│ ├─ 是 → 需要数据库/JPA/事务吗?
│ │ │
│ │ ├─ 是 → 用 SmartLifecycle 或 ContextRefreshedEvent
│ │ │ (JPA 和事务在这些阶段才完全就绪)
│ │ │
│ │ └─ 否 → 需要保证数据在请求进来前就绪吗?
│ │ │
│ │ ├─ 是 → 用 SmartLifecycle
│ │ │ (在 Web 容器启动前执行,无请求并发风险)
│ │ │
│ │ └─ 否 → 可以用 ContextRefreshedEvent
│ │
│ └─ 否 → 只是初始化当前 Bean 的内部状态?
│ │
│ ├─ 是 → 可以用 @PostConstruct
│ │ (简单、轻量,当前 Bean 内的事情)
│ │
│ └─ 否 → 需要读取启动参数?
│ │
│ ├─ 是 → 用 CommandLineRunner 或 ApplicationRunner
│ │ (但要注意耗时初始化期间的并发风险)
│ │
│ └─ 否 → 考虑是否真的需要在启动时做这件事
│
└─ 否 → 考虑懒加载,真正需要时再初始化
六、生产环境最佳实践
6.1 关键资源提前加载模式
将影响业务正常响应的初始化工作放到 Web 容器启动前:
java
@Component
public class CriticalResourceLoader implements SmartLifecycle {
@Autowired
private CacheService cacheService;
@Autowired
private SearchIndexService searchService;
private volatile boolean ready = false;
@Override
public void start() {
// 在 Web 容器启动前完成关键资源加载
// 确保流量进来时资源已就绪
cacheService.warmUp();
searchService.ensureIndexReady();
ready = true;
}
@Override
public int getPhase() {
return Integer.MAX_VALUE - 100; // 最后执行
}
@Override
public boolean isAutoStartup() { return true; }
@Override
public boolean isRunning() { return ready; }
@Override
public void stop() { ready = false; }
}
6.2 非关键资源异步加载模式
不影响核心业务的初始化可以放到 Runner 中异步执行,并配合降级策略:
java
@Component
public class RecommendationCacheWarmer implements CommandLineRunner {
@Autowired
private RecommendationCache cache;
@Override
public void run(String... args) throws Exception {
// 异步加载,不阻塞启动
CompletableFuture.runAsync(() -> {
cache.warmUp();
});
}
}
@RestController
public class RecommendationController {
@Autowired
private RecommendationCache cache;
@GetMapping("/recommendations")
public List<Item> getRecommendations() {
List<Item> cached = cache.getAll();
if (cached == null || cached.isEmpty()) {
// 降级:直接返回热门推荐
return getHotItemsFallback();
}
return cached;
}
}
6.3 就绪状态检查模式
提供就绪检查端点,配合容器编排平台(如 Kubernetes)的 Readiness Probe:
java
@Component
public class ReadinessIndicator {
private volatile boolean ready = false;
@EventListener(ContextRefreshedEvent.class)
public void markReady() {
ready = true;
}
public boolean isReady() {
return ready;
}
}
@RestController
public class HealthController {
@Autowired
private ReadinessIndicator readiness;
@GetMapping("/ready")
public ResponseEntity<String> readiness() {
if (readiness.isReady()) {
return ResponseEntity.ok("ready");
}
return ResponseEntity.status(503).body("not ready");
}
}
七、总结
| 方法 | 执行级别 | 容器状态 | 核心风险 | 推荐场景 |
|---|---|---|---|---|
| @PostConstruct | Bean 级 | Bean 刚初始化 | 其他 Bean 未就绪、事务不可用、MQ 不可用 | 当前 Bean 内部状态初始化 |
| InitializingBean | Bean 级 | 同 @PostConstruct | 同上 | 需要 Spring 接口回调的场景 |
| SmartLifecycle | 容器级 | 所有 Bean 就绪 | MQ 等懒加载组件可能未连接 | 关键资源预加载、需精确控制启动顺序 |
| ContextRefreshedEvent | 容器级 | 容器刷新完成 | 父子容器重复触发 | 一次性初始化任务 |
| CommandLineRunner | 应用级 | Web 容器已启动 | HTTP 请求可能访问到未就绪资源 | 启动日志、需要启动参数的任务 |
| ApplicationRunner | 应用级 | 同 CommandLineRunner | 同上 | 需要结构化参数的初始化任务 |
记住两个核心原则:
-
"注入完成"不等于"功能可用"------不同中间件的就绪时机不同,选择正确的初始化阶段至关重要
-
Web 容器启动是分水岭------在此之前初始化,可以避免并发请求访问到未就绪的资源;在此之后初始化,必须考虑降级和容错
补充:ContextRefreshedEvent 与父子容器问题
一、什么是父子容器?
在传统的 Spring MVC 架构中(包括 Spring Boot 2.x 及更早版本),应用内部实际上存在两个 IoC 容器,它们之间是父子关系:
-
父容器(Root ApplicationContext) 管理 Service、Repository、组件配置等业务 Bean,通常由
ContextLoaderListener或 Spring Boot 主类创建。 -
子容器(Servlet ApplicationContext) 管理 Controller、HandlerMapping、ViewResolver 等 Web 层 Bean,由
DispatcherServlet创建,并将父容器设置为自己的 parent。
父子容器的核心规则:子容器可以访问父容器中的 Bean,但父容器无法访问子容器中的 Bean。这种设计的初衷是隔离 Web 层与业务层,同时允许多个 DispatcherServlet 共享同一套业务组件。
二、ContextRefreshedEvent 为什么会触发多次?
ContextRefreshedEvent 是容器级别的事件------每当一个 ApplicationContext 被刷新(初始化或重建)完成时发布一次。由于父子容器的存在,整个启动流程会触发两次事件:
-
父容器刷新完成 → 发布第一次
ContextRefreshedEvent此时 Service、Repository 等业务 Bean 已就绪,但 Controller 尚未加载。 -
子容器刷新完成 → 发布第二次
ContextRefreshedEvent此时 Controller 等 Web 层 Bean 也加载完毕。
如果你的 @EventListener 或 ApplicationListener 直接监听该事件,里面的初始化逻辑就会执行两次,这往往会带来重复建表、重复插入数据、重复预热缓存等问题。
java
@Component
public class InitExample {
@EventListener(ContextRefreshedEvent.class)
public void onRefresh() {
// 这段代码会被调用两次!
System.out.println("Context refreshed!");
}
}
三、如何可靠地避免重复执行?
方案一:判断是否为父容器(经典方案)
通过检查容器是否有 parent 来识别根容器,只在根容器中执行初始化。
java
@EventListener(ContextRefreshedEvent.class)
public void init(ContextRefreshedEvent event) {
if (event.getApplicationContext().getParent() == null) {
// 只在父容器(根容器)中执行,避免重复
performInitialization();
}
}
方案二:使用 AtomicBoolean 标志位(更通用)
java
private final AtomicBoolean initialized = new AtomicBoolean(false);
@EventListener(ContextRefreshedEvent.class)
public void init() {
if (initialized.compareAndSet(false, true)) {
performInitialization();
}
}
最佳实践:两种方式可以组合使用,既保证语义清晰,又防止极端情况下的并发重复。
四、Spring Boot 3.x 的变化
从 Spring Boot 3.x(基于 Spring Framework 6)开始,默认情况下不再维护独立的父子容器 。DispatcherServlet 直接使用主容器,不再单独创建子容器。因此,ContextRefreshedEvent 在大多数场景下只会触发一次,不再需要担心因父子容器导致的重复执行。
然而,以下情况仍可能出现多次触发:
-
手动注册了额外的
DispatcherServlet; -
使用某些 Spring Cloud 组件(如 Spring Cloud Gateway)可能引入额外容器;
-
项目采用传统
web.xml部署方式或在外部 Servlet 容器中运行。
因此,兼容性写法依然极具价值 ------在代码中增加父容器判断或使用 AtomicBoolean 并不会带来副作用,反而能让你的初始化逻辑在各种环境、各种 Spring 版本下都安全运行。
五、总结
-
问题本质 :Spring MVC 传统的父子容器架构导致
ContextRefreshedEvent触发两次。 -
Spring Boot 3 的改进:架构简化后通常只触发一次,但并非绝对。
-
推荐写法:始终加上父容器判断或使用原子标志位,既兼容老版本,也防御特殊场景,是实现健壮初始化逻辑的最佳实践。