但凡把 MyBatis 用熟的人,心里多少都会攒几个问题:为什么 Mapper 接口连实现类都没有,直接注入进来就能用?我调一个自定义方法,比如selectById,它到底是怎么一步步变成 SQL、绑定参数、再映射回对象的?市面上讲 MyBatis 用法的文章很多,但能把这套自定义 Mapper 方法执行流程讲透的源码分析其实不算多。这篇是 MyBatis 源码学习系列的第四篇,我打算把这条链路从头到尾拆一遍,把背后涉及的注册、代理、方法解析、SQL 执行、结果映射全部串起来。适合已经能熟练使用 MyBatis、对 JDBC 有基本概念,但还没深入读过源码的开发者。
先说结论:自定义 Mapper 方法执行流程,本质上是一条“接口方法被动态代理接管,再委托给 SqlSession 与 Executor 完成数据库访问”的链路。你写的 Mapper 接口只是一个契约,真正干活的是一堆运行时组件。这篇就把这条链路彻底讲清楚,读完你能回答上面所有问题,以后排查 Mapper 相关的疑难杂症也会顺手很多。
1. 项目背景与学习思路拆解
1.1 为什么“自定义 Mapper 方法”值得单独开一篇源码解析
MyBatis 的核心使用方式有两种:一种是直接调用SqlSession的selectOne、selectList等方法,把 statement id 当参数传进去;另一种是定义一个 Mapper 接口,写上自己的方法,然后让容器注入接口代理对象。后一种方式在业务代码里几乎占绝大多数,因为它类型安全、可读性好,也贴合面向接口编程的习惯。
但业务代码里越是常见,越容易忽略底层机制。很多人只知道“接口加注解或 XML 就能跑”,却说不清以下细节:
- 接口是什么时候、被谁注册到 Configuration 里的?
- 代理对象是在容器启动时一次性生成,还是每次使用时生成?
- 一个方法名对应一个 SQL,MyBatis 是怎么把“方法”和“MappedStatement”对应起来的?
- 多参数情况下,参数名到底怎么解析?为什么有时候必须加
@Param? - 查询结果是 List 还是单个对象,MyBatis 怎么在运行时决定?
这些问题全都能在源码里找到答案。把它们弄明白,你才算真正跨过了“会用”到“懂原理”的分水岭。这也是我把系列第四篇定位在自定义 Mapper 方法执行流程的原因:前面几篇如果已经覆盖了 Configuration、SqlSession、Executor 这些基础组件的源码,那这一篇就是把它们组装起来看整条链路如何协同。
1.2 先画一张全流程心理地图
源码阅读最怕一头扎进细节里出不来。我建议先用一个最简单的方法当锚点:
User user = userMapper.selectById(1);从这个调用出发,整个执行流程大致是:
userMapper是一个 JDK 动态代理对象,真正干活的是MapperProxy。MapperProxy.invoke()拦截方法调用,拿到方法对应的MapperMethod。MapperMethod内部通过SqlCommand找到配置里对应的MappedStatement,再通过MethodSignature解析方法返回类型和参数。- 委托给
SqlSession执行,通常是DefaultSqlSession。 DefaultSqlSession根据配置选择Executor实现,再由Executor调用StatementHandler。StatementHandler联合ParameterHandler、ResultSetHandler完成 SQL 预编译、参数绑定、结果集映射。
后面所有代码分析,都是围绕这张地图展开的。读源码时不断回看这幅图,就不容易迷路。
1.3 涉及的核心包与类
为了方便对照,我先把这条链路上会碰到的关键类按包列出来:
| 包路径 | 核心类 | 职责 |
|---|---|---|
org.apache.ibatis.binding | MapperRegistry、MapperProxyFactory、MapperProxy、MapperMethod | Mapper 注册、动态代理、方法分发 |
org.apache.ibatis.session | Configuration、DefaultSqlSession | 全局配置、SqlSession 默认实现 |
org.apache.ibatis.executor | Executor、BaseExecutor、SimpleExecutor、CachingExecutor | SQL 执行器,含缓存逻辑 |
org.apache.ibatis.mapping | MappedStatement、SqlCommandType | 一条 SQL 的完整描述 |
org.apache.ibatis.scripting | SqlSource、BoundSql | SQL 动态解析与绑定 |
有两点值得提前留意:一是MapperRegistry虽然叫 Registry,其实它是Configuration内部的一个属性,很多文章说“接口注册到 Configuration”,说的就是注册进这个 Registry;二是MappedStatement是整条链路的“核心档案”,每个方法的 SQL 解析结果都挂在它身上。
2. Mapper 接口的注册与动态代理生成
2.1 从 mapper 标签到 KnownMappers
MyBatis 启动时,会通过XMLMapperBuilder解析 XML 配置文件里的<mappers>标签。无论你配置的是包扫描还是一个个 mapper 文件,最终都会进入Configuration.addMapper()方法:
public <T> void addMapper(Class<T> type) { mapperRegistry.addMapper(type); }MapperRegistry内部维护了一个Map<Class<?>, MapperProxyFactory<?>>,名为 knownMappers。addMapper方法里有两个关键动作:
- 如果这个接口已经被注册过,会抛出
BindingException,提示Type ... is already known to the MapperRegistry。 - 如果没有注册,会创建对应的
MapperProxyFactory放进 knownMappers,然后通过MapperAnnotationBuilder解析这个接口的所有方法。
注意这里的顺序:注册在前,解析在后。映射关系的建立是在容器启动阶段完成的,不是等到第一次执行方法时才临时解析。这一点对理解后续“为什么方法名不能重复”很重要,因为每个方法都会被解析成一个MappedStatement,而MappedStatement的 id 是namespace + "." + 方法名,如果两个方法解析出来的 id 一样,就会直接冲突。
2.2 MapperAnnotationBuilder 的解析动作
MapperAnnotationBuilder的parse()方法会做几件事:
- 读取当前接口对应 XML 文件中的
<mapper>节点配置。 - 遍历接口里所有方法(包括从父接口继承来的),逐个解析方法上的注解。
- 为每个方法构建
MappedStatement并注册到Configuration.mappedStatements。
这里有个容易被忽略的细节:即使你的 SQL 都写在 XML 里,方法上没有任何注解,这一步也会执行。parse()里对每个方法调用parseStatement()时,会先去 XML 缓存里查找是否有同 id 的 statement,查不到再去找注解。所以 XML 和注解可以混用,前提是方法 id 不冲突。
parseStatement()内部做了大量默认值处理,比如默认statementType是PREPARED,默认resultType从方法返回值上推断,默认sqlSource从@Select、@Insert等注解或 XML 节点里提取。这个阶段构建好的MappedStatement会一直复用,直到容器销毁。
2.3 MapperProxyFactory 与 JDK 动态代理
注册完成后,当代码里注入UserMapper或调用sqlSession.getMapper(UserMapper.class)时,实际调用的是:
public <T> T getMapper(Class<T> type, SqlSession sqlSession) { final MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type); return mapperProxyFactory.newInstance(sqlSession); }newInstance方法里就是标准的 JDK 动态代理创建过程:
return Proxy.newProxyInstance( mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy );有人会问:为什么 Mapper 必须是接口,不能是类?因为 JDK 动态代理只支持接口,不支持类和抽象类。这也解释了为什么 MyBatis-Spring 集成时,Mapper 扫描也是接口扫描而不是类扫描。代理对象真正持有的是MapperProxy实例,它内部保存了SqlSession和MapperInterface的类型信息。每个代理对象创建时都会绑定一个当前可用的 SqlSession,这个绑定关系在方法调用期间全程生效。
2.4 MapperProxy.invoke 的三层分流逻辑
MapperProxy实现了InvocationHandler,所有对接口方法的调用都会被invoke()方法拦下来。它内部逻辑可以分成三层:
第一层,判断是不是Object暴露的方法,比如toString、hashCode、equals。如果是,直接走本地逻辑,不触发 SQL 执行。这个判断很必要,否则代理对象被打印日志时都会去查一次数据库。
第二层,从缓存里获取或新建对应的MapperMethod。MapperMethod是针对“接口方法”级别的解析结果,解析一次以后缓存在MapperProxy的methodCache里,key 是Method对象。
第三层,调用mapperMethod.execute(sqlSession, args),真正把请求交给SqlSession。这里就进入了下一节要拆的内容。
我实际调试时习惯在invoke()里打断点,因为它是最靠近“业务侧”的源码入口。在这里能看到参数原始值,也能直观看到代理对象拦截了哪些方法。踩过一次坑之后,我反而更喜欢这种方式:所有 Mapper 方法都不可能绕过MapperProxy,所以这里断一次,就能确认代理有没有生效。
3. SqlCommand 与 MethodSignature:从方法到 SQL 的桥接
3.1 SqlCommand 如何定位 MappedStatement
MapperMethod内部持有两个关键内部类:SqlCommand和MethodSignature。先看SqlCommand:
构造时,它会用“接口全限定名 + 方法名”拼出一个 statementId,然后去Configuration里查找对应的MappedStatement:
final String statementId = mapperInterface.getName() + "." + methodName;如果找不到,报错信息非常经典:
Invalid bound statement (not found): com.example.UserMapper.selectById这条报错大约是 MyBatis 使用频率最高的运行时异常了。出现它的原因,一般是 XML 里的 namespace 与方法所在接口全类名不一致,或者 XML 没被扫描进去,或者方法名拼写错误。源码里查找MappedStatement用的是configuration.getMappedStatement(statementId),而MappedStatement集合的 key 正是namespace + "." + id,所以只要 namespace 写错一个字母都匹配不上。
SqlCommand还会顺便取出MappedStatement.getSqlCommandType(),也就是这条 SQL 是 INSERT、UPDATE、DELETE、SELECT 中的哪一种。这个类型直接决定后面MapperMethod.execute走哪个分支。
3.2 MethodSignature 对方法签名的全面分析
MethodSignature是MapperMethod的另一个内部类。它负责回答几个问题:
- 方法返回值是一般对象、
List、Map、Cursor,还是void? - 方法参数里有没有
RowBounds?有没有ResultHandler? - 参数名是怎么抽取的?哪些参数算作 SQL 参数?
它内部用多个布尔变量标记返回类型特征,比如isReturnsMany、isReturnsMap、isReturnsVoid、isReturnsCursor。这些标记在后面execute判断返回结果时非常关键。
参数解析这部分我多说一句。MethodSignature里会创建ParamNameResolver,它负责把所有参数转换成一个可用的参数列表。主要规则是:
- 如果参数只有一个,且没有加
@Param,那么可以直接用这个参数本身作为 SQL 参数。 - 如果参数有多个,或者任意一个参数加了
@Param,那么所有参数都会被包装成一个ParamMap,map 的 key 是@Param指定的名字,或者是编译期保留的-parameters参数名。 - 如果既没加
@Param,JVM 编译时又没开-parameters,那么参数名会变成arg0、arg1、param1、param2这种形式。
这就是为什么我一直建议团队里写多参数 Mapper 方法时必须显式加@Param。不是 MyBatis 解析不了多参数,而是依赖隐式命名会让 SQL 里的参数名非常脆弱,换个编译环境就可能失效。
3.3 execute 分发逻辑与返回结果处理
MapperMethod.execute()是方法到 SQL 的最终分派中心。它会先判断 SqlCommandType,然后走不同分支:
- INSERT:执行
sqlSession.insert(...),返回影响行数。 - UPDATE / DELETE:执行对应方法,返回影响行数。
- SELECT:内部再根据
MethodSignature的返回类型标记继续分派。
SELECT 分支值得展开。源码里的大致逻辑是:
- 返回
void:先执行sqlSession.select(...),消费结果集但不返回。 - 返回
List:调用selectList。 - 返回
Map:调用selectMap,此时要求方法签名里必须指定mapKey。 - 返回
Cursor:调用selectCursor。 - 返回单个对象:调用
selectOne,而selectOne底层还是调用selectList,只不过要求结果集最多只有一行。如果查出多行,会抛出TooManyResultsException。
这里要提醒一个实际开发常见的坑:方法声明的返回类型是User,但 SQL 查出了两条记录,异常信息和原因经常让人摸不着头脑。看到TooManyResultsException时,第一反应应该是检查业务上是否真的保证唯一,或者把返回类型改成List<User>。
4. SqlSession 到 Executor 的底层执行链路
4.1 DefaultSqlSession 如何承载一次查询
MapperMethod.execute把请求交给SqlSession后,默认实现是DefaultSqlSession。以selectList为例,它做了这几件事:
- 用 statement 字符串从
Configuration里拿MappedStatement。 - 把参数对象和
RowBounds传给Executor.query(...)。 - 如果配置了
localCacheScope为STATEMENT,查询结束后会清空一级缓存。 - 在
Executor执行前,通过ExecutorType判断是否要延迟批量执行。
注意DefaultSqlSession里几乎所有方法都会先调用configuration.getMappedStatement(statement),也就是说它内部还做了一次 statement 查找。但正常情况下,这里的查找不会失败,因为MapperMethod已经验证过了。如果你是直接用字符串 id 调sqlSession.selectOne("xxx"),那这里就是第一次校验的地方。
4.2 Executor 接口与三种核心实现
Executor 是 MyBatis 执行 SQL 的核心接口。它的默认实现有三个:
SimpleExecutor:每执行一次 SQL 就创建一个新的StatementHandler,执行完立即关闭 Statement。这是默认配置,适合大多数场景。
ReuseExecutor:用 Map 缓存已经创建的Statement,key 是 SQL 字符串。相同 SQL 可以复用 JDBC Statement,减少重复预编译的开销,但对 SQL 变化特别敏感。
BatchExecutor:把多次update、delete攒起来,通过 JDBC 批量 API 一次性提交。注意它只对写操作生效,查询走的是普通逻辑,而且必须手动 flush 或提交事务才会真正发送到数据库,很多新人用批量模式时发现数据一直不更新,就栽在这。
除了上面三种,还有一个装饰器:CachingExecutor。只要配置了二级缓存,MyBatis 就会用CachingExecutor包装真实 Executor。它会先查二级缓存,查不到再去调底层 Executor,并把结果放回缓存。
你可以通过settings.defaultExecutorType调整使用哪种 Executor,也可以在创建 SqlSession 时临时指定。但业务系统里绝大多数情况用默认的SIMPLE就够了,不要为“性能优化”盲目上BATCH,它带来的心智负担远大于收益。
4.3 BaseExecutor 模板方法与一级缓存的秘密
SimpleExecutor这些实现类都继承了BaseExecutor。BaseExecutor是一个典型的模板方法模式实践:父类定义整体流程骨架,子类只实现doQuery、doUpdate等具体步骤。
BaseExecutor.query(...)的大致流程:
- 从传入参数和
BoundSql生成缓存 key。 - 从
localCache(一级缓存)里查,命中就直接返回。 - 没命中就调用
queryFromDatabase,执行子类的doQuery,并把结果放入localCache。 - 如果
MappedStatement配置了flushCache=true,则清空一级缓存。
一级缓存的默认范围是SESSION,也就是同一个 SqlSession 内有效。如果你在一个事务里多次查同一条数据,第二次会直接命中缓存,SQL 不会发到数据库。很多“事务里查不到最新数据”的排查故事,根源就在这里。调试源码时,我建议在queryFromDatabase打断点,能很直观地看到哪些查询是真正落库的。
SimpleExecutor.doQuery又是怎么工作的?它会创建一个StatementHandler,然后走这条链路:
handler.prepare(connection):通过 JDBC Connection 创建 Statement,并设置超时时间等参数。handler.parameterize(statement):调用ParameterHandler把参数绑定到 SQL 占位符上。handler.query(statement, resultHandler):执行查询,并调用ResultSetHandler处理结果集。
4.4 StatementHandler、ParameterHandler 与 ResultSetHandler 三剑客
StatementHandler 是 MyBatis 四大对象之一,它负责一条 SQL 从预编译到执行的全过程。默认实现是PreparedStatementHandler,内部维护了ParameterHandler和ResultSetHandler。
ParameterHandler的核心方法是setParameters。它会遍历BoundSql中每个参数映射,取出实际参数值,再用对应的TypeHandler往PreparedStatement里设置。注意这里有一步很关键:如果参数是数组或者集合,会做特殊展开处理;如果参数里有JdbcType需要指定,也会在这里处理。
ResultSetHandler的核心方法是handleResultSets。它会处理可能存在的多结果集、多行结果,并对每一行调用ResultSetWrapper完成列名到对象属性的映射。实际上DefaultResultSetHandler的逻辑非常长,因为它要处理resultMap的嵌套结构、集合映射、懒加载等一系列复杂场景。
这三大对象的协作逻辑,是整条链路里最不好啃的部分,也是排查“结果映射不对”时最容易出问题的地方。我的建议是先看懂PreparedStatementHandler的调用顺序,再深入DefaultResultSetHandler时就不会被细节带偏。
5. 一次自定义 Mapper 方法执行的完整旅程
5.1 以 selectById 为例的时序串联
我把前面所有部分串起来,用一个实际的方法走一遍:
// Mapper 接口 public interface UserMapper { User selectById(Integer id); }调用userMapper.selectById(1)时:
- 代理对象收到调用,进入
MapperProxy.invoke。该方法不是 Object 方法,于是拿到或创建MapperMethod实例。 MapperMethod里早已通过SqlCommand定位到了 id 为com.example.UserMapper.selectById的MappedStatement。MethodSignature识别出返回类型是单个User,参数列表只有一个Integer id。execute走 SELECT 单对象分支,调用sqlSession.selectOne(statementId, 1)。DefaultSqlSession.selectList拿到MappedStatement,把参数交给Executor。Executor检查一级缓存未命中,进入SimpleExecutor.doQuery。doQuery创建PreparedStatementHandler,prepare 出PreparedStatement,再用ParameterHandler把 1 绑定到 SQL 第一个占位符。- SQL 执行,返回
ResultSet,ResultSetHandler逐行映射出User对象。 - 结果沿着
Executor→SqlSession→MapperMethod→MapperProxy返回给调用方。
整条链路大约也就这么长。但每一个环节都有大量细节,比如第 6 步里BoundSql的生成,第 8 步里TypeHandler对Integer和User.id的映射,都是源码阅读的深水区。
5.2 关键源码位置速查表
| 环节 | 类与方法 | 看什么 |
|---|---|---|
| 注册 | MapperRegistry.addMapper | knownMappers 的 put 动作 |
| 解析 | MapperAnnotationBuilder.parse | 方法遍历与 MappedStatement 注册 |
| 代理 | MapperProxyFactory.newInstance | JDK 动态代理创建 |
| 拦截 | MapperProxy.invoke | 方法分类与缓存 |
| 桥接 | MapperMethod.execute | 命令类型与返回类型分派 |
| 会话 | DefaultSqlSession.selectList | statement 查找与委托 |
| 执行器 | SimpleExecutor.doQuery | StatementHandler 创建 |
| 预编译 | PreparedStatementHandler.parameterize | 参数绑定 |
| 映射 | DefaultResultSetHandler.handleResultSets | 结果集到对象的转换 |
这张表可以打印出来,配合源码看很顺手。每遇到一个奇怪的现象,就在对应环节停留,比从入口一路往下跟要高效得多。
6. 常见问题排查与源码调试心得
6.1 高频异常速查表
| 异常或现象 | 根因方向 | 排查建议 |
|---|---|---|
Invalid bound statement (not found) | MappedStatement 没注册,通常是 namespace 不一致或 XML 未扫描 | 先检查 XML 是否被纳入,再核对 namespace 与接口全类名 |
TooManyResultsException | 返回类型是对象但查出了多行 | 确认业务唯一性,或改成 List 返回 |
Parameter 'xxx' not found | 多参数未加@Param,或编译时未保留参数名 | 给多参数显式加@Param,别依赖隐式命名 |
| 查询结果全为 null | 列名与属性名不匹配,或 TypeHandler 没生效 | 看看 mapUnderscoreToCamelCase 是否开启,检查 resultMap |
| 一级缓存导致查不到最新数据 | 同一个 SqlSession 内缓存命中 | 确认是否需要清理缓存,或换 SqlSession |
| 批量模式数据未更新 | 误用了 BatchExecutor,未 flush | 尽量避免批量执行器,或确保正常提交事务 |
这里我想特别强调第一类问题。以前我遇到Invalid bound statement,第一反应是重启应用,后来发现十有八九是 XML 的 namespace 写错,或者方法在接口里改了名但 XML 忘了同步。查这种问题最快的方式,是在MapperRegistry.addMapper打断点看接口有没有进来注册,再在MappedStatement集合里看 id 是否匹配。
6.2 调试源码时的几个实操断点
MapperProxy.invoke:确认代理是否拦截到方法。MapperMethod.execute:确认参数转换和命令类型。BaseExecutor.query:确认缓存逻辑与实际落库。DefaultResultSetHandler.getRowValue:确认结果映射过程。ParameterHandler.setParameters:确认参数绑定细节。
调试时最好用真实业务方法入口,不要用单元测试里直接调sqlSession.selectList的方式,因为那会绕过代理层,看不到自定义 Mapper 特有的链路。如果 IDE 支持条件断点,建议在invoke方法上加方法名条件,只拦你想看的方法,避免每次都断在无关调用上。
6.3 学源码的两个阶段建议
第一阶段,先顺着本篇的链路,把每个类名、方法名和调用顺序记住。这个阶段不要纠结细节,只需要建立整体框架。第二阶段,再选一条分支深挖,比如单独研究DefaultResultSetHandler如何处理嵌套 resultMap,或者单独研究BatchExecutor的批量更新机制。每个分支都值得单独写一篇,但前提是主线已经清楚。
我个人在实践里还有个小习惯:每读懂一个环节,就自己画一张调用时序草图,不追求标准,只要能表达清楚即可。画到第三遍时,很多看似分散的点就连成线了。这个土办法比反复看文档都管用。
写在最后
源码学习最忌“一口气吃成胖子”,自定义 Mapper 方法执行流程这条链路,差不多就是 MyBatis 运行时最核心的主干路径。把这条主干摸透之后,再去看插件机制、二级缓存、动态 SQL 生成这些主题,会发现它们都在主干上做文章。如果让我给一点个人建议,那就是别跳过代理这一环,很多框架的设计思想都藏在动态代理里。下一篇我打算继续往更深的地方走,重点拆一拆DefaultResultSetHandler在复杂结果映射时的处理策略,到时候再和大家聊聊我从源码里挖到的那些细节。