☰
MyBatis自定义Mapper方法执行流程源码解析
2026/10/14 11:38:26 网站建设 项目流程

但凡把 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);

从这个调用出发,整个执行流程大致是:

  1. userMapper是一个 JDK 动态代理对象,真正干活的是MapperProxy。
  2. MapperProxy.invoke()拦截方法调用,拿到方法对应的MapperMethod。
  3. MapperMethod内部通过SqlCommand找到配置里对应的MappedStatement,再通过MethodSignature解析方法返回类型和参数。
  4. 委托给SqlSession执行,通常是DefaultSqlSession。
  5. DefaultSqlSession根据配置选择Executor实现,再由Executor调用StatementHandler。
  6. StatementHandler联合ParameterHandler、ResultSetHandler完成 SQL 预编译、参数绑定、结果集映射。

后面所有代码分析,都是围绕这张地图展开的。读源码时不断回看这幅图,就不容易迷路。

1.3 涉及的核心包与类

为了方便对照,我先把这条链路上会碰到的关键类按包列出来:

包路径核心类职责
org.apache.ibatis.bindingMapperRegistry、MapperProxyFactory、MapperProxy、MapperMethodMapper 注册、动态代理、方法分发
org.apache.ibatis.sessionConfiguration、DefaultSqlSession全局配置、SqlSession 默认实现
org.apache.ibatis.executorExecutor、BaseExecutor、SimpleExecutor、CachingExecutorSQL 执行器,含缓存逻辑
org.apache.ibatis.mappingMappedStatement、SqlCommandType一条 SQL 的完整描述
org.apache.ibatis.scriptingSqlSource、BoundSqlSQL 动态解析与绑定

有两点值得提前留意:一是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(...)的大致流程:

  1. 从传入参数和BoundSql生成缓存 key。
  2. 从localCache(一级缓存)里查,命中就直接返回。
  3. 没命中就调用queryFromDatabase,执行子类的doQuery,并把结果放入localCache。
  4. 如果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)时:

  1. 代理对象收到调用,进入MapperProxy.invoke。该方法不是 Object 方法,于是拿到或创建MapperMethod实例。
  2. MapperMethod里早已通过SqlCommand定位到了 id 为com.example.UserMapper.selectById的MappedStatement。
  3. MethodSignature识别出返回类型是单个User,参数列表只有一个Integer id。
  4. execute走 SELECT 单对象分支,调用sqlSession.selectOne(statementId, 1)。
  5. DefaultSqlSession.selectList拿到MappedStatement,把参数交给Executor。
  6. Executor检查一级缓存未命中,进入SimpleExecutor.doQuery。
  7. doQuery创建PreparedStatementHandler,prepare 出PreparedStatement,再用ParameterHandler把 1 绑定到 SQL 第一个占位符。
  8. SQL 执行,返回ResultSet,ResultSetHandler逐行映射出User对象。
  9. 结果沿着Executor→SqlSession→MapperMethod→MapperProxy返回给调用方。

整条链路大约也就这么长。但每一个环节都有大量细节,比如第 6 步里BoundSql的生成,第 8 步里TypeHandler对Integer和User.id的映射,都是源码阅读的深水区。

5.2 关键源码位置速查表

环节类与方法看什么
注册MapperRegistry.addMapperknownMappers 的 put 动作
解析MapperAnnotationBuilder.parse方法遍历与 MappedStatement 注册
代理MapperProxyFactory.newInstanceJDK 动态代理创建
拦截MapperProxy.invoke方法分类与缓存
桥接MapperMethod.execute命令类型与返回类型分派
会话DefaultSqlSession.selectListstatement 查找与委托
执行器SimpleExecutor.doQueryStatementHandler 创建
预编译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在复杂结果映射时的处理策略,到时候再和大家聊聊我从源码里挖到的那些细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询