写点什么

一次空指针问题引发的 Spring 生命周期思考

  • 2026-09-02
    北京
  • 本文字数:9780 字

    阅读完需:约 32 分钟

AI摘要

核心内容:内容安全审核系统在 RocketMQ 异步管道架构下出现偶发性出审结果丢失,ES 审计日志完整但业务方未收到结果,问题在服务重启初期高频出现、随运行时间推移自愈。

关键观点:出审 Topic 消息发送失败但无异常日志,暴露 RocketMQ 生产者端重试机制配置缺陷;ES 日志存在而业务无响应,说明出审链路与审计链路解耦但监控未对齐;偶发性自愈现象指向客户端连接池或 NameServer 路由缓存初始化延迟。

适合后端架构师、中间件工程师、SRE 阅读

背景介绍

我们团队负责的是系统的核心组件——内容安全审核系统。该系统对接了公司内部众多的业务方(如视频、评论、账号、私信等),其核心交互架构基于 RocketMQ 构建,采用典型的异步管道模式:

  1. 进审阶段:业务方将待审核的数据发送到其对应的 RocketMQ 进审 Topic。

  2. 审核阶段:我们的审核系统根据自身处理能力,消费这些进审消息,进行机审、人审、AI 文本/图像识别等一系列安全检测。

  3. 出审阶段:检测完成后,系统会根据预先配置好的映射关系,将审核结果发送到业务方指定的出审 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 {    @Resource    private ProducerManager producerManager;    @PostConstruct    public 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<>());    @Resource    private MBusinessDao businessDao;    /**     * 【第一阶段】内存装配:在依赖注入期执行     * 保证该 Bean 一旦被注入到其他类,其内部的 Map 结构就已经完全就绪(可用)     */    @PostConstruct    public 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,建立物理连接     */    @Override    public 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(); // 耗时网络 IO                log.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 在初始化阶段完成内部状态构建,在应用启动阶段再逐步激活外部依赖。