1. 从“会用”到“懂它”:我们为什么要读MyBatis源码?
如果你是一个Java后端开发者,MyBatis这个名字你肯定不陌生。从早期的iBATIS到现在的MyBatis 3,它几乎成了处理关系型数据库的“标准答案”之一。我们每天都在写Mapper接口、定义XML映射文件、调用SqlSession执行查询。框架用起来很顺手,配置也日渐熟悉,直到有一天,你遇到了一个奇怪的问题:为什么我写的这个动态SQL,在某些条件下生成的语句不对?为什么我配置的插件(Plugin)没有按照预期的顺序执行?又或者,面试官冷不丁地问你:“MyBatis的一级缓存和二级缓存是怎么工作的?在分布式环境下有什么坑?”
这时候,仅仅停留在“会用”的层面就显得捉襟见肘了。阅读源码,不是为了炫技,而是为了在关键时刻能“破局”。它能帮你:
- 精准排错:当遇到诡异的问题时,不再依赖于盲目的Google和Stack Overflow,而是能直接定位到框架内部的执行链路,找到问题的根源。
- 深度定制:理解扩展点(如插件、类型处理器、对象工厂)的设计,让你能游刃有余地编写符合自己业务需求的定制化组件。
- 优化性能:明白缓存机制、执行器(Executor)的工作流程,有助于你在编写SQL和设计数据访问层时做出更优的决策,避免性能陷阱。
- 应对面试:对核心机制的理解,是区分普通开发者和资深开发者的重要标尺。
很多人对源码望而却步,觉得它庞大、复杂。但MyBatis的源码结构清晰,核心流程相对集中,是一个非常好的“源码入门”选择。它不像Spring那样拥有庞大的生态和复杂的抽象层次,MyBatis的目标很纯粹:简化JDBC操作,将Java对象和数据库记录进行灵活映射。接下来,我们就抛开那些枯燥的API文档,直接深入到代码内部,看看这个我们每天都在用的工具,到底是如何运转起来的。
2. 核心架构总览:一张图看懂MyBatis的“五脏六腑”
在深入细节之前,我们需要先建立一个宏观的认知。MyBatis的核心架构可以概括为几个关键组件,它们像精密的齿轮一样协同工作。你可以把一次数据库操作想象成一次“太空发射”:
- 配置系统(Configuration):这是发射控制中心。它加载并解析
mybatis-config.xml全局配置文件以及所有的Mapper.xml文件,将里面的所有信息(数据源、事务管理器、类型别名、插件、映射语句等)统统解析、校验,并最终构建成一个内存中的Configuration对象。这个对象是单例的,是整个MyBatis运行时唯一的核心配置仓库。 - SqlSession:这是面向用户的“指挥舱”接口。开发者通过
SqlSession来执行命令(增删改查)、获取映射器(Mapper)、管理事务。它代表了与数据库的一次会话。但请注意,SqlSession本身只是个门面(Facade),真正的脏活累活都委托给了后面的组件。 - Executor(执行器):这是真正的“火箭发动机”。
SqlSession将命令传递给Executor。Executor负责维护一级缓存(Session级别)、管理Statement、通过StatementHandler与JDBC交互、处理二级缓存(如果启用)。MyBatis有三种基本的执行器:SimpleExecutor(每次执行都创建新的Statement)、ReuseExecutor(复用Statement)、BatchExecutor(批处理)。 - StatementHandler:这是“燃料管路和姿态控制器”。它负责创建
java.sql.Statement(或PreparedStatement、CallableStatement)对象,对SQL语句进行参数化(ParameterHandler介入),以及将结果集映射为Java对象(ResultSetHandler介入)。它是与JDBC API直接对话的桥梁。 - ParameterHandler & ResultSetHandler:这是两个关键的“辅助系统”。
ParameterHandler:负责将传入的Java参数,按照映射规则,设置到PreparedStatement的占位符(?)中。ResultSetHandler:负责将执行SQL后返回的ResultSet结果集,根据映射文件或注解的配置,转换成List<E>或单个Java对象。
- MappedStatement:这是存储在
Configuration中的“飞行任务手册”。每一个<select|insert|update|delete>标签,都会被解析成一个MappedStatement对象,它包含了这条SQL语句的所有元信息:SQL源(可能是动态SQL解析后的)、参数映射、结果映射、缓存配置、语句类型等。
它们之间的协作流程,简化后是这样的:
- 应用启动,
SqlSessionFactoryBuilder读取配置文件,构建出包含完整Configuration的SqlSessionFactory。 - 每次数据库操作,从
SqlSessionFactory中获取一个新的SqlSession。 SqlSession根据方法签名(如selectOne)或Mapper接口的方法,找到对应的MappedStatement。SqlSession将MappedStatement和参数交给Executor。Executor先查一级缓存(如果适用),未命中则委托StatementHandler去准备语句。StatementHandler使用ParameterHandler设置参数,执行JDBC。- 执行完毕后,
StatementHandler使用ResultSetHandler处理结果集。 ResultSetHandler将结果返回给Executor,Executor可能会放入一级缓存,然后返回给SqlSession,最终返回给调用者。
理解了这个主干流程,我们再去看任何具体模块的源码,都不会迷失方向。
3. 源码入口与初始化:SqlSessionFactory的诞生记
一切的故事都从SqlSessionFactory开始。我们通常在代码里这样写:
String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);这行简单的代码背后,隐藏着复杂的初始化过程。SqlSessionFactoryBuilder.build()方法是我们的第一个源码阅读入口。
3.1 配置文件的解析之旅
SqlSessionFactoryBuilder会创建一个XMLConfigBuilder对象。这个类,顾名思义,就是用来解析XML配置的。它的工作流程是典型的“解析-绑定”模式:
解析
<configuration>根标签:它会按顺序解析其下的所有子标签:<properties>:加载外部属性文件,用于后续的占位符替换(比如${jdbc.url})。<settings>:解析几十个运行时行为设置,如是否开启缓存、是否启用延迟加载、日志实现等。每个设置都有默认值,解析后会覆盖默认值。<typeAliases>:为冗长的Java类名起一个简短的别名,方便在映射文件中使用。<plugins>:这是理解MyBatis扩展性的关键。这里配置的拦截器(Interceptor),将会被包装成Plugin对象,并插入到Executor、StatementHandler、ParameterHandler、ResultSetHandler这四个核心组件的创建链中。解析时,会通过Interceptor.plugin()方法返回目标对象的代理。这是责任链模式和动态代理的经典应用。<environments>:配置数据源(DataSource)和事务管理器(TransactionFactory)。这是支持多数据源的基础。<mappers>:重头戏。这里告诉MyBatis去哪里找SQL映射定义。解析器会根据配置(resource, url, class, package)找到对应的Mapper XML文件或接口类,然后交给XMLMapperBuilder或MapperAnnotationBuilder进行下一步解析。
解析Mapper XML文件:对于每一个
Mapper.xml文件,XMLMapperBuilder会:- 解析
<mapper>命名空间。 - 解析其中的每一个SQL语句标签(
<select>,<insert>等)。对于每个标签,会创建一个MappedStatement对象,其id为“命名空间.标签id”,并注册到Configuration的mappedStatements(一个Map<String, MappedStatement>)中。 - 在这个过程中,会处理
<cache>、<cache-ref>定义二级缓存,解析<resultMap>定义复杂的结果映射,解析<sql>片段等。
- 解析
处理动态SQL:在解析SQL语句时,如果遇到
<if>,<choose>,<foreach>等标签,MyBatis并不会在这里就生成最终的SQL字符串。它会把整个标签树解析成一个SqlSource对象。SqlSource是一个接口,主要有两种实现:DynamicSqlSource:对应包含动态标签(OGNL表达式)的SQL。它内部保存了解析后的SQL节点树。RawSqlSource:对应静态的、不含动态标签的SQL。它会在初始化阶段就完成#{}占位符的解析,并预编译成StaticSqlSource,性能更高。
一个重要的实操心得:很多人疑惑
#{}和${}的区别在源码层面如何体现。简单说,#{}在SqlSource被解析时,会被替换成?占位符,然后由ParameterHandler用PreparedStatement.setXXX()来安全设置参数,防止SQL注入。而${}则是在SQL解析阶段就被直接替换成字符串拼接进SQL语句中。所以,绝对不要在用户输入可控的地方使用${}。
3.2Configuration对象的最终成型
当所有配置文件解析完毕,一个包含了全局所有配置信息、所有Mapper语句定义的Configuration对象就构建完成了。SqlSessionFactoryBuilder会用这个Configuration对象实例化一个DefaultSqlSessionFactory(这是SqlSessionFactory的默认实现),并返回给我们。
至此,MyBatis的“静态”初始化工作全部完成。接下来,就是“动态”的运行时了。
4. 一次SQL执行的完整生命周期剖析
假设我们调用sqlSession.selectOne("com.example.BlogMapper.selectBlog", 1),让我们跟随代码,看看这个请求是如何走完一生的。
4.1SqlSession与Executor的交接
DefaultSqlSession.selectOne()方法内部,实际上会调用selectList(),并取返回列表的第一个元素。在selectList()中,核心代码如下:
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) { try { // 1. 根据statement id,从Configuration中获取对应的MappedStatement MappedStatement ms = configuration.getMappedStatement(statement); // 2. 调用Executor执行查询 return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER); } catch (Exception e) { throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e); } finally { ErrorContext.instance().reset(); } }关键点在于第2步:executor.query()。这个executor是在创建SqlSession时,由Configuration里的ExecutorType和设置决定的。它可能被我们配置的插件层层代理。
4.2Executor:缓存与调度中心
我们以默认的SimpleExecutor为例(忽略缓存和插件代理的细节,先看主干):
- 创建
StatementHandler:Executor会先根据MappedStatement中的信息,创建一个StatementHandler。这里用到了策略模式:根据语句类型(STATEMENT, PREPARED, CALLABLE)创建不同的处理器(SimpleStatementHandler,PreparedStatementHandler,CallableStatementHandler)。 - 实例化
Statement:Executor调用StatementHandler.prepare()方法,该方法内部会调用Connection.prepareStatement()来创建JDBC的PreparedStatement对象。 - 参数化:
Executor调用StatementHandler.parameterize()方法。这个方法会调用ParameterHandler.setParameters(),将我们传入的Java参数,按照MappedStatement中定义的参数映射(ParameterMapping),一个个地设置到PreparedStatement的占位符上。 - 执行与结果映射:
Executor调用StatementHandler.query()。这个方法会执行PreparedStatement.execute(),然后调用ResultSetHandler.handleResultSets()来处理返回的ResultSet。
4.3StatementHandler及其左膀右臂
StatementHandler是承上启下的关键。在创建StatementHandler时(通常是通过Configuration.newStatementHandler()),MyBatis会做一件至关重要的事情:插件注入。
public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql); // 应用所有插件(Plugin.wrap),返回一个代理对象 statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler); return statementHandler; }interceptorChain.pluginAll()会遍历所有已注册的拦截器(Interceptor),调用其plugin()方法。通常,拦截器会使用JDK动态代理或CGLIB,对目标对象(这里是StatementHandler)进行包装。这就是为什么我们自定义的插件能够拦截prepare、parameterize、query等方法。
ParameterHandler和ResultSetHandler的创建过程也类似,也会被插件链包装。因此,一次SQL执行可能会经过多个插件的拦截处理。
4.4ResultSetHandler:从结果集到Java对象的魔法
这是ORM(对象关系映射)最核心的一步。DefaultResultSetHandler.handleResultSets()方法逻辑非常复杂,但主干清晰:
- 获取
MappedStatement中定义的ResultMap(结果映射)。 - 遍历
ResultSet。 - 根据
ResultMap的配置,创建目标Java对象(通过对象工厂ObjectFactory)。 - 根据映射规则,将结果集中的列值,通过类型处理器
TypeHandler,设置到Java对象的对应属性中。这个过程会处理简单属性、复杂关联(一对一<association>)、集合关联(一对多<collection>),以及嵌套查询(select属性)带来的N+1问题。 - 如果启用了延迟加载(懒加载),对于关联属性,MyBatis会创建代理对象(通常是Javassist或CGLIB代理),只有在真正访问该属性时,才会触发额外的查询。
一个重要的避坑经验:在处理复杂关联映射时,务必理解“嵌套结果”(
resultMap内联定义)和“嵌套查询”(使用select属性)的区别。嵌套结果通过单表(或连接查询)一次性查出所有数据,在内存中组装对象,性能通常更好,但SQL可能复杂。嵌套查询写法简单,但容易引发“N+1查询问题”(主查询返回N条记录,每条记录再发起一次关联查询)。务必根据数据量和业务场景谨慎选择。
4.5 回到Executor:缓存处理
如果是查询操作,在Executor.query()方法返回前,它会将结果放入一级缓存(LocalCache),其作用域是同一个SqlSession。这意味着,在同一个会话中,完全相同的查询(相同的MappedStatement ID、相同的参数、相同的分页条件等)会直接返回缓存结果,不会再次访问数据库。但要注意,任何insert、update、delete操作都会清空当前SqlSession的一级缓存,这是为了保证数据一致性。
如果配置了二级缓存(<cache/>标签),Executor本身会被CachingExecutor装饰。CachingExecutor会在执行查询前先去二级缓存(一个PerpetualCache实例,通常与Mapper命名空间绑定)查找,命中则返回,未命中则委托给底层Executor(如SimpleExecutor)去数据库查询,并将结果存入二级缓存。二级缓存是跨SqlSession的,多个会话可以共享,因此它的数据一致性需要更小心地维护,通常需要实现序列化接口,并注意事务提交后才更新缓存等细节。
至此,一次简单的selectOne调用,就完成了它在MyBatis内部世界的奇幻漂流。
5. 动态SQL的生成原理:从标签到最终SQL语句
我们经常在XML里写这样的动态SQL:
<select id="findActiveBlogWithTitleLike" resultType="Blog"> SELECT * FROM BLOG WHERE state = ‘ACTIVE’ <if test="title != null"> AND title like #{title} </if> </select>MyBatis是如何把这段XML变成可执行的SQL字符串的呢?关键在于SqlSource和SqlNode。
在初始化解析XML时,包含动态标签的SQL块会被解析成一个MixedSqlNode对象,它包含了一系列SqlNode子节点(如IfSqlNode,TextSqlNode,ForEachSqlNode等)。这个MixedSqlNode被包装在DynamicSqlSource里。
当需要执行SQL时(即调用StatementHandler.prepare()之前),DynamicSqlSource会被调用其getBoundSql()方法:
- 创建
DynamicContext:这是一个上下文对象,持有参数对象和一个StringJoiner(用于拼接SQL)。 - 应用
SqlNode:遍历MixedSqlNode中的每一个SqlNode,调用其apply()方法。IfSqlNode会使用OGNL引擎评估test表达式,决定是否拼接其内部的SQL片段;ForEachSqlNode会遍历集合,生成(item1, item2, ...)这样的片段,并处理参数映射。 - 生成原始SQL字符串:经过所有
SqlNode的处理后,DynamicContext中就得到了一条完整的、但可能还包含#{}占位符的SQL字符串。 - 创建
BoundSql:将上一步的SQL字符串,以及解析出的参数映射关系(List<ParameterMapping>),封装成一个BoundSql对象。BoundSql是最终提供给StatementHandler和ParameterHandler使用的对象,它包含了要执行的SQL和对应的参数信息。
一个调试技巧:在开发中,我们经常想看MyBatis最终执行的SQL是什么。除了开启日志(配置
logImpl为STDOUT_LOGGING或集成Logback等),还可以通过编写一个拦截StatementHandler.prepare方法的插件,在方法执行后,从BoundSql中获取getSql()方法返回的SQL字符串(此时#{}已被替换成?),并结合参数值,手动拼接出完整的、可直接在数据库客户端执行的SQL,这对于复杂动态SQL的调试非常有帮助。
6. 插件(Plugin)机制深度解析:如何优雅地“插手”核心流程
插件是MyBatis框架留给用户的“后门”,功能极其强大。通过实现Interceptor接口,我们可以拦截四大核心组件的方法调用。
6.1 插件的工作原理:动态代理与责任链
- 声明与配置:在
mybatis-config.xml中配置<plugin interceptor="com.example.MyPlugin">。 - 初始化包装:在
Configuration初始化组件(newExecutor,newStatementHandler,newParameterHandler,newResultSetHandler)时,会调用InterceptorChain.pluginAll()。public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target = interceptor.plugin(target); } return target; } - 创建代理:通常,我们会在自定义拦截器的
plugin()方法中调用Plugin.wrap(target, this)。Plugin类实现了InvocationHandler接口,它内部维护了一个Interceptor实例和一个Map<Class<?>, Set<Method>>(表示该拦截器声明要拦截的接口和方法)。wrap方法会判断目标对象是否实现了拦截器注解@Intercepts所声明的接口,如果是,就为其创建一个JDK动态代理。 - 方法拦截:当代理对象的方法被调用时,会触发
Plugin.invoke()。它会检查当前调用的方法是否在拦截范围内。如果是,则调用拦截器的intercept()方法,并将一个Invocation对象(包含了目标对象、方法、参数)传递进去;如果不是,则直接调用目标对象的原方法。
6.2 编写一个实用的分页插件示例
虽然已有PageHelper这样优秀的分页插件,但理解其原理很有必要。一个最简单的分页插件思路是拦截Executor的查询方法,在原始SQL上拼接LIMIT ?, ?(以MySQL为例)。
@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class SimplePaginationInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); RowBounds rowBounds = (RowBounds) args[2]; // 如果使用了默认的RowBounds(不翻页),则直接放行 if (rowBounds == RowBounds.DEFAULT) { return invocation.proceed(); } // 获取原始的MappedStatement和参数 MappedStatement ms = (MappedStatement) args[0]; Object parameter = args[1]; // 获取原始的BoundSql BoundSql boundSql = ms.getBoundSql(parameter); String originalSql = boundSql.getSql(); // 拼接分页SQL String paginationSql = originalSql + " LIMIT " + rowBounds.getOffset() + ", " + rowBounds.getLimit(); // 创建一个新的BoundSql(注意,这里需要反射修改sql属性,因为BoundSql的sql字段是final的,实际插件中会使用MetaObject工具类) // ... 省略反射修改代码 ... // 创建一个新的MappedStatement(通常是原语句的一个副本,但使用新的BoundSql) // ... 省略创建新MappedStatement的代码 ... // 将新的MappedStatement设置回参数中,然后继续执行 args[0] = newMappedStatement; return invocation.proceed(); } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } }这个示例简化了很多细节(如数据库方言、总数查询、线程安全等),但它清晰地展示了插件的核心逻辑:在调用链的某个环节,修改传入的参数(如SQL),从而改变最终的执行行为。
重要注意事项:插件虽然强大,但要慎用。多个插件会形成代理链,执行顺序与配置顺序有关。过度使用或编写不当的插件会严重影响框架性能和稳定性。务必确保插件逻辑高效、无副作用,并做好充分的测试。
7. 结合热点问题与实战思考
回顾我们开头提到的那些热搜词和常见问题,现在可以从源码层面得到更深刻的理解:
#{}和${}的区别:根源在于SqlSource解析阶段,#{}被处理为占位符,由ParameterHandler安全设参;${}则是简单的字符串替换。永远优先使用#{}。- 一级/二级缓存:一级缓存是
SqlSession级别的HashMap,默认开启。二级缓存需手动配置,是Mapper命名空间级别的,底层也是PerpetualCache,但可以通过<cache>标签配置淘汰策略、序列化器等。分布式环境下,一级缓存无影响,二级缓存需要解决数据一致性问题,通常建议使用Redis等集中式缓存替代。 - 插件执行顺序:取决于在
mybatis-config.xml中的配置顺序,因为InterceptorChain是按顺序包装的,执行时也是按包装的逆序进行intercept调用(类似栈)。 - 动态SQL中的
<, >转义:在XML中,<和>是特殊字符,需要转义为<和>,或者将SQL片段包裹在<![CDATA[ ... ]]>中。MyBatis在解析XML时处理的是转义后的字符或CDATA区的内容,生成SQL字符串时不会再有这个问题。 MyBatis Plus的removeBatchByIds与removeByIds区别:虽然这是MP的功能,但原理相通。removeByIds接受的通常是一个集合参数,生成DELETE FROM table WHERE id IN (?, ?, ...)。而removeBatchByIds从名字上看更倾向于进行批量删除操作,可能通过ExecutorType.BATCH执行器来执行,或者在内部对超长ID列表进行分批处理,避免IN语句过长。具体需要查看MP的源码实现。
阅读源码不是一蹴而就的,最好的方式是带着问题去读。下次当你再遇到MyBatis的疑难杂症时,试着打开IDE,沿着本文梳理的主干流程,设置几个断点,一步步跟踪下去。你会发现,源码之下,了无秘密。这份通过自己探索得来的理解,远比背诵面试题要牢固和深刻得多。