一次空指针问题引发的 Spring 生命周期思考
背景介绍
我们团队负责的是系统的核心组件——内容安全审核系统。该系统对接了公司内部众多的业务方(如视频、评论、账号、私信等),其核心交互架构基于 RocketMQ 构建,采用典型的异步管道模式:
进审阶段:业务方将待审核的数据发送到其对应的 RocketMQ 进审 Topic。
审核阶段:我们的审核系统根据自身处理能力,消费这些进审消息,进行机审、人审、AI 文本/图像识别等一系列安全检测。
出审阶段:检测完成后,系统会根据预先配置好的映射关系,将审核结果发送到业务方指定的出审 Topic(或通过 Webhook 下发)。同时,为了进行审计和排查,审核记录会通过一个专用的异步 MQ 发送到 Elasticsearch(ES)进行存储。
其简化架构如下:

诡异的线上异常
在一次服务升级重启发布后,我们收到业务方的反馈:部分内容已经生产成功了,但一直没有收到审核结果。
我们随即展开排查。诡异的是:
我们在后台的 ES 审计日志中,能够明确查到这几条内容的审核记录。这说明系统确实正常消费了这几条消息,并成功通过异步 MQ 写入了 ES。
但这些业务确实没有收到出审结果。
而且这种现象是偶现的。项目刚刚启动时会出现,运行一段时间之后,所有业务的下发就完全恢复正常了。
顺着下发逻辑,我们排查到了发送结果的代码:
复制代码public static boolean sendProduce(Producer producer, Object message, String key, int count) {if (Objects.isNull(producer)) {// 线上正是卡在了这里!logger.info("message " + message + "key " + key + "sendProduce producer is null,return.");throw new RuntimeException("sendProduce err.producer is null.");}if (Objects.isNull(message) || StringUtils.isBlank(key)) {logger.info("message=" + message + ",key=" + key + " is null,return.");return true;}try {Result<SendResult> res = producer.publish(message, key);if (res != null && res.isSuccess) {logger.info("produce message:" + GsonUtil.toGson(message) + ",success:" + GsonUtil.toGson(res));return true;} else {if (count <= 3) {logger.info(count + " try produce message: " + GsonUtil.toGson(message));count++;sendProduce(producer, message, key, count);}logger.info("produce error message: " + GsonUtil.toGson(message));throw new RuntimeException("sendProduce retry 3,fail! group: " + producer.getGroup() + " ,topic: " + producer.getTopic());}} catch (Exception e) {logger.error("send Produce method message: " + GsonUtil.toGson(message) + ", error: " + e.getMessage(), e);throw new RuntimeException(e);}}
摆在面前的两个“不合常理”
面对这个空指针异常,结合我们当时的系统认知,脑海中冒出了两个极度矛盾的疑问:
疑问一:为什么 ES 审计 MQ 正常,而业务出审 MQ 却报空指针?
如果是 ProducerManager 这个管理类在注入时存在时序问题,那为什么同样由它管理的、往 ES 写审计日志的 esTransProducer 可以正常工作,唯独下发给业务结果的 Producer 是 null?
当时 ProducerManager 的定义中,ES 生产者是这样声明的:
复制代码@Bean(initMethod = "start", destroyMethod = "shutdown", name = "esTransProducer")public Producer esTransProducer() {return new Producer(esTransProducerStr, esTransTopic);}
疑问二:为什么不是全量失败,而是部分业务失败、偶现失败?
按照我们当时的直觉:一个组件要么没加载完(大家都不能用),要么加载完了(大家都能用)。
如果是 Spring 创建 Bean 的顺序或者类加载的时机出了问题,不应该“众生平等”全部报空指针吗?为什么偏偏是有些业务的 Producer 已经可用,而有些业务的 Producer 却拿到了 null?
带着这两个巨大的问号,我们不得不按图索骥,重新审视团队对于Spring 生命周期以及代码初始化时序的理解。
快速止血:首要解决生产环境问题
我们服务在不断迭代,不断升级,不能每次升级都出现类似不下发审核结果的情况,当务之急是快速止血。
回到 ProducerManager 和 ConsumerManager 的代码,我们发现它们都实现了 ApplicationRunner 接口。由于当时没有配置任何 @Order 注解,Spring Boot 启动时,这两个 Runner 的执行顺序是不确定的。
顺着这个线索,我们做出了第一个推断:加载顺序问题。
如果 ConsumerManager 先执行,消费者便会立刻启动并开始拉取 MQ 消息;而此时 ProducerManager 的 run() 方法可能还没执行(或者还没执行完),导致动态创建 Producer 的 Map 依然为空。此时一旦有消息进来,必然会触发 producer is null 的异常。
为了验证这个推断,我们紧急引入了 @Order 注解进行时序控制:
复制代码// 1. 让 ProducerManager 优先执行,先初始化所有的发送端@Component@Order(10)public class ProducerManager implements ApplicationRunner { ... }// 2. 让 ConsumerManager 最后执行,等发送端全部 Ready 后,再开启消费者@Component@Order(Ordered.LOWEST_PRECEDENCE)public class ConsumerManager implements ApplicationRunner { ... }
上线之后,线上的空指针报错彻底消失了,服务启动非常平稳。
当时我们松了一口气,以为完美解决了问题。但当生产环境稳定下来,我们冷静下来重新审视代码和 Spring 源码时,才惊觉:这次看似成功的修复,实际上只是一次“误打误撞”的妥协,背后还隐藏着更大的设计漏洞。
深入源码:戳破 @Order 的温情幻觉
上线后一段时间,线上问题确实消失了。但随着我们重新阅读 Spring 源码和梳理整个启动流程,一个新的疑问出现了:
@Order 究竟控制的是什么?它真的解决了 ProducerManager 的初始化问题吗?复制代码要回答这个问题,首先需要弄清楚 Spring Boot 在启动过程中各个生命周期阶段的执行顺序。
复制代码Spring 启动│▼实例化 Bean(new)│▼依赖注入(@Resource)│▼@PostConstruct / InitializingBean│▼initMethod(例如 @Bean(initMethod="start"))│▼Bean 初始化完成(Ready)──────────────────────────────────────────所有 Bean 初始化完成│▼ApplicationContext Refresh 完成│▼ApplicationRunner(按 @Order 顺序执行)│▼应用进入运
这张图里有两个非常容易混淆、但又至关重要的概念:
Bean 生命周期:负责对象的创建、依赖注入以及初始化(例如 @PostConstruct、initMethod)。
应用启动生命周期:负责整个应用启动后的逻辑,例如 ApplicationRunner、CommandLineRunner 等。
@Order 控制的,仅仅是多个 ApplicationRunner 之间的执行顺序,它并不会影响 Bean 的创建、注入和初始化顺序。
理解了这一点,前面留下的两个疑问其实就可以解释清楚了。
疑问一的真相:为什么 ES 审计正常,而业务出审报错?
回看代码,ES 的 esTransProducer 是通过标准的 Spring @Bean 声明的,并指定了 initMethod = "start":
复制代码@Bean(initMethod = "start", destroyMethod = "shutdown", name = "esTransProducer")public Producer esTransProducer() {return new Producer(esTransProducerStr, esTransTopic);}
Spring 会在 Bean 初始化过程中调用 initMethod 指定的 start() 方法。因此,在 ApplicationRunner 开始执行之前,esTransProducer 就已经完成了实例化、初始化以及网络连接建立,可以直接对外使用。
而业务的 Producer 是怎么创建的?它们是在 ProducerManager 的 init() 方法中,通过查询数据库动态 new 出来并塞入 Map 的。
复制代码private void init() {List<MBusiness> businessList = businessDao.listValid();if (CollectionUtils.isEmpty(businessList)) {return;}for (MBusiness business : businessList) {if (StringUtils.isNotBlank(business.getInTopic()) && StringUtils.isBlank(business.getOutTopic())) {producerExclusionMap.put(business.getType(), 1);}if (StringUtils.isNotBlank(business.getBanwordType()) && !banwordTypeMap.containsKey(business.getType())) {banwordTypeMap.put(business.getType(), business.getBanwordType());}if (business.getPassFirst() != null && !passFirstTypeMap.containsKey(business.getType())) {passFirstTypeMap.put(business.getType(), business.getPassFirst());}if (business.getAiType() != null && !aiTypeMap.containsKey(business.getType())) {aiTypeMap.put(business.getType(), business.getAiType());}if (StringUtils.isNotBlank(business.getOutWebhook()) && !webhookMap.containsKey(business.getType())) {webhookMap.put(business.getType(), business.getOutWebhook());}if (producerMap.containsKey(business.getOutTopic())) {if (!producerTypeMap.containsKey(business.getType())) {producerTypeMap.put(business.getType(), producerMap.get(business.getOutTopic()));log.info("business type:{} topic:{} group:{} init producer.", business.getType(), business.getOutTopic(), business.getOutGroup());}continue;}Producer producer = initProducer(business);if (producer == null) {continue;}producer.start();producerMap.put(business.getOutTopic(), producer);producerTypeMap.put(business.getType(), producer);log.info("business type:{} topic:{} group:{} init producer.", business.getType(), business.getOutTopic(), business.getOutGroup());}}
这就导致了本质的区别:
ES Producer:在 Spring 的“依赖注入与初始化”阶段就已经彻底 Ready 了。
业务 Producer:并不是由 Spring 在 Bean 初始化阶段创建,而是在 Spring Context Refresh 完成之后,由 ApplicationRunner 主动创建并放入 Map 中。
因此,当消费者在项目刚启动、Runner 还未执行时就拿到消息,ES 链路是通的,而业务下发链路必然因为 Map 为空而报空指针。
疑问二的真相:为什么是部分业务偶现失败,而不是全量失败?
这就不得不提到多线程和 RocketMQ 消息拉取的时序竞争。
ConsumerManager.run() 调用了 consumer.start() 并返回后,RocketMQ 客户端会在后台线程开始拉取消息;与此同时,Spring 开始执行下一个 Runner——ProducerManager.run()。此时后台消费线程与 Producer 初始化线程发生了真正的时序竞争。
这是一个典型的多线程时序竞争(Race Condition)场景:
复制代码时刻 T1: ProducerManager 正在加载业务配置(目前只加载了业务 1 和 2)时刻 T2: 消费者拉取到了【业务 2】的消息 ──► 调用 getProducer("业务 2") ──► 成功拿到实例时刻 T3: 消费者拉取到了【业务 3】的消息 ──► 调用 getProducer("业务 3") ──► 此时 Map 里还没有,报空指针!
这就是为什么问题呈现出“偶现、部分业务失败”的特征。这完全取决于那条消息到达的精准毫秒数,以及当时 init() 循环执行到了哪一步。
更大的隐患:在非 Runner 阶段的依赖危机
通过 @Order 限制 Runner 的顺序,确实让 ConsumerManager 避开了这个时序陷阱。但这属于“治标不治本”。因为:
producerManager 已经注入成功,绝不代表它内部的 Map 已经处理完成。复制代码@Order 只能保护那些同样在 ApplicationRunner 阶段执行的后续代码。一旦项目引入了在非 Runner 阶段(如 Spring Bean 实例化阶段)对 ProducerManager 的依赖,这颗“定时炸弹”就会被瞬间引爆。
复制代码@Configurationpublic class BusinessConfigService {@Resourceprivate ProducerManager producerManager;@PostConstructpublic void init() {Producer producer = producerManager.getProducer("text-sohu-forward");if (producer == null) {throw new RuntimeException("初始化失败,找不到对应的 Producer!");}}}
启动之后,直接崩溃:
复制代码Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled.2026-07-21 11:45:31.179 ERROR 18984 --- [ main] o.s.boot.SpringApplication : Application run failedorg.springframework.beans.factory.BeanCreationException: Error creating bean with name 'businessConfigService': Invocation of init method failed; nested exception is java.lang.RuntimeException: 初始化失败,找不到对应的 Producer!at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor.postProcessBeforeInitialization(InitDestroyAnnotationBeanPostProcessor.java:160)at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyBeanPostProcessorsBeforeInitialization(AbstractAutowireCapableBeanFactory.java:440)at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1796)at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:620)at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:542)at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:335)at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:333)at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:208)at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:955)at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:920)at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:583)at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:145)at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:745)at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:423)at org.springframework.boot.SpringApplication.run(SpringApplication.java:307)at com.sohu.tv.ugc.monitor.EnterTestApp.main(EnterTestApp.java:28)Caused by: java.lang.RuntimeException: 初始化失败,找不到对应的 Producer!at com.sohu.tv.ugc.monitor.service.BusinessConfigService.init(BusinessConfigService.java:19)at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)at java.base/java.lang.reflect.Method.invoke(Method.java:566)at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleElement.invoke(InitDestroyAnnotationBeanPostProcessor.java:389)at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleMetadata.invokeInitMethods(InitDestroyAnnotationBeanPostProcessor.java:333)at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor.postProcessBeforeInitialization(InitDestroyAnnotationBeanPostProcessor.java:157)... 16 common frames omitted
因为 Spring 生命周期的严格顺序是:
所有 Bean 的 @PostConstruct → ApplicationContext refresh → 执行 ApplicationRunner (包括我们加了 @Order 的 ProducerManager.run())复制代码所有 Bean 的 @PostConstruct→ApplicationContext refresh→执行 ApplicationRunner (包括我们加了 @Order 的 ProducerManager.run()) 当 BusinessConfigService 执行 @PostConstruct 时,所有的 ApplicationRunner 连影子都还没看到!此时,无论你给 ProducerManager 加了 @Order(1) 还是 @Order(10),它内部的 producerTypeMap 此时都必定是空的({})。
这意味着,我们的 @Order 方案,仅仅是建立在“假定所有依赖方都在 Runner 阶段运行”这层脆弱契约之上的。它并没有真正解决 ProducerManager 自身的设计缺陷。
架构重构:职责分离的最终方案
为了从根本上消除隐患,我们必须对 ProducerManager 进行重构,核心思想是:将“内存对象组装”与“网络 IO 激活”彻底剥离。
第一阶段:内存装配(安全期)
在 @PostConstruct 阶段,查询数据库,实例化所有的 Producer 对象并塞入 Map。此时绝对不调用 producer.start()。这确保了在任何 Bean 注入 ProducerManager 后,调用 getProducer() 都能立刻拿到一个非空(not-null)的实例引用。
第二阶段:服务激活(运行期)
在 ApplicationRunner 阶段,遍历 Map 中已经组装好的 Producer 实例,批量调用 .start() 建立 TCP 网络连接。
重构后的 ProducerManager 核心代码:
复制代码@Slf4j@Component@Order(10) // 依然保持最先激活 IOpublic class ProducerManager implements ApplicationRunner, DisposableBean {// 内存中的对象映射表private Map<String, Producer> producerMap = Collections.synchronizedMap(new HashMap<>());private Map<String, Producer> producerTypeMap = Collections.synchronizedMap(new HashMap<>());private Map<String, Producer> producerMqTypeMap = Collections.synchronizedMap(new HashMap<>());@Resourceprivate MBusinessDao businessDao;/*** 【第一阶段】内存装配:在依赖注入期执行* 保证该 Bean 一旦被注入到其他类,其内部的 Map 结构就已经完全就绪(可用)*/@PostConstructpublic void buildDependencies() {log.info("【Phase 1】开始加载业务配置并构建 Producer 实例映射(内存装配)...");// 1. 同步加载业务配置,创建实例并塞入 Map(暂不 start)List<MBusiness> businessList = businessDao.listValid();if (CollectionUtils.isNotEmpty(businessList)) {for (MBusiness business : businessList) {if (StringUtils.isNotBlank(business.getOutGroup()) && StringUtils.isNotBlank(business.getOutTopic())) {Producer producer = new Producer(business.getOutGroup(), business.getOutTopic());producerMap.put(business.getOutTopic(), producer);producerTypeMap.put(business.getType(), producer);}}}log.info("【Phase 1】内存映射构建完成。");}/*** 【第二阶段】激活服务:在容器完全就绪后执行* 此时集中处理网络 IO,建立物理连接*/@Overridepublic void run(ApplicationArguments args) throws Exception {log.info("【Phase 2】开始连接网络并激活服务(Producer Start)...");// 1. 激活业务生产者连接for (Map.Entry<String, Producer> entry : producerMap.entrySet()) {try {entry.getValue().start(); // 耗时网络 IOlog.info("Topic:{} 对应的 Producer 激活成功。", entry.getKey());} catch (Exception e) {log.error("Topic:{} 对应的 Producer 激活失败!", entry.getKey(), e);}}// 3. 开启定时动态刷新任务flush();log.info("【Phase 2】所有本地 Producer 物理激活完成。");}}
演进后的系统启动时序
重构后,系统启动的时序流转变得非常清晰和安全:
复制代码Spring 启动加载│▼[实例化 ProducerManager]│执行 @PostConstruct (内存装配)│ ├── 1. 查询数据库 valid 配置│ └── 2. 创建 Producer 对象并装入 Map▼[实例化 BusinessConfigService]│执行 @PostConstruct│ └── 这一步调用 producerManager.getProducer() -> 拿到非空实例 (安全!)▼[所有 Bean 初始化就绪]│Spring 上下文 Refresh 完成,启动 ApplicationRunner│├─► [1. 执行 ProducerManager.run() (@Order(10))]│ └── 遍历 Map,批量调用 producer.start() 激活连接 (物理就绪)│└─► [2. 执行 ConsumerManager.run() (@Order(Max))]└── 启动消费端开始拉取消息 -> 即使第一条消息立刻到达,调用发送端也是安全的
避坑指南:Spring 生命周期研发规约
通过这次线上故障的演进,我们整理出一份针对系统启动与生命周期管理的通用研发规约:

结语
技术上的很多“诡异问题”,往往只是因为我们对工具底层的生命周期和协作契约缺乏敬畏。
在这次解决 Producer is null 的过程中,我们从最开始的“加 Order 误打误撞解决问题”,到冷静下来的“戳破温情幻觉”,再到最终的“职责分离架构重构”。我们不仅修复了一个线上的偶现 Bug,更理清了 Spring IOC 设计中关于“Bean 装配阶段”与“组件运行阶段”的生命周期哲学。
Spring 管理的是 Bean 的生命周期,而不是业务组件的生命周期。Bean 是否已经可用,与它是否已经开始对外提供服务,是两个不同维度的问题。真正健壮的设计,应该让 Bean 在初始化阶段完成内部状态构建,在应用启动阶段再逐步激活外部依赖。