MyBatis 启动的时候都在干什么:从 MappedStatement 说起

MyBatis 启动的时候都在干什么:从 MappedStatement 说起

天天写 CRUD,但大部分人没看过 MyBatis 启动那几秒在忙什么。

面试题都会背:SqlSessionFactoryBuilderSqlSessionFactorySqlSession。这条链谁都能念出来,但真正值钱的东西在链的终点:一个叫 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(接口) │
                                          └────────────────────────┘

这个设计决定了两件事:

  1. XML 只在启动期读一次。运行期没有解析 XML 这回事,getSql 的开销是一次 Map 查找。
  2. 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()

这一步把标签上二十来个属性逐个拆下来:parameterTyperesultMap / resultTypekeyGeneratortimeoutfetchSize......然后到整个启动期最关键的一行:

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 动态代理:MapperProxyFactoryMapperProxy。调用接口方法时,代理按"接口全限定名 + 方法名"拼 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 快",没什么黑科技,就是这笔时间账算得好。

六、三个日常现象,回头看都有了着落

  1. 为什么 mapper 写错了,报错在启动而不是第一次调用?语句注册发生在启动期,id 重复、resultMap 引用不存在、XML 格式错误,全是启动期异常。反过来,方法名和 XML id 不一致启动期查不出来,注册的两条线要到运行期才碰头。

  2. 为什么 ${} 永远是注入风险的源头?它在启动期被归入动态分支,注定走运行期字符串替换。哪怕你的参数是内部传参不是用户输入,这条边界也值得刻在脑子里:${} 是 SQL 的一部分,#{} 是参数。

  3. 为什么插件能拦截四大对象?pluginElement 在启动期就建好了 InterceptorChainnewExecutor / 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 项目启动,最慢的一步又是什么?

相关链接

相关推荐
en.en..1 小时前
Linux wait()函数(预防僵尸进程)
java·大数据·开发语言
国奉1 小时前
iOS 如何处理 GB 级大文件?从 FileHandle、分块读写到进度、取消与异常恢复
后端
Json____1 小时前
基于 FastAPI + Vue3 的在线拍卖系统技术解析
spring boot·后端·fastapi·wwwoop.com
创新技术阁1 小时前
FastapiAdmin 实战:二次开发前的准备(环境配置与项目启动)
前端·后端·fastapi
g10565591391 小时前
华为 OceanStor 基础使用入门指南
服务器·数据库·性能优化
潇凝子潇1 小时前
MySQL buffer pool 计算公式
数据库·mysql
Java内核笔记1 小时前
Spring Boot 4.1 官方 gRPC 支持源码剖析:从社区 Starter 到一等公民
spring boot·后端
扬大平仔1 小时前
小深:用 AgentScope Java 2.0 Harness 做私人助手(上)
java·开发语言
captain3761 小时前
网络编程(1)
java·网络·ide·java-ee