mybatis 启动时最核心的“活”就是:解析所有 sql 映射配置,把它们封装成一个个 mappedstatement 对象,注册进全局 configuration 里备查。 可以把 mappedstatement 理解成每个 sql 的“身份证+说明书”,之后每次执行 sql 都是靠它来定位和运行的。
📦 mappedstatement 是什么
它统一保存一条 sql 的全部元数据,包括 sql 内容、参数类型、返回值类型、缓存配置、动态 sql 配置、resultmap 等。 每个 /// 标签(或对应注解)都会生成一个 mappedstatement。
天天写 crud,但大部分人没看过 mybatis 启动那几秒在忙什么。
面试题都会背:sqlsessionfactorybuilder → sqlsessionfactory → sqlsession。这条链谁都能念出来,但真正值钱的东西在链的终点:一个叫 configuration 的对象,和它肚子里成百上千个 mappedstatement。
这篇把启动期从头到尾走一遍。mybatis 为什么快、几个经典报错是怎么回事、#{} 和 ${} 的区别在哪,看完就都清楚了。
一、先给结论:启动期是一次"预编译"
mybatis 启动时把所有 xml 解析成一个个
mappedstatement,塞进一个configuration对象;运行期只是按名字查表,然后执行。
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 解析主配置
入口就是面试题那句:
sqlsessionfactory factory = new sqlsessionfactorybuilder().build(inputstream);
build() 里 new xmlconfigbuilder(...) 然后 parse(),parse() 的主体是 parseconfiguration(),按固定顺序消化 mybatis-config.xml 的每个节点:
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……然后到整个启动期最关键的一行:
sqlsource sqlsource = langdriver.createsqlsource(configuration, context, parametertypeclass);
第三步:sql 怎么存?先拆树,再分拣
createsqlsource 里的 xmlscriptbuilder 做的事,可以概括成"先拆树,再分拣":
拆树:把 sql 文本拆成一棵节点树。纯文本变 textsqlnode,<if> 变 ifsqlnode,<where> 变 wheresqlnode,<foreach> 变 foreachsqlnode……整个动态 sql 就是一棵组合树,运行时对参数求值,按需拼接。
分拣(parsedynamictags 的判定):
// 伪代码,抹掉了细节
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 里装着:
id com.example.mapper.usermapper.selectbyid ← 全局唯一标识 sqlsource dynamicsqlsource / rawsqlsource ← sql 的静态结构 commandtype select / insert / update / delete resultmaps 结果怎么映射回对象 keygenerator 主键怎么生成(usegeneratedkeys / selectkey) timeout、fetchsize、……
说它是"原子",是因为运行期的一切都围绕它发生:
user user = usermapper.selectbyid(1l);
这行代码的完整旅程是:mapperproxy(动态代理)→ mappermethod → 拼出 "com.example.mapper.usermapper.selectbyid" 这个字符串 → configuration.getmappedstatement(id) 查表 → 拿到 mappedstatement → 取出 boundsql → 交给 executor 执行。
说到底,mybatis 的接口调用就是一次以字符串为 key 的查表。
这也解释了那个经典报错:
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。之后:
usermapper mapper = sqlsession.getmapper(usermapper.class);
getmapper 走的是 jdk 动态代理:mapperproxyfactory → mapperproxy。调用接口方法时,代理按"接口全限定名 + 方法名"拼 key 去 mappedstatements 查表。
所以启动期其实做了两件事:语句入库(xml 侧),接口注册(java 侧)。运行期靠"名字"这个字符串把两边接上。
这个设计省事,但也埋了雷。省事在零依赖、纯约定;雷在那根线是字符串:方法改名了 xml 没跟上,要到运行期才会蹦出一个 bindingexception。xml 写错这类问题反而好排查,启动期当场就炸;真正磨人的,恰恰是启动期查不出来的那种。
五、运行期:查表,不是解析
把完整流程画出来:
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…
到此这篇关于从mappedstatement说起mybatis启动的时候都在干什么的文章就介绍到这了,更多相关mybatis mappedstatement内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论