Administrator
发布于 2022-12-05 / 2886 阅读
62

Spring Bean 初始化顺序那些事

一个偶发的空指针

服务启动时偶发 NPE,堆栈指向缓存预热器里取到的 RedisTemplate 是 null。但 RedisTemplate 明明配好了——问题是它被用在了配置类的 @PostConstruct 里,而那时它依赖的连接工厂还没就绪。重启三四次才复现一次,最磨人。这种"时灵时不灵"的启动问题,往往就是 Bean 初始化顺序在作怪。

Bean 初始化的真实顺序

Spring 里"顺序"至少有四层,容易混淆:

  • @DependsOn:强制某些 Bean 先实例化;
  • @PostConstruct:当前 Bean 属性注入完成后执行;
  • InitializingBean.afterPropertiesSet:同理,但接口契约更明确;
  • Ordered / PriorityOrdered:影响同一批次 Bean 的排序。

很多人以为 @DependsOn("A") 之后,A 就"完全就绪"了。错。@DependsOn 只保证 A 先被实例化(构造 + 属性注入),不保证 A 自己的 @PostConstruct 已经跑完。如果 A 在 @PostConstruct 里才建连接,那 B 在 @PostConstruct 里用 A,A 的连接可能还没建好。

@DependsOn 的坑

我那次就是这样:RedisTemplate 依赖的 LettuceConnectionFactory,在它的 @PostConstruct 里才去建立到 Redis 的连接。我的预热器用 @DependsOn 声明依赖它,但预热器自己的 @PostConstruct 跑的时候,连接工厂的 @PostConstruct 还没轮到——顺序只是"实例化了",不是"初始化完了"。于是 template 拿到了,但底层连接是 null,get 一下就 NPE。

正确的写法

@Configuration
@DependsOn("redisConnectionFactory")
public class CacheWarmup {

    @Autowired
    private RedisTemplate<String, String> template;

    @PostConstruct
    public void init() {
        // 此时连接工厂已实例化;
        // 若还需其初始化完成,应把预热移到
        // SmartInitializingSingleton.afterSingletonsInstantiated
        template.opsForValue().set("warmup", "1");
    }
}

更稳妥的是实现 SmartInitializingSingleton,在所有单例都初始化完之后统一做预热,避免互相依赖顺序的泥潭:

@Component
public class CacheWarmup implements SmartInitializingSingleton {
    @Autowired RedisTemplate<String,String> template;

    @Override
    public void afterSingletonsInstantiated() {
        // 这里所有单例 Bean 都已完全初始化
        template.opsForValue().set("warmup", "1");
    }
}

为什么 SmartInitializingSingleton 更稳

它是在容器完成所有单例实例化、包括所有 @PostConstruct 和 afterPropertiesSet 之后才回调的。所以不管你依赖的 Bean 内部初始化多绕,到这一步都保证就绪。需要"跨 Bean 协调的启动逻辑"时,这是比 @DependsOn 更安全的落点。

还有个类似的钩子是 ApplicationRunner / CommandLineRunner,它们在容器完全刷新后才跑,适合"启动后做点事"而非"启动中初始化"。预热属于初始化,用 SmartInitializingSingleton;发个启动通知之类用 Runner。

排查技巧

这类偶发问题怎么定位?我加了一个 -Dspring.main.lazy-initialization=false(默认就是 eager),再在可疑 Bean 的 @PostConstruct 里打日志,观察启动顺序。复现后看日志时间戳,谁先谁后一目了然。另外 Debug 时看 AbstractApplicationContext 的 finishRefresh 流程,能清楚看到 SmartInitializingSingleton 在所有 Bean 之后。

循环依赖的连锁反应

初始化顺序问题常和循环依赖纠缠。两个 Bean 互相 @Autowired,Spring 靠三级缓存解决,但如果你在 @PostConstruct 里调用对方的方法,可能拿到"半成品"。我们遇到过 A 的 @PostConstruct 里调 B,而 B 还没初始化完,拿到 null 字段。这类问题用 SmartInitializingSingleton 也救不了,因为循环还在。根治是拆掉循环依赖,把公共逻辑抽到第三个 Bean。顺序坑很多时候是设计坏味道的信号。

配置类的特殊顺序

@Configuration 类之间也有顺序。@AutoConfigureAfter / @AutoConfigureBefore 控制自动配置类的先后;自己写的 @Configuration 如果依赖别的配置类先就绪,可以用 @DependsOn 在配置类上。但我们更推荐用 @Conditional 系列表达"依赖关系",让 Spring 自己决定,而不是硬塞顺序。硬顺序越多,越脆,一次加依赖就可能打破假设。

一个通用排查清单

遇到启动期 NPE 或空 Bean,按这个顺序查:先确认 Bean 有没有被注册(看启动日志的 Bean 定义数);再确认是不是 @Lazy 导致没初始化;然后看 @DependsOn 只管实例化不管初始化完成;最后考虑用 SmartInitializingSingleton 挪到所有单例就绪后。九成的问题落在这四步里。别一上来就怀疑 Spring 有 bug,绝大部分是自己的初始化假设错了。

和 @Lazy 的配合

@Lazy 会让 Bean 延迟到首次使用时才初始化,这会改变你以为的"启动顺序"。我们曾给一个重 Bean 加 @Lazy 想加速启动,结果它在第一次请求时才初始化,那次请求 RT 暴涨 2 秒,还因为初始化时依赖的另一个 Bean 此时已部分回收而出错。@Lazy 是把启动成本挪到运行期,不是消除,用之前想清楚"第一次谁触发、能否承受"。预热类逻辑尤其别依赖 @Lazy 的 Bean。

一个速查口诀

记一个口诀帮团队避坑:实例看 @DependsOn,就绪看 SmartInitializingSingleton,单 Bean 内收尾看 @PostConstruct,跨 Bean 协调看后者。新人按这个对口,十有八九能对。我们把这段写进了团队的 Spring 开发规范,配了两个反例(一个用 @DependsOn 踩空、一个用 SmartInitializingSingleton 救回)当教材。规范 + 反例比口头说教管用,新服务上线后再没出过启动期 NPE。

下篇预告

这篇先把《Spring Bean 初始化顺序那些事》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考