️ 周三下午3:27,测试小妹第5次敲我桌子

“哥!预发环境用户登录报‘系统配置缺失’!但本地跑得飞起啊!”
她手机怼到我眼前:
Caused by: NullPointerException: configMap is null

我心头一紧——这行代码我亲手写的:

@WebListener
public class SystemConfigLoader implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        Map<String, String> configMap = ConfigService.load(); // 从DB加载
        sce.getServletContext().setAttribute("GLOBAL_CONFIG", configMap); // ️关键!
    }
}

诡异现象
本地IDE启动:configMap有值,登录正常
预发环境:configMap为null,但DB查询日志显示“加载成功”
重启10次:7次失败,3次成功(玄学?)

我盯着天花板:“这代码…它自己会隐身?”


三小时硬核溯源(附真实排查流水)

第一回合:甩锅部署脚本?

# 对比本地与预发的war包
diff -r local/WEB-INF/classes prod/WEB-INF/classes | grep SystemConfigLoader
# 结果:字节码完全一致!

运维兄弟摊手:“部署流程没动过,就你昨天提的代码。”

第二回合:日志埋点现原形

在Listener加关键日志:

@Override
public void contextInitialized(ServletContextEvent sce) {
    log.info("【STEP1】开始加载配置...");
    Map<String, String> configMap = ConfigService.load();
    log.info("【STEP2】加载结果: {}", configMap != null ? configMap.size() : "NULL");
    sce.getServletContext().setAttribute("GLOBAL_CONFIG", configMap);
    log.info("【STEP3】设置ServletContext属性完成");
}

预发日志暴击

2024-06-19 15:41:02 [INFO] 【STEP1】开始加载配置...
2024-06-19 15:41:03 [INFO] 【STEP2】加载结果: 42
2024-06-19 15:41:03 [INFO] 【STEP3】设置ServletContext属性完成
2024-06-19 15:41:05 [ERROR] NullPointerException: configMap is null  登录时取不到!

灵魂疑问
Listener明明执行了,为什么后续取不到?
为什么有时能取到(重启成功的3次)?

第三回合:Arthas监控ServletContext

# 监控登录时获取的configMap
watch com.xxx.controller.LoginController login '{params[0].getServletContext().getAttribute("GLOBAL_CONFIG")}' -x 3

关键发现

  • 成功时:返回HashMap@7a8f...
  • 失败时:返回 null
  • 但Listener日志显示“设置完成”!

我猛拍大腿:“有另一个Listener把属性覆盖了! "


源码扒皮:Tomcat如何执行Listener链?

翻Tomcat 9.0.85源码(ContextConfig.java)

// 第1872行:按web.xml声明顺序构建Listener链
for (int i = 0; i < listeners.length; i++) {
    applicationListeners.add(listeners[i]); // 顺序添加
}
// 第1905行:执行时倒序遍历!
for (int i = applicationListeners.size() - 1; i >= 0; i--) {
    lifecycle.fireLifecycleEvent(Lifecycle.START_EVENT, null); // 逆序触发!
}

真相核爆
1️⃣ Listener执行顺序 = web.xml声明顺序的逆序
2️⃣ 项目中存在两个关键Listener:

<!-- web.xml -->
<listener>
    <listener-class>com.xxx.SystemConfigLoader</listener-class> <!-- 我的 -->
</listener>
<listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> <!-- Spring的 -->
</listener>

3️⃣ 执行时序

Tomcat启动 → 先执行ContextLoaderListener(后声明) → 初始化Spring容器  
            → 再执行SystemConfigLoader(先声明) → 设置GLOBAL_CONFIG  
            → 但!ContextLoaderListener内部会清空ServletContext属性!(见下文)

深扒ContextLoaderListener(Spring 5.3.28)

// ContextLoader.java 第288行
public WebApplicationContext initWebApplicationContext(...) {
    // ️ 关键操作:清理旧上下文(包含所有setAttribute设置的属性!)
    if (this.contextAttribute != null) {
        servletContext.removeAttribute(this.contextAttribute);
    }
    // ... 初始化新Spring上下文
}

事故链路还原

SystemConfigLoader执行 → setAttribute("GLOBAL_CONFIG", map)  
↓  
ContextLoaderListener执行 → removeAttribute清理旧上下文 → GLOBAL_CONFIG被删!  
↓  
登录时取属性 → null → NPE!  

为什么有时成功

  • 本地IDE启动:Spring插件加载顺序随机,偶尔SystemConfigLoader后执行
  • 预发环境:Tomcat严格按web.xml逆序执行,100%覆盖

️ 三招根治(已上线2周零故障)

方案1:调整web.xml声明顺序(最快)

<!-- 把Spring Listener放前面! -->
<listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<listener>
    <listener-class>com.xxx.SystemConfigLoader</listener-class>
</listener>

原理:逆序执行 → Spring先初始化 → 我的Listener后设置属性 → 不被覆盖

方案2:改用Spring生命周期(推荐!)

@Component
public class SpringConfigLoader implements ApplicationListener<ContextRefreshedEvent> {
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        // 此时Spring容器已就绪,且不会被清理
        Map<String, String> configMap = ConfigService.load();
        event.getApplicationContext()
             .getBean(ServletContext.class)
             .setAttribute("GLOBAL_CONFIG", configMap);
    }
}

优势

  • 与Spring生命周期深度绑定
  • 避免Servlet API与Spring混用风险
  • 支持@Order控制执行顺序

方案3:Listener内延迟设置(应急)

@WebListener
public class SafeConfigLoader implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        // 延迟1秒,等Spring初始化完成(不推荐!仅应急)
        Executors.newSingleThreadScheduledExecutor().schedule(() -> {
            sce.getServletContext().setAttribute("GLOBAL_CONFIG", ConfigService.load());
        }, 1, TimeUnit.SECONDS);
    }
}

️ 注意:仅限临时方案!存在竞态风险


血泪总结:Listener使用红绿灯

表格

场景推荐方案避坑口诀
需要Spring Bean用ApplicationListener“Listener里别碰Bean,Spring事件最稳妥”
纯Servlet环境严格按web.xml逆序声明“先声明的后执行,画个流程图再上线”
多Listener协作加执行日志+Arthas验证“眼见为实,别猜顺序”
Spring Boot项目彻底弃用@WebListener“用@PostConstruct/@EventListener”

灵魂三问(上线前必答)
1️⃣ 我的Listener依赖其他框架初始化吗?→ 依赖?改用框架生命周期!
2️⃣ web.xml里Listener声明顺序画过执行流程图吗?→ 没画?现在就画!
3️⃣ 本地和服务器环境加载顺序一致吗?→ 不一致?用Arthas实测!


写在复盘会后

组长把“Listener执行顺序检查”加进上线Checklist。
测试小妹递来手写卡片:“哥,这次排查笔记能当团队教材吗?”
我笑着贴到工位白板上:

技术成长,藏在每一次“为什么本地能跑线上崩”的追问里。
那些翻源码熬红的眼,终会变成代码里稳稳的底气。

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com