MyBatis 启动的时候都在干什么:从 MappedStatement 说起
天天写 CRUD,但大部分人没看过 MyBatis 启动那几秒在忙什么。
面试题都会背:SqlSessionFactoryBuilder → SqlSessionFactory → SqlSession。这条链谁都能念出来,但真正值钱的东西在链的终点:一个叫 Configuration 的对象,和它肚子里成百上千个 MappedStatement。
这篇把启动期从头到尾走一遍。MyBatis 为什么快、几个经典报错是怎么回事、#{} 和 ${} 的区别在哪,看完就都清楚了。
一、先给结论:启动期是一次"预编译"
MyBatis 启动时把所有 XML 解析成一个个
MappedStatement,塞进一个Configuration对象;运行期只是按名字查表,然后执行。
text
mybatis-config.xml ─┐
UserMapper.xml ─┤ ┌────────────────────────┐
DeptMapper.xml ─┼─ 解析 ────────▶ │ Configuration │
OrderMapper.xml ─┘ │ ├ mappedStatements │ ← 每条 SQL 一个
(启动期,只做一次)│ ├ resultMaps │
│ ├ sqlFragments │
│ ├ caches / keyGenerators│
│ ├ interceptors(插件链) │
│ └ mapperRegistry(接口) │
└────────────────────────┘
这个设计决定了两件事:
- XML 只在启动期读一次。运行期没有解析 XML 这回事,
getSql的开销是一次 Map 查找。 - SQL 在启动期就"定型"了。一条
<select>对应一个MappedStatement,里面装着这条语句的全部信息:SQL 源、参数类型、返回映射、主键策略。
理解了这两点,后面全是细节。
二、三步走:从 XML 到 Configuration
第一步:XMLConfigBuilder 解析主配置
入口就是面试题那句:
java
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(inputStream);
build() 里 new XMLConfigBuilder(...) 然后 parse(),parse() 的主体是 parseConfiguration(),按固定顺序消化 mybatis-config.xml 的每个节点:
java
propertiesElement(...) // properties
settingsElement(...) // settings(mapUnderscoreToCamelCase 在这里生效)
typeAliasesElement(...) // typeAliases
pluginElement(...) // plugins ------ 插件在这里就排好了
environments... // 数据源、事务工厂
typeHandlerElement(...) // typeHandlers
mapperElement(...) // mappers ------ 正文开始
两个容易忽略的点:
- 插件(plugins)在启动期就装配完了。解析到
<plugin>时,拦截器实例被创建并包到InterceptorChain里,等后面创建 Executor 等对象时统一包代理。运行期你看到的 Executor,早就穿着好几层代理了。 - 数据库连接此时还没建立。environments 只是解析了数据源配置。所以启动期的报错和数据库无关:XML 写错、resultMap 引用不存在,连接池碰都没碰。
第二步:XMLMapperBuilder 解析每个 mapper.xml
mapperElement() 对每个 mapper 走 XMLMapperBuilder.parse(),依次处理:namespace → 二级缓存引用 → <resultMap> → <sql> 片段 → 最后是重头戏,每个 <select> / <insert> / <update> / <delete> 走 buildStatementFromContext() → XMLStatementBuilder.parseStatementNode()。
这一步把标签上二十来个属性逐个拆下来:parameterType、resultMap / resultType、keyGenerator、timeout、fetchSize......然后到整个启动期最关键的一行:
java
SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
第三步:SQL 怎么存?先拆树,再分拣
createSqlSource 里的 XMLScriptBuilder 做的事,可以概括成"先拆树,再分拣":
拆树:把 SQL 文本拆成一棵节点树。纯文本变 TextSqlNode,<if> 变 IfSqlNode,<where> 变 WhereSqlNode,<foreach> 变 ForEachSqlNode......整个动态 SQL 就是一棵组合树,运行时对参数求值,按需拼接。
分拣(parseDynamicTags 的判定):
java
// 伪代码,抹掉了细节
if (节点树里有动态标签 || SQL 文本里有 "${") {
return new DynamicSqlSource(节点树); // 运行期才拼出最终 SQL
} else {
return new RawSqlSource(SQL 文本); // 启动期就把 #{} 换成 ?
}
这里藏着一个面试题的标准答案。为什么 #{} 防注入、${} 不防?因为:
#{}在启动期就被替换成?占位符,值走PreparedStatement的参数绑定------预编译,类型安全;${}被判定为"动态"内容,留到运行期做字符串替换,值直接拼进 SQL。
同一份 mapper.xml 里,两种占位符从这一刻起走的就不是一条路了。
分拣完,MapperBuilderAssistant.addMappedStatement() 把所有东西打包:id 取 namespace + "." + 标签 id,塞进 Configuration.mappedStatements,一个对重复 key 极其敏感的 StrictMap。
三、MappedStatement:MyBatis 的原子
为什么标题要"从 MappedStatement 说起"?因为这个对象是 MyBatis 的最小完整单元。一条 MappedStatement 里装着:
text
id com.example.mapper.UserMapper.selectById ← 全局唯一标识
sqlSource DynamicSqlSource / RawSqlSource ← SQL 的静态结构
commandType SELECT / INSERT / UPDATE / DELETE
resultMaps 结果怎么映射回对象
keyGenerator 主键怎么生成(useGeneratedKeys / selectKey)
timeout、fetchSize、......
说它是"原子",是因为运行期的一切都围绕它发生:
java
User user = userMapper.selectById(1L);
这行代码的完整旅程是:MapperProxy(动态代理)→ MapperMethod → 拼出 "com.example.mapper.UserMapper.selectById" 这个字符串 → configuration.getMappedStatement(id) 查表 → 拿到 MappedStatement → 取出 BoundSql → 交给 Executor 执行。
说到底,MyBatis 的接口调用就是一次以字符串为 key 的查表。
这也解释了那个经典报错:
text
Mapped Statements collection already contains value for
com.example.mapper.UserMapper.selectById
StrictMap 不允许重复 key。两个 mapper 的 namespace + id 撞了、同一个 XML 被加载了两遍、接口方法名和 XML 标签 id 对不上,都在启动期这一步当场爆炸,不用等运行。
四、接口没有实现类,为什么能跑
启动期还有最后一颗种子:XMLMapperBuilder.parse() 的收尾动作 bindMapperForNamespace()。
它检查 XML 的 namespace 是否对应一个真实存在的接口,是的话把这个接口注册进 MapperRegistry。之后:
java
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
getMapper 走的是 JDK 动态代理:MapperProxyFactory → MapperProxy。调用接口方法时,代理按"接口全限定名 + 方法名"拼 key 去 mappedStatements 查表。
所以启动期其实做了两件事:语句入库(XML 侧),接口注册(Java 侧)。运行期靠"名字"这个字符串把两边接上。
这个设计省事,但也埋了雷。省事在零依赖、纯约定;雷在那根线是字符串:方法改名了 XML 没跟上,要到运行期才会蹦出一个 BindingException。XML 写错这类问题反而好排查,启动期当场就炸;真正磨人的,恰恰是启动期查不出来的那种。
五、运行期:查表,不是解析
把完整流程画出来:
text
openSession()
│ newExecutor() ------ 插件代理链在这一刻包上
▼
getMapper(UserMapper.class)
│ JDK 动态代理
▼
mapper.selectById(1L)
│ 拼 key:"接口全限定名.方法名"
▼
configuration.getMappedStatement(key) ← Map 查找,O(1)
▼
mappedStatement.getBoundSql(param) ← 动态 SQL 在这一刻才展开
▼
Executor → StatementHandler → JDBC
注意 getBoundSql 这一步:RawSqlSource 的语句,启动期就定型了,运行期近乎零成本;DynamicSqlSource 的语句,要在这一刻对参数求值、遍历节点树拼出 SQL。
MyBatis 快,就快在这:重的活全压进了启动期,运行期只剩查表和 JDBC。网上说"MyBatis 裸调用比很多 ORM 快",没什么黑科技,就是这笔时间账算得好。
六、三个日常现象,回头看都有了着落
-
为什么 mapper 写错了,报错在启动而不是第一次调用?语句注册发生在启动期,id 重复、resultMap 引用不存在、XML 格式错误,全是启动期异常。反过来,方法名和 XML id 不一致启动期查不出来,注册的两条线要到运行期才碰头。
-
为什么
${}永远是注入风险的源头?它在启动期被归入动态分支,注定走运行期字符串替换。哪怕你的参数是内部传参不是用户输入,这条边界也值得刻在脑子里:${}是 SQL 的一部分,#{}是参数。 -
为什么插件能拦截四大对象?
pluginElement在启动期就建好了InterceptorChain,newExecutor/newStatementHandler/newParameterHandler/newResultSetHandler每次创建都会过一遍代理链。分页插件能改 SQL、慢 SQL 插件能偷看语句,都是在这条链上做文章。这个机制值得单独写一篇,先挖个坑。
七、另一种思路
看完启动期,我长期有一个观察:
MyBatis 起得那么早,配置、XML、注解全部解析完,MappedStatement 全部入库,但它只干了解析的活,没干生成的活。SQL 还是要人写,框架只负责把人写的 SQL 注册好。
我做的 MyBatisGX,瞄准的就是这个空档:启动期扫描所有 DAO 接口,依据方法名规则、注解和实体元数据,直接生成 MappedStatement,等于把"写 SQL"这一步也搬进了启动期。运行期不变,还是 MyBatis 那套查表 + 执行,所以 MyBatis 的性能底色原样保留。
这篇文章也是后面"MyBatisGX 设计内幕"系列的地基:聊启动期 SQL 预生成之前,得先有共同语言。现在有了,就是 MappedStatement。
结语
框架的日常使用和它的设计之间,隔着一层"启动期"。多数人在这层之外住了很多年,也能把活干完。
但搞懂这层是划算的。最直接的,报错能看懂了,面试也能答得深一点。再往远说,选型的时候你会去问一个问题:这个方案把什么成本放在了启动期、什么留给了运行期?
评论区聊聊:你第一次见到 already contains value for 是什么场景?你们的 Spring Boot 项目启动,最慢的一步又是什么?
相关链接
- GitHub:github.com/cris-xue/my...