Spring Boot + MyBatis 启动阶段批量初始化偶发 ResultMap 找不到问题总结
一、问题背景
项目使用 Spring Boot + MyBatis。
系统启动时,需要初始化一批默认模板数据,具体流程是:
- 从数据库读取约 2000 条模板数据。
- 对每条模板数据进行业务字段修改。
- 再通过 MyBatis Mapper 逐条插入数据库。
- 该初始化任务是在 Bean 的
@PostConstruct中直接new Thread()后异步执行。
代码结构大致如下:
@PostConstruct
public void init() {
new Thread(() -> {
initTenantDefault();
}).start();
}
正常情况下,约 2000 条数据应该全部初始化完成。
但实际运行过程中出现了一个非常诡异的偶发问题:
- 前面几百条数据能够正常插入;
- 执行到后面某一条时突然失败;
- 当前实际执行的是
DevRoleDao.insertRole(); - 但异常却提示另外一个完全不同的
ITopoNodeDao的ResultMap找不到; ITopoNodeDao本身平时功能正常,Topo 相关功能也能够正常运行;- 问题不是每次启动都发生。
二、核心异常
典型异常:
org.mybatis.spring.MyBatisSystemException:
nested exception is
org.apache.ibatis.builder.IncompleteElementException:
Could not find result map
'com.mdcp.nm.topo.node.dao.ITopoNodeDao.topoNodeResult'
referenced from
'com.mdcp.nm.topo.node.dao.ITopoNodeDao.getTopoNodesByTopoViewId'
调用链的表面表现是:
DevRoleService.initTenantDefault()
↓
DevRoleService.addDevRole()
↓
DevRoleDao.insertRole()
↓
SqlSessionTemplate.insert()
↓
IncompleteElementException
↓
ITopoNodeDao.getTopoNodesByTopoViewId
↓
topoNodeResult 找不到
最令人困惑的地方:
明明正在执行
DevRoleDao.insertRole(),为什么会突然报ITopoNodeDao的 ResultMap 找不到?
三、第一阶段分析:先怀疑 Mapper XML 本身
看到:
Could not find result map
ITopoNodeDao.topoNodeResult
第一反应通常是检查:
ITopoNodeDao.xml的namespace是否正确;topoNodeResult的id是否正确;getTopoNodesByTopoViewId的resultMap引用是否正确;mapper-locations是否能够扫描到 XML;- 是否存在重复 XML;
- ResultMap 是否存在嵌套引用或继承关系。
例如正常结构应该类似:
<mapper namespace="com.mdcp.nm.topo.node.dao.ITopoNodeDao">
<resultMap id="topoNodeResult"
type="com.xxx.xxx.TopoNode">
...
</resultMap>
<select id="getTopoNodesByTopoViewId"
resultMap="topoNodeResult">
...
</select>
</mapper>
但是进一步结合实际现象后,发现:
如果
ITopoNodeDao.xml本身真的存在固定配置错误,那么这个问题通常应该稳定复现,而不是偶尔出现。
四、第二阶段分析:结合“非必现”重新定位
继续观察问题特征:
1. Topo DAO 平时正常
ITopoNodeDao 并不是一个一直无法工作的 Mapper。
Topo 功能在应用正常启动后可以正常运行。
因此:
topoNodeResult 永远不存在
这个判断不符合实际。
2. 问题具有明显的随机性
有时候:
2000 条全部成功
有时候:
前几百条成功
↓
突然失败
这种“同一套代码,有时正常、有时异常”的特征,更像:
时序问题 / Race Condition(竞态条件)
而不是简单的 XML 配置错误。
3. 当前操作 Mapper 与异常 Mapper 不一致
实际执行:
DevRoleDao.insertRole()
异常却指向:
ITopoNodeDao.getTopoNodesByTopoViewId()
这说明不能简单理解成:
DevRoleDao.insertRole()使用了ITopoNodeDao。
更合理的方向是考虑:
MyBatis 在执行当前操作的过程中,可能触发了其他尚未完整解析的 Mapper Statement 的处理。
五、关键突破:IncompleteElementException
异常中最重要的关键词不是:
ResultMap
而是:
IncompleteElementException
MyBatis 在解析 Mapper XML 时,如果某个 Statement 当时依赖的元素还没有准备好,会将其视为 Incomplete Element,后续再尝试解析。
因此:
ITopoNodeDao.getTopoNodesByTopoViewId
可能并不是一个“永远错误”的 Statement,而可能是在某个初始化时间点上:
Statement 尚未完成解析
↓
处于 incomplete 状态
↓
后续某次操作触发重新解析
↓
此时 topoNodeResult 尚未处于可用状态
↓
IncompleteElementException
这解释了为什么异常看起来与当前 DevRoleDao.insertRole() 毫无关系。
六、真正的关键:@PostConstruct + new Thread()
最终发现,初始化任务是在:
@PostConstruct
中直接创建子线程:
@PostConstruct
public void init() {
new Thread(() -> {
initTenantDefault();
}).start();
}
这里存在一个非常重要的生命周期问题:
@PostConstruct不代表整个 Spring Boot 应用已经完成启动。
@PostConstruct 只表示:
当前 Bean 自身完成依赖注入并进入初始化阶段。
此时整个:
ApplicationContext
Spring Bean
MyBatis
Mapper
Mapper XML
的初始化过程未必全部完成。
但 new Thread() 一旦启动:
@PostConstruct
↓
new Thread()
↓
子线程立即开始执行
它并不会等待:
Spring Boot ApplicationContext
完全初始化。
七、问题发生时的真实时序推演
正常情况下,希望的是:
Spring Boot 启动
↓
创建各种 Bean
↓
初始化 MyBatis
↓
加载 Mapper XML
↓
完成 Mapper 初始化
↓
ApplicationContext 完成启动
↓
开始业务初始化线程
↓
读取/修改/插入 2000 条模板数据
但实际可能变成:
Spring Boot 启动
↓
创建某个 Bean
↓
执行 @PostConstruct
↓
new Thread()
↓
========================
子线程开始初始化数据
========================
↓
DevRoleDao.insertRole()
↓
MyBatis 开始处理 Mapper
↓
与此同时主线程仍在继续 Spring/MyBatis 初始化
↓
某个 Mapper Statement 尚未完全解析
↓
触发 incomplete statement 的重新解析
↓
ITopoNodeDao.getTopoNodesByTopoViewId
↓
topoNodeResult 暂时无法找到
↓
IncompleteElementException
这就是一个典型的:
Spring 启动生命周期与自建业务线程之间的竞态。
八、为什么会出现“已经插入几百条才失败”?
这个现象并不矛盾。
假设:
第 1 条 √
第 2 条 √
第 3 条 √
...
第 300 条 √
第 301 条 ×
并不代表第 301 条数据有问题。
更可能是:
子线程:
第 1~300 条
↓
正常执行
与此同时:
Spring 主线程:
继续完成其他 Bean / MyBatis 初始化
↓
某个时刻触发 Mapper 解析
↓
出现时序冲突
所以真正的失败条件不是:
第 301 条数据
而是:
某一时刻,子线程与 Spring/MyBatis 初始化过程发生了竞争
因此每次失败的位置可能不同,甚至某些启动完全不会失败。
九、为什么 Topo DAO 本身可能没有问题?
这是这次问题中非常容易产生误判的地方。
异常:
Could not find result map
ITopoNodeDao.topoNodeResult
并不一定意味着:
ITopoNodeDao有 Bug。
它更可能意味着:
在异常发生的那个时间点,MyBatis 尝试处理
ITopoNodeDao.getTopoNodesByTopoViewId时,相关 ResultMap 尚未处于完整可用状态。
所以:
应用完全启动以后
↓
Topo Mapper 初始化完成
↓
topoNodeResult 正常存在
↓
Topo 功能完全正常
与:
启动过程中
↓
子线程过早开始访问 MyBatis
↓
某个 Mapper 处于 incomplete 状态
↓
偶发 ResultMap 异常
是可以同时成立的。
十、如何验证这个推断?
可以做一个非常简单的实验:
临时把线程启动延迟:
@PostConstruct
public void init() {
new Thread(() -> {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
initTenantDefault();
}).start();
}
如果原来:
偶发几百条后失败
加入延迟以后:
多次启动均正常
那么就进一步证明:
问题与启动时序高度相关。
注意:
sleep 只能作为定位问题的实验手段,不应该作为最终生产方案。
因为:
10 秒
只是经验值,并不能保证所有机器、容器、部署环境都能在这个时间内完成初始化。
十一、最终解决思路
核心原则:
不要在
@PostConstruct阶段直接启动依赖整个应用环境的业务线程。
应该等 Spring Boot 应用完成启动以后,再执行初始化任务。
例如使用:
@EventListener(ApplicationReadyEvent.class)
public void init() {
new Thread(() -> {
initTenantDefault();
}).start();
}
逻辑变成:
Spring Boot
↓
Bean 初始化
↓
MyBatis 初始化
↓
Mapper XML 初始化
↓
ApplicationContext 完成启动
↓
ApplicationReadyEvent
↓
启动初始化线程
↓
读取 2000 条模板
↓
修改
↓
插入
这样可以避免:
业务初始化线程
↕
Spring/MyBatis 初始化
之间发生竞争。
十二、最终问题定位
表面问题
DevRoleDao.insertRole()
执行过程中报:
ITopoNodeDao.topoNodeResult
找不到。
真正问题
不是:
DevRoleDao SQL 错误
也不是优先怀疑:
ITopoNodeDao XML 配置错误
而是:
在 Spring Boot 尚未完成整个 ApplicationContext / MyBatis Mapper 初始化时,通过
@PostConstruct直接创建子线程执行数据库初始化任务,导致业务线程与 MyBatis Mapper 初始化/解析过程发生时序竞争。
最终表现为:
偶发
+
批量执行到中途
+
当前 Mapper 与异常 Mapper 不一致
+
IncompleteElementException
+
ResultMap 找不到
十三、这次问题的经验沉淀
经验 1:@PostConstruct ≠ 应用启动完成
这是本次最重要的经验。
@PostConstruct
只代表当前 Bean 初始化,不代表:
整个 Spring Boot
整个 ApplicationContext
所有 Mapper
所有 Mapper XML
都已经完全准备好。
经验 2:不要在 @PostConstruct 中直接 new Thread() 执行复杂业务初始化
尤其是线程中涉及:
- MyBatis
- 数据库
- Redis
- 其他 Spring Bean
- 事务
- MQ
- RPC
- 缓存
- 定时任务依赖
等组件时,更应该注意启动生命周期。
经验 3:看到“非当前 Mapper 的 ResultMap 找不到”,不要立即修改那个 Mapper
如果同时满足:
① 非必现
② 当前 Mapper 与异常 Mapper 不一致
③ 应用正常启动后相关功能完全正常
④ 异常发生在启动阶段
⑤ 使用了异步线程
⑥ 异常包含 IncompleteElementException
应该优先考虑:
Mapper 初始化时序 / 并发 / 生命周期问题。
而不是直接认为:
“这个 Mapper XML 写错了。”
经验 4:偶发问题往往比稳定问题更应该考虑竞态
如果一个 XML 真有固定错误:
通常 → 每次都报
而:
有时成功
有时失败
失败位置还不固定
应该重点考虑:
Race Condition
初始化顺序
线程安全
资源生命周期
异步任务启动时机
经验 5:批量执行到一半失败,不一定是某一条数据的问题
看到:
2000 条
↓
300 条成功
↓
第 301 条失败
不能直接得出:
第 301 条数据有问题
还要考虑:
执行过程中是否有其他线程?
是否存在异步初始化?
是否存在共享资源?
是否存在事务/Session?
是否存在懒加载?
是否存在 Mapper 动态解析?
十四、一句话总结
本次问题的根因不是 Topo Mapper 本身异常,而是
@PostConstruct中直接new Thread(),导致批量数据库初始化线程过早启动,与 Spring/MyBatis 的启动初始化过程发生竞态;最终在某次 Mapper 操作过程中触发了尚未完整解析的 Mapper Statement,从而出现“当前操作 DevRoleDao,却报 ITopoNodeDao 的 ResultMap 找不到”的偶发IncompleteElementException。
最终解决原则
不要:
@PostConstruct → new Thread() → 直接执行依赖 MyBatis 的业务初始化
推荐:
ApplicationReadyEvent → 再启动异步初始化任务
这条经验以后遇到类似:
Spring Boot + MyBatis
+
启动初始化
+
异步线程
+
偶发 Mapper/ResultMap 异常
+
当前 Mapper 与异常 Mapper 不一致
可以作为第一批排查方向。
5万+



被折叠的 条评论
为什么被折叠?



