资讯动态

深入MyBatis源码:架构解析、SQL执行流程与缓存机制详解

发布时间:2026/8/12 14:25:21 来源:尧图企业网站定制
1. 项目概述为什么我们要深入Mybatis源码如果你是一名Java后端开发者Mybatis这个名字对你来说一定不陌生。它几乎是处理关系型数据库的“瑞士军刀”从简单的CRUD到复杂的动态SQL它都能优雅地胜任。但你是否曾在使用if标签时疑惑过它是如何被解析的是否在配置多数据源时对SqlSessionFactory的创建过程感到好奇又或者在面试中被问到“Mybatis的一级缓存和二级缓存有什么区别”时只能背出答案却说不清其底层实现这些问题官方文档不会告诉你市面上大多数教程也只会教你“怎么用”。而真正能让你从“会用”到“精通”从“面向百度编程”到“心中有数”的恰恰是深入它的源码。这次我们不谈空泛的理论就以一个一线开发者的视角亲手“解剖”Mybatis看看这个陪伴我们多年的框架内部究竟是如何精巧运转的。这不仅能帮你彻底解决日常开发中的疑难杂症更能让你在架构设计和性能优化时拥有降维打击的能力。2. 核心架构与设计思想拆解在打开IDE跟踪代码之前我们必须先建立起对Mybatis整体架构的宏观认知。如果把Mybatis比作一个餐厅那么它的核心组件和流程大致是这样的SqlSessionFactory餐厅总部这是Mybatis的起点一个应用通常只有一个。它负责读取“菜单”即Mybatis的配置文件如mybatis-config.xml和“菜谱”Mapper XML文件并准备好所有的“厨房设备”和“原材料”数据库连接池、事务管理器、映射关系等。它内部最重要的产物是Configuration对象这个对象是一个全局的、不可变的配置容器承载了所有配置信息。SqlSession一次用餐体验你可以把它理解为一次数据库会话。它是对外提供数据操作API的核心接口。我们通过SqlSession来执行映射的SQL语句、获取Mapper接口的代理对象、管理事务等。重要的是SqlSession是非线程安全的这意味着它不应该被多个线程共享最佳实践是在每次请求或方法中获取用完后立即关闭。Executor厨师长这是SqlSession背后的真正执行者。SqlSession只是门面所有数据库操作命令最终都会交给Executor执行。Executor定义了执行SQL、管理缓存、管理事务等核心方法。Mybatis提供了几种不同的Executor实现比如简单执行器SimpleExecutor、可重用语句的执行器ReuseExecutor以及能批量执行的BatchExecutor。MappedStatement一道菜的完整烹饪指南它是对Mapper XML文件中一个SQL语句如select idselectUser的完全解析和封装。里面包含了SQL源码、参数映射关系、结果映射关系、缓存策略、语句类型SELECT/UPDATE等等所有元数据。每一个SQL语句在应用启动时都会被解析成一个MappedStatement对象并注册到Configuration中。StatementHandler具体炒菜的厨师它负责和JDBC的Statement对象打交道包括创建Statement、参数化将Java对象属性设置到SQL的?占位符、执行SQL、以及将结果集映射成Java对象。ParameterHandler和ResultSetHandler可以看作是它的左右手分别专精于参数处理和结果集处理。这个“餐厅”的工作流程是顾客业务代码向服务员SqlSession点餐调用Mapper方法服务员将订单MappedStatementID和参数交给厨师长Executor厨师长查看是否有现成的菜查询缓存如果没有则指派具体的厨师StatementHandler按照菜谱MappedStatement炒菜最后将成品结果对象经由服务员交给顾客。注意理解这个分层和职责分离的设计至关重要。它使得Mybatis的扩展性极强我们可以在Executor、StatementHandler等环节通过插件Interceptor进行拦截和增强这也是实现分页、数据权限、SQL打印等高级功能的基础。2.1 配置加载与初始化一切故事的开始Mybatis的初始化过程是其稳定性的基石。这个过程主要发生在构建SqlSessionFactory时核心是解析XML配置文件并构建Configuration对象。让我们深入XMLConfigBuilder这个类。它并不是一次性把所有XML读入内存再解析而是采用了递归下降解析的方式按照配置文件的结构顺序逐步调用不同的方法如parseConfiguration、parseMappers来解析不同的节点。解析全局配置首先它会解析settings、typeAliases、plugins等节点。这里有个关键细节settings中的每一项都有默认值解析器会用一个MetaClass工具来检查和赋值确保配置项的合法性。例如mapUnderscoreToCamelCase下划线转驼峰的默认值是false。解析映射器这是最复杂的部分。解析mappers节点时Mybatis支持多种方式通过resource/url指定XML文件路径通过class指定Mapper接口或者通过package名扫描。对于XML方式会创建XMLMapperBuilder来专门解析Mapper XML文件。在解析Mapper XML时对于每一个select|insert|update|delete节点解析器会做以下几件事解析SQL语句本身将其中的#{}和${}占位符信息提取出来生成一个SqlSource对象。#{}会生成包含参数映射的ParameterMapping为后续的参数设置做准备${}则直接进行字符串替换。解析resultMap构建出复杂的ResultMap对象树描述结果集列与Java对象属性之间的映射关系包括嵌套查询association/collection的处理。将以上信息连同语句的id、parameterType、resultType等封装成一个MappedStatement对象并以namespace.id为key存入Configuration的MapString, MappedStatement中。关于Mapper接口的绑定如果你使用的是Mapper接口注解的方式或者在XML解析后Mybatis会使用MapperRegistry来“绑定”Mapper接口。绑定的本质是使用JDK动态代理为每一个Mapper接口生成一个代理对象。当调用接口方法时代理逻辑会根据全限定类名方法名作为key去Configuration里找到对应的MappedStatement然后委托给Executor去执行。这就是为什么Mapper接口不需要实现类也能工作的魔法所在。实操心得初始化过程是性能敏感点尤其当Mapper XML文件很多很大时。在生产环境务必确保这个过程只在应用启动时发生一次。我曾遇到过因为有人在PostConstruct方法里重复创建SqlSessionFactory导致服务启动慢一分钟的案例。另外合理使用typeAliases和package扫描能让配置文件更简洁。3. SQL执行流程的深度剖析理解了架构和初始化我们来看最核心的SQL执行流程。以一次简单的sqlSession.selectOne(com.example.UserMapper.selectById, 1L)调用为例跟踪其完整生命周期。3.1 SqlSession与Executor的协作当我们调用sqlSession.selectOne时DefaultSqlSession内部会做以下事情根据传入的statement id即com.example.UserMapper.selectById从Configuration中获取对应的MappedStatement对象。将查询参数1L进行包装。Mybatis设计了一个BoundSql对象它包含了已经完成了解析处理了动态标签如if、foreach的最终SQL字符串以及参数映射列表ListParameterMapping。调用Executor的query方法。这里就是核心的分水岭。Executor.query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)方法签名揭示了其职责。它首先会检查是否开启了二级缓存并尝试根据CacheKey从二级缓存中获取结果。如果未命中则调用queryFromDatabase。在queryFromDatabase中Executor会先尝试查询一级缓存。一级缓存是SqlSession级别的其本质是Executor内部的一个HashMap在BaseExecutor中名为localCache。CacheKey由MappedStatement的Id、分页参数、最终SQL、参数值等多重因素计算而来确保唯一性。如果一级缓存命中则直接返回不再访问数据库。注意一级缓存默认是开启的在同一个SqlSession内相同的查询会直接返回缓存对象。这有时会导致“脏读”问题例如你修改了数据但后续查询返回的仍是缓存中的旧对象。可以通过sqlSession.clearCache()手动清空或者在select标签上设置flushCachetrue。3.2 StatementHandler的创建与参数化如果缓存未命中Executor会调用newStatementHandler方法创建一个StatementHandler。根据MappedStatement的statementType默认是PREPARED会创建PreparedStatementHandler。接下来是关键一步Executor会通过Transaction对象从数据库连接池获取一个Connection然后调用StatementHandler.prepare方法。在这个方法里StatementHandler会调用Connection.prepareStatement(sql)创建出JDBC的PreparedStatement对象。创建完PreparedStatement后Executor会调用StatementHandler.parameterize方法。这个方法会委托给ParameterHandler来设置参数。ParameterHandler会根据BoundSql中的ParameterMapping列表利用反射或TypeHandler将传入的Java参数对象可能是Map、POJO或简单类型的属性值逐个设置到PreparedStatement的对应占位符?上。关于TypeHandler的妙用TypeHandler是Mybatis类型转换的基石。例如将Java的Date类型转换为JDBC的Timestamp或者将枚举类型转换为数据库的String或Integer。自定义TypeHandler可以让你轻松处理数据库字段与Java属性间复杂的映射关系比如将JSON字符串映射为ListString属性。3.3 SQL执行与结果集映射参数设置完毕后Executor调用StatementHandler.query方法最终执行PreparedStatement.execute()。拿到JDBC的ResultSet后最精妙的一环开始了结果集映射。Executor会调用ResultSetHandler.handleResultSets方法。这个方法负责将原始的ResultSet转换成我们指定的结果对象单个对象、List、Map等。其核心流程如下从MappedStatement中获取ResultMap。遍历ResultSet的每一行。根据ResultMap的规则通过反射创建目标结果对象如果返回类型是接口会使用Javassist动态生成实现类。遍历ResultMap中定义的id,result,association,collection等映射规则。对于简单属性result使用对应的TypeHandler从ResultSet中取出列值并设置到对象的属性中。对于关联对象association这可能触发嵌套查询Nested Select或嵌套结果映射Nested Results。嵌套查询会发起一次新的数据库查询存在N1问题风险嵌套结果映射则依赖于SQL JOIN通过一次查询获取所有数据然后在内存中进行对象组装性能更优。将组装好的对象放入结果列表List中。最终这个结果列表被返回。Executor会将其放入一级缓存localCache如果配置了二级缓存也会异步地放入二级缓存。最后结果经由SqlSession返回给调用者。4. 动态SQL与插件机制的实现原理4.1 动态SQLOGNL与SqlNode树Mybatis动态SQLif,choose,foreach等的功能强大且易用其底层实现基于OGNL表达式和一颗SqlNode组成的语法树。在解析Mapper XML时所有动态SQL标签和静态文本都会被解析成一个个SqlNode对象。例如if标签对应IfSqlNodeforeach对应ForEachSqlNode纯文本对应StaticTextSqlNode。这些SqlNode会按照XML中的嵌套结构组成一颗树。当需要执行SQL时DynamicSqlSource负责处理包含动态标签的SQL会调用SqlNode树的apply方法。这个方法会传入一个DynamicContext对象作为上下文其中包含了本次执行的参数对象。apply过程是一个递归遍历IfSqlNode会使用OGNL引擎评估其test属性表达式如testname ! null and name ! 。OGNL会从参数对象或_parameter中取出name属性的值进行判断。如果为true则继续应用其子SqlNode否则跳过。ForEachSqlNode会使用OGNL取出参数中的集合如list然后遍历集合每次遍历将当前项放入上下文并应用其子SqlNode同时负责生成(item1, item2, ...)这样的SQL片段和对应的参数映射。所有SqlNode在应用过程中都会将自身需要贡献的SQL片段追加到DynamicContext的sqlBuilder中并可能向其中注册新的参数映射。最终DynamicContext中拼接出来的就是最终的、静态的SQL字符串同时也有了完整的参数映射列表。这个结果被封装进BoundSql供后续ParameterHandler使用。避坑技巧动态SQL的拼接是在每次执行时发生的有一定性能开销。对于极其复杂或执行频率极高的SQL可以考虑使用Provider SQL通过SelectProvider注解在Java代码中拼接SQL有时能获得更好的控制和性能。另外OGNL表达式功能强大但要小心使用避免在表达式中执行复杂逻辑或调用不安全方法。4.2 插件机制责任链模式与动态代理Mybatis的插件Interceptor是其扩展性的灵魂。它允许我们在四大核心对象Executor,StatementHandler,ParameterHandler,ResultSetHandler的方法执行前后插入自定义逻辑。其实现基于责任链模式和JDK动态代理。1. 插件声明与加载在配置文件中通过plugins标签声明插件类。插件类必须实现Interceptor接口并使用Intercepts和Signature注解指定要拦截的目标方法。在初始化时InterceptorChain会保存所有插件。2. 代理对象的创建在Configuration创建上述四大对象的时候例如newExecutor,newStatementHandler会调用InterceptorChain.pluginAll()方法。这个方法会遍历所有插件对目标对象进行层层包装。// 简化示意 public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target interceptor.plugin(target); } return target; }通常插件的plugin方法会调用Plugin.wrap(target, this)。Plugin类实现了InvocationHandler它利用JDK动态代理为目标对象生成一个代理对象。这个代理对象会检查当前调用的方法是否匹配插件声明的Signature如果匹配则转而调用插件的intercept方法否则调用原目标方法。3. 责任链的执行由于多个插件会层层包装最终形成的是一条代理链。调用从最外层的代理开始每个插件的intercept方法内部通过调用Invocation.proceed()来将调用传递到链中的下一个节点可能是另一个插件也可能是最终的真实对象。编写一个简单的SQL执行时间统计插件Intercepts({ Signature(type StatementHandler.class, method query, args {Statement.class, ResultHandler.class}), Signature(type StatementHandler.class, method update, args {Statement.class}) }) public class SqlCostTimeInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { long startTime System.currentTimeMillis(); try { return invocation.proceed(); // 执行原方法 } finally { long cost System.currentTimeMillis() - startTime; StatementHandler statementHandler (StatementHandler) invocation.getTarget(); BoundSql boundSql statementHandler.getBoundSql(); System.out.println(SQL执行耗时 [ cost ms]: boundSql.getSql()); } } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } // 设置属性方法省略... }这个插件拦截了StatementHandler的query和update方法在方法执行前后记录时间从而统计出SQL执行耗时。通过插件我们可以轻松实现分页拦截Executor或StatementHandler修改SQL、数据脱敏拦截ResultSetHandler修改结果、SQL审计等功能。注意事项插件会改变Mybatis底层行为需谨慎使用。避免在插件中编写耗时或抛出异常的逻辑这会影响主流程。另外插件的加载顺序很重要因为责任链的顺序决定了执行顺序。理解Invocation.proceed()的调用是理解插件链执行的关键。5. 缓存机制一级与二级缓存源码详解缓存是提升数据库访问性能的利器Mybatis提供了两级缓存结构理解其实现和差异对正确使用至关重要。5.1 一级缓存SqlSession级别的缓存实现位置BaseExecutor中的localCache一个HashMap和localOutputParameterCache。生命周期与SqlSession同生共死。当SqlSession被关闭或执行了提交/回滚commit/rollback操作时一级缓存会被清空。执行insert、update、delete操作无论是否成功也会清空该SqlSession的一级缓存这是为了避免脏读。Key的生成CacheKey由以下部分计算哈希值而得MappedStatement的Id分页参数RowBounds的offset和limit最终要执行的SQL字符串BoundSql.getSql()用户传入的参数值parameter环境IdEnvironment.id 这个复杂的计算确保了查询条件稍有不同就是不同的缓存Key。工作流程在BaseExecutor.query方法中会先根据上述要素创建CacheKey然后查询localCache。如果命中直接返回如果未命中查询数据库并将结果存入localCache。常见问题在分布式或微服务场景下一级缓存几乎无效因为请求可能由不同线程或不同服务实例处理SqlSession无法共享。此外在同一个SqlSession中如果你先查询A数据然后另一个方法修改了A数据即使是通过Mybatis的update方法紧接着再查询A数据由于一级缓存的存在你可能会拿到旧数据。这是开发中一个非常隐蔽的坑。我的建议是对于读多写少、且对实时性要求不高的数据可以积极使用二级缓存对于写多或要求强一致性的场景直接关闭缓存flushCachetrue。5.2 二级缓存Mapper/Namespace级别的缓存实现位置它是一个独立的模块默认实现是PerpetualCache但可以通过cache标签配置使用Ehcache、Redis等第三方缓存。二级缓存是跨SqlSession的。启用与配置在Mapper XML文件中添加cache/标签即可启用。可以配置eviction淘汰策略如LRU、flushInterval刷新间隔、size引用数目等属性。工作原理存储结构二级缓存的存储与MappedStatement关联。当某个select语句配置了useCachetrue默认时它的查询结果就有资格被放入二级缓存。读写流程当CachingExecutor它是Executor的装饰器执行查询时会先尝试从二级缓存获取。获取到则直接返回。未命中则委托给被装饰的Executor如SimpleExecutor去查询数据库然后将结果存入二级缓存。缓存更新当执行insert、update、delete操作时CachingExecutor会清空该Mapper namespace下的所有缓存。这是通过TransactionalCacheManager来管理的它维护了多个TransactionalCache对象每个对应一个namespace。清空操作是事务性的在事务提交时才真正生效避免了脏数据问题。序列化要求由于二级缓存可能被存储到磁盘或通过网络传输如使用Redis因此缓存的对象必须实现Serializable接口。否则在尝试序列化时会抛出异常。与一级缓存的交互当启用二级缓存后查询顺序是二级缓存 - 一级缓存 - 数据库。数据从数据库查询出来后会同时放入一级缓存和二级缓存。但是当SqlSession关闭时它的一级缓存内容会被清空而二级缓存中的数据会保留。代码层面的关键点查看CachingExecutor的query方法你会发现它创建CacheKey的逻辑与一级缓存类似。但它在查询前会先通过MappedStatement.getCache()获取该语句对应的缓存对象。TransactionalCache起到了一个“暂存区”的作用在事务提交前所有的缓存put操作只是记录在entriesToAddOnCommit这个Map中提交时才批量写入底层缓存如PerpetualCache这保证了事务内的可见性。实操心得二级缓存是一把双刃剑。在单机应用且数据更新不频繁的场景下它能极大提升性能。但在分布式环境下直接使用Mybatis自带的基于内存的二级缓存会导致数据不一致问题各个节点缓存不同步。生产环境中如果要用二级缓存强烈建议集成Redis、Memcached等集中式缓存中间件并妥善设置缓存的过期和淘汰策略。对于财务、交易等强一致性要求的业务宁可不用缓存也不要使用不可靠的缓存。6. 高级特性与自定义扩展实战6.1 类型处理器与枚举映射TypeHandler是Mybatis处理Java类型与JDBC类型转换的桥梁。自定义TypeHandler可以解决很多棘手问题。场景数据库字段是VARCHAR存储JSON数组字符串如[admin, user]我们希望在Java实体中映射为ListString roles。实现创建一个实现TypeHandlerListString的类。在setParameter方法中将List用JSON库如Jackson序列化成字符串设置到PreparedStatement中。在getResult方法中从ResultSet中取出字符串用JSON库反序列化成List。在Mapper XML中对应的result标签上通过typeHandler属性指定这个自定义处理器或者在mybatis-config.xml中全局注册。枚举映射的最佳实践Mybatis内置了EnumTypeHandler存储枚举的name()和EnumOrdinalTypeHandler存储枚举的ordinal()。但更推荐使用自定义TypeHandler来实现“编码-枚举”的映射。例如数据库存1代表ACTIVE状态存0代表INACTIVE。自定义处理器可以精准控制存到数据库的值和从数据库读出的转换逻辑比使用EnumValue注解Mybatis-Plus特性或实现IntegerCodeEnum接口更加灵活和直观。6.2 脚本语言驱动与动态SQL扩展Mybatis 3.2支持使用脚本语言如Velocity、Freemarker来编写动态SQL这比传统的OGNLXML标签方式更强大。配置在mybatis-config.xml中配置语言驱动。typeAliases typeAlias aliasvelocity typeorg.mybatis.scripting.velocity.VelocityLanguageDriver/ /typeAliases使用在Mapper接口的方法上使用Lang注解。Lang(VelocityLanguageDriver.class) Select(SELECT * FROM user WHERE #if($name)name {name}#end) ListUser selectByCondition(Param(name) String name);这样你就可以利用Velocity等模板引擎的全部能力来构建SQL实现极其复杂的动态逻辑。这对于从旧系统迁移或需要高度灵活SQL生成的场景非常有用。6.3 自定义执行器与批处理优化默认的SimpleExecutor为每条语句创建新的PreparedStatement。ReuseExecutor会在同一个SqlSession内复用PreparedStatement。BatchExecutor则专门用于批处理。深入BatchExecutor当你调用sqlSession.flushStatements()时BatchExecutor才会真正将累积的SQL语句发送到数据库执行。它的内部维护了一个ListStatement。对于批处理更新性能提升显著。自定义场景你可以继承BaseExecutor实现自己的执行器。例如实现一个“只读从库写走主库”的智能路由执行器。在doQuery方法中根据SQL语句类型SELECT/UPDATE或自定义注解从不同的数据源Connection中获取连接。这需要与自定义的数据源或Spring的AbstractRoutingDataSource结合使用。7. 生产环境问题排查与性能调优7.1 常见问题排查实录问题1N1查询问题现象查询一个用户列表1条SQL然后循环列表查询每个用户的订单N条SQL导致数据库压力巨大。源码根源在ResultSetHandler处理嵌套查询association select...时会为父结果集中的每一条记录发起一次新的数据库查询。解决方案改用嵌套结果映射在SQL中使用JOIN一次性查出所有数据在resultMap中使用association/collection的resultMap属性而非select属性进行映射。这是首选方案。开启Mybatis的懒加载在association/collection上配置fetchTypelazy。这样只有在真正访问关联属性时才会触发查询。但需注意这可能会在事务外触发查询导致LazyInitializationException在如Spring的Open Session In View模式关闭时。使用Mybatis-Plus的TableField注解或手动在Service层进行数据组装。问题2分页查询性能差现象使用RowBounds进行逻辑分页在内存中分页当数据量巨大时内存溢出。源码分析DefaultResultSetHandler在处理带有RowBounds的查询时会遍历整个结果集跳过offset之前的记录然后取limit条。这意味着数据库会返回所有数据网络和内存开销都很大。解决方案使用物理分页插件如PageHelper。其原理是通过插件拦截Executor在查询执行前使用数据库方言如MySQL的LIMIT重写SQL。这是最推荐的方式。手动编写分页SQL在Mapper XML中直接使用数据库特定的分页语法。问题3动态SQL中的test判断空字符串失效现象if testname ! null and name ! 当name是空字符串时条件依然成立。根源OGNL对于空字符串的判断逻辑。在旧版本中name ! 可能被解析为name ! null。更可靠的写法是使用Mybatis内置的_parameter和OGNL方法if test_parameter ! null and _parameter.name ! null and _parameter.name.length() 0或者升级到较新的Mybatis版本。7.2 性能调优经验Statement重用在循环中执行相同结构的SQL时考虑使用ReuseExecutor。你可以通过sqlSessionFactory.openSession(ExecutorType.REUSE)来获取一个使用ReuseExecutor的SqlSession。批量操作对于大批量数据插入或更新务必使用BatchExecutor。通过sqlSessionFactory.openSession(ExecutorType.BATCH)获取会话并记得在循环结束后调用sqlSession.commit()和sqlSession.flushStatements()。结果集处理如果查询结果集非常大且你只需要处理一部分可以使用RowBounds进行逻辑分页或者更优的是让StatementHandler使用ResultSet.TYPE_FORWARD_ONLY和ResultSet.CONCUR_READ_ONLY并设置fetchSize为一个合理的值如1000进行流式处理。这需要自定义插件来设置Statement的属性。缓存评估使用Arthas等工具监控缓存命中率。对于热点数据合理配置二级缓存并设置合适的过期时间。对于更新频繁的数据果断关闭缓存。SQL监控使用前面提到的自定义插件或者集成P6Spy等工具记录所有SQL的执行时间、参数便于发现慢查询。结合EXPLAIN命令分析SQL执行计划。最后我想说的是阅读源码不是为了炫技而是为了在遇到黑盒问题时能有拨云见日的能力在设计系统时能做出更合理的选型和折中。Mybatis的源码设计精良模块清晰是学习优秀Java框架设计的绝佳范本。下次当你再遇到一个棘手的Mybatis问题时不妨试着打开IDE沿着调用栈走下去答案很可能就在源码的某个角落里静静等着你。这个过程本身就是一次极好的成长。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价