
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结果集根据映射文件或注解的配置转换成ListE或单个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将结果返回给ExecutorExecutor可能会放入一级缓存然后返回给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一个MapString, 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 ListE 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为例忽略缓存和插件代理的细节先看主干创建StatementHandlerExecutor会先根据MappedStatement中的信息创建一个StatementHandler。这里用到了策略模式根据语句类型STATEMENT, PREPARED, CALLABLE创建不同的处理器SimpleStatementHandler,PreparedStatementHandler,CallableStatementHandler。实例化StatementExecutor调用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属性带来的N1问题。如果启用了延迟加载懒加载对于关联属性MyBatis会创建代理对象通常是Javassist或CGLIB代理只有在真正访问该属性时才会触发额外的查询。一个重要的避坑经验在处理复杂关联映射时务必理解“嵌套结果”resultMap内联定义和“嵌套查询”使用select属性的区别。嵌套结果通过单表或连接查询一次性查出所有数据在内存中组装对象性能通常更好但SQL可能复杂。嵌套查询写法简单但容易引发“N1查询问题”主查询返回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里写这样的动态SQLselect idfindActiveBlogWithTitleLike resultTypeBlog SELECT * FROM BLOG WHERE state ‘ACTIVE’ if testtitle ! null AND title like #{title} /if /selectMyBatis是如何把这段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字符串以及解析出的参数映射关系ListParameterMapping封装成一个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 interceptorcom.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实例和一个MapClass?, SetMethod表示该拦截器声明要拦截的接口和方法。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中和是特殊字符需要转义为lt;和gt;或者将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沿着本文梳理的主干流程设置几个断点一步步跟踪下去。你会发现源码之下了无秘密。这份通过自己探索得来的理解远比背诵面试题要牢固和深刻得多。