资讯详情

资讯详情

从主链路开始:MyBatis源码阅读的高效路径

用了两三年 MyBatis也看过不少项目但有个问题我一直觉得特别值得写出来很多人真正开始看 MyBatis 源码的时候不是被代码难度劝退而是被“不知道看什么”劝退。常见的画面是把源码从 GitHub 拉下来打开Configuration从第一行开始读读了两天脑子里全是 Map、List、解析器、注册表唯独没有一条能把它们串起来的线。回头被问到“Mapper 接口为什么不需要实现类”或者“一条 select 到底是怎么跑到数据库的”还是只能说个大概。这个问题的根源不在阅读量而在阅读顺序。MyBatis 源码学习的核心难点从来不是某个类看不懂而是没有一个“主链路地图”。如果先建立主链路再沿着链路去读会发现大部分核心类都挂在一条清晰的流水线上配置加载 - 会话创建 - Mapper 代理 - SQL 绑定 - SQL 执行 - 结果映射。这篇文章我就按这条主链路来拆。1. 读 MyBatis 源码最难的从来不是语法而是不知道主线在哪1.1 MyBatis 的几个核心层配置层、代理层、执行层MyBatis 的源码类非常多但职责划分其实很收敛。从顶层看可以分成三层。配置层。这一层负责把 XML 和注解解析成内部对象。核心类是Configuration它是全局配置的“仓库”围绕它工作的有XMLConfigBuilder、XMLMapperBuilder、MapperAnnotationBuilder。它们最终的目标是把一条条 SQL 变成MappedStatement这样的结构化对象。代理层。这一层解决“为什么 Mapper 接口没有实现类也能调用”。核心类是MapperProxyFactory、MapperProxy、MapperMethod。本质是 JDK 动态代理调用某个 Mapper 接口方法时真正执行的是代理类的invoke方法。执行层。这一层负责真正和数据库打交道。核心类是Executor、StatementHandler、ParameterHandler、ResultSetHandler。它们分成四个角色执行器负责整体调度语句处理器负责创建 JDBC Statement参数处理器负责把 Java 参数塞进 SQL结果集处理器负责把数据库返回的结果转成 Java 对象。这三层不是割裂的而是按调用顺序天然串联。1.2 为什么说带着“主链路”去读源码效率最高我自己的体感是看 MyBatis 源码顺序比努力重要。如果从Configuration的第一行开始读很容易陷进“ArrayList 怎么扩容、HashMap 原理是什么”这类细节里。这些东西不是没用但它们属于另一个知识专题。读源码时一旦被这类细节带走主线就丢了。更好的做法是先建立一个“调用链视角”SqlSessionFactoryBuilder.build() - XMLConfigBuilder 解析配置构建 Configuration - 解析 mapper 文件注册 MappedStatement - SqlSessionFactory.openSession() - 创建 Executor - SqlSession.getMapper() - MapperProxyFactory 生成 Mapper 接口代理 - MapperMethod.execute() - 找 MappedStatement - 交给 Executor 执行 - StatementHandler 准备 SQL - ParameterHandler 设置参数 - ResultSetHandler 处理结果只要你记住这张地图再看源码时就不是漫无目的地翻代码而是带着问题去“定位”。看到一个类时先问一句它在这条链路的哪个环节它负责解决什么问题它的上下游是谁如果能回答这三个问题这个类你已经读懂了一半。2. 从 mybatis-config.xml 到 Configuration一次配置加载的幕后流程2.1 XMLConfigBuilder 是如何把 XML 变成 Configuration 的MyBatis 应用启动时通常从下面这行代码开始SqlSessionFactory factory new SqlSessionFactoryBuilder().build(inputStream);这里最容易被忽略的关键点SqlSessionFactoryBuilder不是一个负责保存状态的对象它更像一个“启动器”。build方法真正做的事是创建XMLConfigBuilder然后调用它的parse()方法得到Configuration。在XMLConfigBuilder内部有一个典型的解析流程parse() - parseConfiguration() - propertiesElement() - settingsElement() - typeAliasesElement() - environmentsElement() - mapperElement()也就是说先解析全局配置里的properties、settings、typeAliases等标签再解析environments数据源和事务配置最后解析mappers里注册的映射文件。这个结构说明了一个事实MyBatis 的配置加载并不是把 XML 存成 XML而是把 XML 解释成一组 Java 对象。配置标签最终都会变成Configuration类里的一个个字段比如mappedStatements、resultMaps、parameterMaps、typeHandlerRegistry、interceptorChain。实际调试时可以这么做在XMLConfigBuilder.parseConfiguration()方法里打一个断点。启动你的 Spring Boot 或普通 Java 应用。观察方法执行顺序以及每一步之后Configuration里多了哪些内容。这样你就能直观看到XML 里的一个environment最后对应到的是Configuration.getEnvironment()一个mapper resource...最后会触发对应 mapper XML 文件的解析。2.2 MappedStatement一条 SQL 在内存里的“档案袋”MappedStatement是我建议你第一个要认识的“核心数据结构”。它表示一条 SQL 在内存中的完整描述。可以把它理解成一个“档案袋”里面装着你写 SQL 时关心的所有信息id通常是namespace . 方法名。sqlSourceSQL 来源里面有原始 SQL。resultMaps结果映射规则。statementType是PREPARED、STATEMENT还是CALLABLE。sqlCommandType是SELECT、UPDATE、INSERT还是DELETE。这个对象是在解析 mapper XML 时构建出来的。无论是 XML 方式还是注解方式最终都会被解析成MappedStatement注册到Configuration.mappedStatements这个 Map 里。这里有个容易被忽略的点MyBatis 每次调用 Mapper 方法时并不会重新解析 XML而是通过 MappedStatement 的 id 去 Configuration 里找已经构建好的对象。所以配置加载虽然发生在启动阶段但它的影响贯穿整个运行周期。2.3 实际动作怎么打断点验证配置加载如果你想真正掌握配置加载不要只看文章建议自己做一个“最小实验”String resource mybatis-config.xml; InputStream inputStream Resources.getResourceAsStream(resource); SqlSessionFactory factory new SqlSessionFactoryBuilder().build(inputStream);然后做三件事在XMLConfigBuilder的parseConfiguration()里打断点观察每个XxxElement()方法的调用顺序。在Configuration构造器执行完之后观察mappedStatements这个字段是否已经有值。在XMLMapperBuilder的buildStatementFromContext()里打断点观察一条 SQL 是如何被包装成MappedStatement的。做完这一步你会建立起一个非常重要的认知MyBatis 启动阶段的全部工作说白了就是把“配置文本”翻译成“运行时对象”。后续的所有执行逻辑都建立在这批对象之上。提醒不同小版本里方法的命名和顺序可能有微调但“解析 XML - 注册 MappedStatement - 存入 Configuration”这条主线基本一致。3. Mapper 接口没有实现类为什么调用起来像有实现类3.1 JDK 动态代理在 MyBatis 里的作用这个问题是所有 MyBatis 面试题里的常客也是很多人从“会用”到“想懂”的第一个节点。答案并不复杂SqlSession.getMapper() 返回的不是一个真实实现类而是一个 JDK 动态代理对象。我们来还原一下代码上的链路。在 MyBatis 中Configuration里维护着一个MapperRegistry它负责注册所有 Mapper 接口。当你调用UserMapper mapper sqlSession.getMapper(UserMapper.class);实际发生的是return mapperRegistry.getMapper(type, sqlSession);MapperRegistry会先看看这个接口有没有注册过如果注册过就通过MapperProxyFactory创建一个代理对象。MapperProxyFactory做的事情是Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[]{mapperInterface}, new MapperProxy(sqlSession, mapperInterface, methodCache));也就是说你拿到的这个对象底层并不是你写的接口实现而是一个MapperProxy代理。接下来你调用这个对象的任何方法都会先进入MapperProxy.invoke()。这里有个容易忽略的细节invoke()会区分目标方法类型。如果调用的是toString()、hashCode()、equals()这些Object方法会直接走默认逻辑只有调用你定义的接口业务方法时才会走 MyBatis 的 SQL 执行流程。换句话说接口方法本身没有“实现”实现的是代理逻辑。3.2 从 MapperMethod 到 SqlSession方法和 SQL 是怎么绑定的代理对象拿到方法后下一步是找到这个接口方法对应的 SQL。MapperProxy会针对每个被调用的方法构造或缓存一个MapperMethod。这个对象承担了一个非常关键的职责把“Java 接口方法”翻译成“SqlSession 上的具体操作”。MapperMethod内部有两个重要组件SqlCommand负责根据MapperStatement.getId()和SqlCommandType找到对应 SQL 命令。MethodSignature负责解析这个 Java 方法的返回类型、参数个数、是否有Param注解等。最终MapperMethod.execute()会根据 SQL 命令类型选择调用sqlSession.insert(...) sqlSession.update(...) sqlSession.delete(...) sqlSession.selectOne(...) sqlSession.selectList(...)也就是说你调用userMapper.getById(1)内部其实是先定位到了MappedStatement然后转成sqlSession.selectOne(com.example.UserMapper.getById, 1)。这也是为什么很多人在早期会疑惑Mapper 接口的方法名和 mapper XML 里的 id 为什么要一致。因为 MyBatis 就是靠namespace . id这个坐标来定位 SQL 的。至此你已经走完了主链路的前半段接口调用 - MapperProxy - MapperMethod - SqlSession后面剩下的就是真正执行 SQL 的部分了。3.3 常见疑问为什么接口方法不能重载顺着这个机制你就能理解一个 MyBatis 的常见限制同一个 Mapper 接口里不建议对同一个 id 进行方法重载。因为 MyBatis 定位 MappedStatement 时使用的是坐标namespace.methodName它不像 Java 那样还区分参数列表。如果同一个接口里出现两个同名方法它们的坐标相同最终只会有一个 MappedStatement 注册成功另一个方法调用时很可能得到错误的结果。这可能不算一个“官方禁令”而是一个设计取舍牺牲了 Java 方法重载能力换来了配置和实现的简单直接。理解了这一点你在设计 Mapper 接口时就会格外注意方法名要稳定不要重名Param参数名要统一。4. SQL 从字符串到数据库执行Executor、StatementHandler、ParameterHandler 的分工4.1 Executor执行器的模板职责进入SqlSession之后真正干活的对象是Executor。SqlSession本身不直接操作 JDBC它更像门面Executor才是执行层的大门入口。MyBatis 内置了几种 ExecutorSimpleExecutor每次执行都创建新的 Statement。ReuseExecutor复用 Statement。BatchExecutor批量执行语句。CachingExecutor在基础 Executor 外层做二级缓存装饰。默认情况下SqlSession使用的是SimpleExecutor。这一点可以从Configuration.newExecutor()里看到如果没指定ExecutorType走的就是SIMPLE。这里需要理解一个设计模式模板方法模式。BaseExecutor作为抽象基类定义了一个query()方法的大致骨架query() - 查询一级缓存 localCache - 如果缓存里有直接返回 - 如果缓存里没有调用 queryFromDatabase() - 交给子类实现的 doQuery()doQuery()是不同的子类各自实现的但缓存检查和清理的逻辑是统一的。这也是为什么一级缓存在所有 Executor 下都生效——因为它放在BaseExecutor这一层。如果你在调试一条 select可以在BaseExecutor.query()和SimpleExecutor.doQuery()分别打断点可以看到第一次执行时localCache没有命中。数据库查询后结果写入localCache。第二次同参数查询时直接走缓存不再访问数据库。4.2 StatementHandler把 BoundSql 变成 JDBC 语句Executor不直接操作 JDBC 的Statement它把这项职责交给了StatementHandler。这里出现一个新的对象BoundSql。BoundSql是动态 SQL 处理之后的结果它里面包含三样东西带有?占位符的 SQL 字符串。parameterMappings参数映射列表。parameterObject实际参数对象。StatementHandler拿到BoundSql后会做这样几件事通过Connection.prepareStatement(sql)创建PreparedStatement。调用ParameterHandler设置参数。执行 SQL。调用ResultSetHandler处理结果。这个流程很容易被忽略的是第 2 步MyBatis 并不是简单地把 Java 参数拼进 SQL而是通过 TypeHandler 把 Java 类型转换为 JDBC 类型。常见的 TypeHandler 包括IntegerTypeHandler处理 int、Integer。StringTypeHandler处理 String。LocalDateTimeTypeHandler处理日期时间。当你遇到“参数传进去了但类型不对”的报错时大概率就出在这一层要么没有合适的 TypeHandler要么 TypeHandler 被错误指定。4.3 ResultSetHandler结果集映射的核心SQL 执行完之后ResultSetHandler负责把ResultSet转成 Java 对象。这个转换有两种情况第一种自动映射。如果你配置了resultTypecom.example.UserMyBatis 会尝试把列名映射到属性名。这里能不能转成功取决于列名和属性名是否一致。是否开启了mapUnderscoreToCamelCase。是否有对应的 TypeHandler 把 JDBC 类型转成 Java 类型。第二种手动映射。使用resultMap标签明确指定每一列对应哪个属性。这种方式比自动映射更可控尤其适合列名复杂、多表联查、嵌套对象等场景。很多人在项目里遇到“查询出来某个字段是 null”的问题排查路径往往是先看 SQL 里有没有这个列。再看列名和属性名能不能对上。最后看类型转换是否正常。这套排查路径本质上就是在顺着ResultSetHandler的职责走。4.4 一条 select 的完整调用链路配合断点如果你想完整走一遍链路建议从SqlSession.getMapper()之后调用一个查询方法然后依次打断点MapperMethod.execute() - sqlSession.selectList() - executor.query() - BaseExecutor.query() // 一级缓存检查 - SimpleExecutor.doQuery() - StatementHandler.query() - ParameterHandler.setParameters() - ResultSetHandler.handleResultSets()这一条链路走完之后你会发现之前零散的知识点全被串起来了MappedStatement提供 SQL 定义。Executor提供执行调度和缓存。BoundSql提供最终可执行 SQL。ParameterHandler处理入参。ResultSetHandler处理出参。在调试时不要只断点一个方法把关键节点的断点全部打上然后单步执行观察每一步的输入输出。这种体感是看任何源码解析文章都替代不了的。5. 缓存、插件、动态 SQL面试高频点如何挂在主链路上5.1 一级缓存为什么只在一个 Session 内有效面试里经常问“MyBatis 的一级缓存在哪里”答案就在前面提到的BaseExecutor.localCache。一级缓存其实是一个本地 Map它以CacheKey作为 key以查询结果作为 value。CacheKey由查询的 SQL、参数、分页信息等共同决定。为什么它是“一级”的因为它作用域非常窄——只在同一个SqlSession生命周期内有效。一旦执行了增删改、提交或关闭缓存就会清空。正因为作用域窄实际工程里一级缓存几乎不能被多个请求共享。Spring 场景下每个方法调用通常用的是独立的 SqlSession所以一级缓存主要用来避免“同一个 SqlSession 里同一条 SQL 反复查库”。这带来的启示是别把一级缓存当作解决查询性能问题的方案。如果需要跨调用共享缓存要看二级缓存或者引入外部缓存组件。5.2 二级缓存和基础 Executor 的装饰器叠加二级缓存的作用域比一级缓存大它是 namespace 级别的。在源码结构里CachingExecutor作为一个装饰器包在基础 Executor 外层。查询顺序通常是二级缓存 - 一级缓存 - 数据库二级缓存可以通过cache标签开启也可以配置useCachefalse等参数做局部控制。但有一点要特别清醒MyBatis 的二级缓存是本地缓存不是分布式缓存。如果你的应用是部署在多台机器上二级缓存会面临一致性问题。很多团队的经验是默认不开启二级缓存或者在单机、对一致性要求不高的场景下才考虑开启。5.3 插件Interceptor如何绕过主链路MyBatis 的插件机制也是面试常客。它的底层仍然是 JDK 动态代理但目标是代理四大对象ExecutorStatementHandlerParameterHandlerResultSetHandler这其实就是之前主链路里出现的几个核心角色。通过自定义Interceptor你可以在这些对象的某个方法前后插入逻辑。一个典型的例子是分页插件它通常会拦截Executor.query()在执行前改写 SQL拼上limit等分页语句然后继续执行。这里要提醒一句插件不是越猛越好。因为它本质上是在核心执行链路上做代理增强如果实现不严谨会影响所有 SQL 的执行。比如拦截了ResultSetHandler之后忘记释放资源或者对返回类型做过多假设都可能埋坑。5.4 动态 SQL 的真相不是模板渲染而是 SqlNode 组合很多人以为 MyBatis 的动态 SQL 是字符串拼接实际上它是通过SqlNode的组合来实现的。在解析 mapper XML 时if、where、set、choose、foreach这些标签会被解析成一个又一个SqlNode并以组合结构保存在SqlSource里。到了执行阶段DynamicSqlSource.getBoundSql()会递归调用这些SqlNode.apply()方法把动态片段拼装成最终的BoundSql。这带来的好处是标签可以灵活嵌套。每个 SqlNode 只负责自己的逻辑。最终生成的 SQL 是运行时确定的而不是启动时固定的。所以如果你在写if testxxx ! null时发现条件一直不生效排查方向通常是参数对象里到底有没有这个属性test里的属性名和参数名是否一致有没有开启mapUnderscoreToCamelCase影响参数结构。理解了 SqlNode 组合机制你排查起来会更有方向。6. 从源码理解到生产实践一个可复用的排查框架6.1 当 SQL 行为不符合预期从哪里查起源码不是只用来背书它可以直接指导我们排查问题。我在项目里遇到 MyBatis 相关问题时通常按下面这个顺序排查。第一步看日志。确认 MyBatis 是否打印了 SQL 日志。如果没有可以先在 mybatis-config.xml 中临时配置settings setting namelogImpl valueSTDOUT_LOGGING/ /settings这一步能确认两件事当前执行的是哪条 SQL、参数是什么。第二步看 BoundSql。如果你怀疑动态 SQL 拼接有问题可以在运行时获取 MappedStatement 对应的 BoundSql直接看最终拼接出来的 SQL 长什么样。MappedStatement ms configuration.getMappedStatement(com.example.UserMapper.getById); BoundSql boundSql ms.getBoundSql(paramObject); System.out.println(boundSql.getSql());这个方法很实用尤其适合定位where、foreach生成结果不符合预期的情况。第三步看参数绑定。如果 SQL 没问题但参数传不进去或者报 “Parameter xxx not found”那就去检查Param注解、参数名称是否一致以及 TypeHandler 是否正确。第四步看结果映射。如果参数也没问题但查询结果里某个字段是 null重点检查 resultType 的自动映射是否符合预期或者是否用了 resultMap。第五步看缓存和插件。如果 SQL 正确、参数正确、映射也正确但结果依然很怪这时就要警惕缓存干扰一级缓存、二级缓存命中和插件改写。这个顺序本质上就是按照主链路上的“配置 - SQL - 参数 - 映射 - 执行增强”的顺序来排查的。它比直接盲猜要快得多。6.2 把源码理解转成工程能力的三个建议第一做一次最小主链路断点训练。不要一开始就做源码二次开发而是先把主链路上的所有关键节点断点打上完整跑一条 select。目标只有一个能不看文章自己说出从 getMapper 到结果返回经过了哪些核心对象。第二自定义一个 TypeHandler。你可以在自己的项目里写一个 TypeHandler处理某个特殊类型比如把 List 存成 JSON 字符串。这个动作会逼你去理解ParameterHandler和ResultSetHandler的工作方式。第三自己写一个最简单的 Mapper 代理 Demo。不用引入 MyBatis纯用 JDK 动态代理实现一个小框架你只定义接口通过代理把接口方法名作为 key去一个 Map 里找对应的 SQL再通过 JDBC 执行。这个小 Demo 做完你对 MyBatis 的代理层理解会完全不一样。6.3 适用边界什么人、什么时候应该深入读源码读 MyBatis 源码不是“所有人、所有阶段”都必须做的事。它是一种按需深度匹配的能力。适合深入读源码的典型场景需要做 ORM 框架选型比较 MyBatis 和 MyBatis-Plus、JPA 的差异。项目里出现疑难 SQL 执行问题需要从执行链路定位。需要写自定义插件、自定义 TypeHandler、做通用分页或数据权限控制。想系统提升 Java 动态代理、JDBC 编程和框架设计能力。可以不急着深入读源码的场景刚接触 Java Web还在熟悉 SQL 和基础语法。项目工期紧当前只需要用 MyBatis 完成常规 CRUD。对 Spring Boot 整合 MyBatis 的基本用法还不熟悉。即使决定要读也建议循环式学习第一次只建立主链路第二次再深入缓存第三次再看插件和动态 SQL 的细节。一次读太深很容易丢线索。回到那条主链路如果这篇文章你只能记住一件事我希望是这条链路SqlSessionFactoryBuilder - Configuration - SqlSession - MapperProxy - MappedStatement - Executor - StatementHandler - ParameterHandler - ResultSetHandler它像是 MyBatis 的地铁线路图。你不需要记住每一站的每一块瓷砖长什么样但你需要知道从哪里上车、到哪里下车、中间停靠哪些关键站。下次再遇到 MyBatis 相关问题先别急着翻日志或百度报错。先问一句这个问题发生在主链路的哪一层是配置加载层没有解析到正确文件是代理层没有正确绑定坐标是参数层 TypeHandler 没有对上还是结果映射层漏了列把问题定位到层再去看对应的源码片段。你会发现很多看起来神秘的报错本质上都是主链路上某个环节的输入输出没有对齐。这也正是读源码最值钱的部分——它不负责让你背出某个类的方法名而是帮你建立起一种“沿着链路排查”的问题观。有了这张地图弯路自然就少了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →