汉字魔法师
118.67M · 2026-02-04
“哥!预发环境用户登录报‘系统配置缺失’!但本地跑得飞起啊!”
她手机怼到我眼前:
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次)?
# 监控登录时获取的configMap
watch com.xxx.controller.LoginController login '{params[0].getServletContext().getAttribute("GLOBAL_CONFIG")}' -x 3
关键发现:
我猛拍大腿:“有另一个Listener把属性覆盖了! "
// 第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属性!(见下文)
// 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!
为什么有时成功?
<!-- 把Spring Listener放前面! -->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<listener>
<listener-class>com.xxx.SystemConfigLoader</listener-class>
</listener>
原理:逆序执行 → Spring先初始化 → 我的Listener后设置属性 → 不被覆盖
@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);
}
}
优势:
@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);
}
}
️ 注意:仅限临时方案!存在竞态风险
表格
| 场景 | 推荐方案 | 避坑口诀 |
|---|---|---|
| 需要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。
测试小妹递来手写卡片:“哥,这次排查笔记能当团队教材吗?”
我笑着贴到工位白板上:
技术成长,藏在每一次“为什么本地能跑线上崩”的追问里。
那些翻源码熬红的眼,终会变成代码里稳稳的底气。