三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

深入MyBatis源码:从动态SQL到插件机制,掌握ORM框架核心原理

深入MyBatis源码:从动态SQL到插件机制,掌握ORM框架核心原理

1. 从一次线上慢查询说起:为什么我们要深入Mybatis源码

那天下午,监控系统突然告警,一个核心接口的响应时间从平时的50ms飙升至2秒以上。团队立刻进入战斗状态,数据库监控显示CPU使用率正常,但慢查询日志里多出了几条执行时间超过1秒的SQL。奇怪的是,这些SQL的查询条件看起来并不复杂,而且索引也是齐全的。我们第一时间怀疑是Mybatis的映射文件写错了,或者参数绑定出了问题。经过一番紧张的排查,最终定位到问题出在一个不起眼的动态SQL标签<if>上,它内部的条件判断逻辑在特定参数组合下,生成了我们意料之外的SQL片段,导致全表扫描。

这次经历让我深刻意识到,仅仅会使用Mybatis的API和XML配置是远远不够的。当系统复杂度上升,遇到性能瓶颈、诡异的N+1查询问题、动态SQL的“灵异”行为,或者需要深度定制插件来满足审计、分页等非功能性需求时,对Mybatis核心运行机制的理解就成了解决问题的关键。网上零散的“面试宝典”和“配置教程”只能解决表面问题,真正要驾驭它,必须深入到源码层面,理解它的设计哲学和运行脉络。

Mybatis作为一个优秀的半自动化ORM框架,其核心价值在于将SQL的灵活性与Java对象的便捷操作相结合。但这份灵活性也带来了复杂性。它的源码,就像一张精密的地图,指引着我们理解SQL是如何从一串XML文本或注解,变成在数据库里执行的命令;参数是如何安全地绑定;结果又是如何神奇地填充到我们定义的Java对象中的。接下来,我将结合自己多次阅读源码和解决实际问题的经验,带你一起拆解Mybatis的核心骨架,我们不仅看它“是什么”,更要弄明白它“为什么”这样设计。

2. 骨架初探:Mybatis的核心运行流程与三大件

要理解Mybatis,不能一上来就钻进某个类的细节里,那样很容易迷失。我们必须先站在高处,俯瞰它的核心工作流程。简单来说,Mybatis完成一次数据库操作,可以概括为以下几个核心阶段:

  1. 配置加载与解析阶段:这是一切的起点。Mybatis通过SqlSessionFactoryBuilder读取mybatis-config.xml全局配置文件,以及所有的Mapper.xml文件。这个过程会构建出整个框架运行所需的“世界模型”——Configuration对象。这个对象是单例的,是Mybatis的配置信息中心,包含了数据源、事务管理器、所有映射语句(MappedStatement)、缓存等一切信息。
  2. 会话创建阶段:每次数据库操作都需要通过SqlSessionFactory创建一个SqlSession。你可以把SqlSession理解为一次数据库会话或一次工作单元。它提供了执行SQL、获取Mapper接口代理对象、管理事务等核心方法。这里有个关键点SqlSession只是一个门面,真正的执行逻辑委托给了Executor(执行器)。
  3. SQL执行阶段:这是最复杂也最精彩的部分。当我们调用SqlSession.selectOne()或通过Mapper接口调用一个方法时,Mybatis会找到对应的MappedStatement,然后交给Executor去执行。Executor会先查询缓存(如果开启),然后通过StatementHandler来创建PreparedStatement对象,接着由ParameterHandler处理参数映射,执行SQL,最后由ResultSetHandler将结果集映射成Java对象。

这个流程中的ExecutorStatementHandlerParameterHandlerResultSetHandler,它们被称为Mybatis的“四大组件”。但在我看来,ConfigurationSqlSessionExecutor才是支撑起整个流程的“三大件”。

Configuration:配置的宇宙所有在XML和注解里写的东西,最终都会解析到Configuration对象里。它内部有几个非常重要的Map:

  • Map<String, MappedStatement> mappedStatements: 键是namespace.id,值是封装了一条SQL所有信息的MappedStatement对象,它是执行时的蓝图。
  • Map<String, ResultMap> resultMaps: 存储所有的结果集映射规则。
  • Map<String, Cache> caches: 存储命名空间级别的缓存。 这个对象在初始化后基本是只读的,保证了线程安全。理解Configuration,你就拿到了Mybatis的全局配置清单。

SqlSession:用户交互的门面我们平时打交道最多的就是它。但DefaultSqlSession本身并不干活,它内部持有一个Executor引用,所有方法如selectListinsert,都是转调Executor的对应方法。这种门面模式简化了用户接口,将复杂的执行逻辑隐藏在后面。一个常见的误区是认为SqlSession是线程不安全的,所以要在方法内用完即关。其实不安全的根本原因是它内部持有的Executor可能关联着数据库连接和事务状态,在并发环境下混用会导致数据混乱。因此,最佳实践是确保每个线程使用独立的SqlSession(通常通过SqlSessionTemplate与Spring集成来自动管理)。

Executor:真正的执行引擎这是Mybatis的核心调度者。它有几种实现:

  • SimpleExecutor:最简单的实现,每次执行都会创建一个新的Statement对象,用完后关闭。
  • ReuseExecutor:会重用预处理语句PreparedStatement,以减少创建和解析的开销。
  • BatchExecutor:用于批量操作。
  • CachingExecutor:装饰器模式,为其他Executor添加二级缓存功能。

默认情况下,如果不配置,Mybatis使用的是SimpleExecutorExecutor的职责链包括获取连接、准备语句、参数处理、执行SQL、结果处理、事务提交/回滚等。插件(Plugin)机制也正是通过动态代理,拦截Executor(以及另外三个组件)的方法来实现功能的。

提示:在阅读源码时,我建议以一次简单的查询为线索,用调试模式跟着程序走一遍这个流程。从SqlSessionFactory.openSession()开始,到sqlSession.selectList(“namespace.id”),一步步跟进。你会清晰地看到Configuration如何被查找,Executor如何被调用,StatementHandler如何被创建。这个过程是理解Mybatis源码最有效的捷径。

3. 映射语句的诞生:从XML/注解到MappedStatement

我们写在Mapper接口和XML文件里的那些@Select注解或者<select>标签,最终是如何变成Mybatis能够执行的指令的呢?这个过程发生在配置加载阶段,核心类是XMLConfigBuilderXMLMapperBuilder

配置文件的解析之旅SqlSessionFactoryBuilder.build()方法会创建一个XMLConfigBuilder来解析全局配置文件。这个解析过程是递归的:

  1. 解析<properties>,处理占位符。
  2. 解析<settings>,这决定了Mybatis的运行时行为,比如是否启用缓存、是否使用延迟加载、日志实现等。这里隐藏了一个性能关键点lazyLoadingEnabled(延迟加载)和aggressiveLazyLoading(侵略性延迟加载)的设置,直接影响关联查询的SQL发送策略,设置不当会导致著名的“N+1查询问题”。
  3. 解析<environments>,建立起数据源(DataSource)和事务管理器(TransactionFactory)。
  4. 解析<mappers>,这是重头戏。对于每个Mapper,会创建一个XMLMapperBuilder来专门解析对应的XML映射文件。

Mapper XML的深度解析XMLMapperBuilder.parse()方法负责将我们熟悉的<mapper>节点转化为Configuration中的元数据。对于每一个<select|insert|update|delete>节点:

  1. 解析SQL命令类型、语句ID、参数类型、返回类型等基础属性
  2. 解析SQL脚本:这是动态SQL发挥作用的地方。节点内的文本和子标签(如<if>,<where>,<foreach>)并不会被直接当作SQL字符串。Mybatis会使用XMLScriptBuilder来解析这些节点,构建出一个SqlSource对象。
    • SqlSource可以理解为“SQL源”,它知道如何根据传入的参数对象,产出一个可执行的SQL字符串(BoundSql)。对于静态SQL(没有<if>等标签),它是RawSqlSource;对于动态SQL,它是DynamicSqlSource
    • DynamicSqlSource内部包含了一个SqlNode的根节点(通常是MixedSqlNode),它组合了各种动态标签对应的SqlNode(如IfSqlNode,TrimSqlNode,ForEachSqlNode)。在运行时,根据参数值,这些SqlNode会决定是否将自身包含的SQL片段拼接到最终的SQL中。
  3. 解析结果映射<resultMap>标签会被解析成ResultMap对象,它包含一个List<ResultMapping>,定义了数据库列名到Java对象属性的复杂映射关系,包括嵌套查询(association,collection)和自动映射。
  4. 构建MappedStatement:最后,将上面解析出的所有元素——SqlSourceResultMap、语句ID、配置等——封装进一个MappedStatement对象,并以namespace.id为键,存入Configuration.mappedStatements这个大的注册表中。

关于Mapper接口的绑定你可能会有疑问:XML解析完了,那Mapper接口呢?这个过程是通过MapperRegistryMapperAnnotationBuilder完成的。在解析Mapper XML的后期,XMLMapperBuilder会尝试绑定对应的Mapper接口。绑定过程主要是为接口中的每个方法,在Configuration中找到对应的MappedStatement(通过namespace.methodName匹配)。如果方法使用了注解(如@Select),MapperAnnotationBuilder会直接根据注解内容创建MappedStatement。最终,通过JDK动态代理,为Mapper接口生成一个代理对象。当我们调用mapper.selectUser(1)时,实际上调用的是代理对象的invoke方法,该方法会转而调用SqlSession的对应方法(如selectOne),并传入之前存储好的MappedStatement的ID。

注意:很多人对#{}${}的区别停留在“防SQL注入”的层面。从源码角度看,它们的处理时机和方式截然不同。#{}SqlSource被解析为BoundSql时,会被替换成?占位符,其参数值由ParameterHandler在后续阶段通过PreparedStatement.setXXX()来设置,这是安全的。而${}在SQL解析阶段就会被直接替换成对应的参数值字符串,是简单的文本替换,因此存在注入风险,通常仅用于动态指定表名、列名等非值参数。

4. 执行引擎的奥秘:Executor与插件机制

SqlSession拿到一个方法调用请求和对应的MappedStatementID后,它就退居二线,把舞台交给了ExecutorExecutor是执行过程的指挥官,它的设计充分体现了责任链和模板方法模式。

一次查询的微观旅程CachingExecutor.query()为例(这是最常用的场景,因为二级缓存默认是启用的):

  1. 缓存查询:首先,根据MappedStatement的ID、查询参数、行边界等生成一个缓存Key。然后去二级缓存(TransactionalCache)中查找。如果命中,直接返回。
  2. 委托执行:如果缓存未命中,则调用被装饰的BaseExecutor(比如SimpleExecutor)的query()方法。BaseExecutor会先查询一级缓存(本地缓存,PerpetualCache),如果命中则返回。
  3. 数据库查询:如果一二级缓存均未命中,则执行queryFromDatabase()。这是一个模板方法,其中调用了抽象方法doQuery()。以SimpleExecutor.doQuery()为例: a.创建StatementHandler:根据MappedStatementStatementTypeSTATEMENT,PREPARED,CALLABLE)创建对应的StatementHandlerSimpleStatementHandler,PreparedStatementHandler,CallableStatementHandler)。 b.准备语句:调用StatementHandler.prepare()获取Connection并创建Statement/PreparedStatement。 c.参数处理:调用StatementHandler.parameterize()->ParameterHandler.setParameters(),这里会遍历BoundSql中的参数映射列表,使用TypeHandler将Java类型的参数值设置到PreparedStatement的占位符上。 d.执行与结果映射:调用StatementHandler.query()执行SQL,然后通过ResultSetHandler.handleResultSets()将返回的ResultSet转换成我们指定的结果对象(List<Object>或单个Object)。这个过程涉及复杂的嵌套查询和延迟加载判断。
  4. 缓存写入:查询结果返回后,BaseExecutor会将其写入一级缓存。CachingExecutor在事务提交后,才会将结果真正提交到二级缓存。

插件机制:无侵入的扩展点Mybatis的插件是其框架设计中最精妙的部分之一,它基于JDK动态代理实现,可以在不修改核心代码的情况下,拦截四大组件(Executor,StatementHandler,ParameterHandler,ResultSetHandler)的方法。

  1. 实现原理:所有插件都需要实现Interceptor接口,并标注@Intercepts@Signature注解,指定要拦截的组件类型、方法及参数。在配置加载时,InterceptorChain会收集所有插件。
  2. 代理包装:当Mybatis创建上述四大组件的实例时,会调用InterceptorChain.pluginAll()方法。这个方法会遍历所有插件,对当前对象进行层层包装(Plugin.wrap(target, interceptor))。最终得到的对象是一个被多个代理层层包裹的目标对象。
  3. 执行拦截:当调用被代理对象的方法时,会经过插件链。每个插件的intercept()方法可以决定是否执行目标方法、何时执行、以及如何处理执行结果。经典的PageHelper分页插件,就是通过拦截Executor的查询方法,在原有SQL上动态添加LIMIT子句实现的。

踩坑实录:插件执行顺序与代理链我曾遇到一个自定义插件失效的问题。插件意图在SQL执行前后打印日志,但配置后毫无反应。调试后发现,问题出在插件的@Signature注解上,我指定拦截的Executor方法不够精确。更深层的原因是,插件的执行顺序与它们在配置文件中的声明顺序相反(类似栈,后配置的先执行)。如果多个插件拦截同一个方法,它们会形成一个代理链。理解这个链式调用顺序,对于编写复杂的插件至关重要。例如,如果你的插件需要修改SQL,它最好在分页插件之后执行,这样才能对已经添加了分页条件的SQL进行修改。

5. 结果映射的魔法:ResultSetHandler与延迟加载

ResultSet转换成Java对象,是ORM框架的灵魂。Mybatis的DefaultResultSetHandler承担了这个繁重的任务,它的handleResultSets()方法是结果映射的入口。

自动映射与ResultMap结果映射有两种方式:自动映射和基于ResultMap的显式映射。

  • 自动映射:当查询的列名(或别名)与Java对象的属性名(驼峰转换后)匹配时,Mybatis会自动调用属性的setter方法进行填充。这背后是MetaObject在起作用,它通过反射和缓存机制高效地操作对象属性。
  • 显式映射:通过<resultMap>定义,可以处理更复杂的场景,如列名与属性名不一致、处理枚举类型、进行类型转换(通过TypeHandler),以及处理关联关系(一对一<association>、一对多<collection>)。

嵌套查询与“N+1”问题<association><collection>标签可以通过select属性指定另一个查询的ID,来实现嵌套查询。例如,查询Blog时,通过author_id再去查Author这是“N+1”问题的典型来源:如果查询出N篇博客,就会触发N次对作者的查询。Mybatis提供了两种解决方案:

  1. 嵌套结果映射:使用<association>resultMap属性,通过JOIN SQL一次性查出所有数据,然后在结果映射层面进行对象的组装。这需要编写复杂的SQL和ResultMap,但性能最优。
  2. 延迟加载:在全局设置中开启lazyLoadingEnabled,并在嵌套映射中设置fetchType=“lazy”。这样,关联对象只有在被真正访问时才会触发查询。

延迟加载的源码实现延迟加载是Mybatis高级特性之一,其实现非常巧妙。它并不是在ResultSetHandler里直接返回真实对象,而是返回一个由JavassistCGLIB生成的代理对象。

  1. DefaultResultSetHandler创建结果对象时,如果发现某个属性配置了延迟加载,它不会立即执行嵌套查询。
  2. 它会为该属性创建一个ProxyFactory(默认是JavassistProxyFactory),生成一个代理对象,并将触发加载所需的信息(如ExecutorMappedStatement、参数等)封装到一个MethodInvoker(或类似的触发器)中,存入代理对象。
  3. 当程序第一次调用代理对象的getter方法时,拦截器会触发,执行之前保存的嵌套查询,并将真实对象加载回来,替换掉代理对象本身(或填充其属性)。

注意:延迟加载虽然方便,但需要警惕两个坑。第一是“序列化陷阱”,被代理的对象在序列化/反序列化后,代理会失效,可能导致延迟加载触发异常。第二是作用域问题,延迟加载的执行需要SqlSession仍然处于打开状态。在Web应用中,如果采用“每次请求一个Session”的模式,在视图层(如JSP)渲染时尝试延迟加载,而此时SqlSession已经关闭,就会抛出异常。这就是为什么常使用OpenSessionInView模式或在Service层就完成所有必要数据的加载。

6. 动态SQL的编译与执行:SqlSource与SqlNode

动态SQL是Mybatis区别于其他ORM框架的一大特色。它允许我们在XML中编写带有逻辑判断的SQL片段。理解其原理,能帮助我们写出更高效、更安全的动态SQL。

从XML标签到SqlNode树在配置解析阶段,XMLScriptBuilder会将<select>节点下的所有内容解析成一棵SqlNode树。每个动态标签对应一种SqlNode实现:

  • StaticTextSqlNode: 代表纯文本SQL片段。
  • IfSqlNode: 包含test表达式,根据OGNL表达式结果决定是否包含其子节点。
  • TrimSqlNode/WhereSqlNode/SetSqlNode: 用于智能处理SQL片段的前缀、后缀和多余连接词(如AND, OR, 逗号)。
  • ForEachSqlNode: 处理循环,生成(item1, item2, ...)item1, item2这样的片段。
  • VarDeclSqlNode: 变量声明。

运行时:从SqlNode到BoundSql当执行查询时,DynamicSqlSource.getBoundSql()方法会被调用。它做了两件核心事:

  1. 创建DynamicContext:这是一个上下文对象,持有参数对象(parameterObject)和一个StringJoiner(用于拼接SQL)。
  2. 应用SqlNode:从根SqlNode(通常是MixedSqlNode)开始,调用其apply(DynamicContext context)方法。每个SqlNode根据当前参数和自身逻辑,决定是否向contextStringJoiner中追加SQL文本片段。例如,IfSqlNode会使用OGNL引擎评估test表达式,如果为真,则执行其子节点的apply方法。
  3. 生成BoundSql:遍历完整个SqlNode树后,DynamicContext中就得到了完整的、已进行过文本替换(处理了${})的SQL字符串。同时,在遍历过程中,所有遇到的#{}占位符信息(参数映射ParameterMapping)也被收集起来。最终,用SQL字符串和参数映射列表构建出BoundSql对象。

性能考量与最佳实践动态SQL的解析是在运行时每次执行都会发生的吗?对于DynamicSqlSource,是的,因为每次参数不同,生成的SQL可能不同。但对于RawSqlSource(静态SQL),其BoundSql在框架初始化时就创建好了,可以被复用,性能更高。 因此,一个重要的优化建议是:尽量减少动态SQL的复杂度,将能静态化的部分尽量静态化。例如,避免在循环中嵌套大量的<if>判断。对于极其复杂且多变的查询条件,可以考虑使用<choose><when>...</choose>结构,或者评估是否适合用Provider注解(@SelectProvider)在Java代码中动态构建SQL,以获得更强的逻辑控制能力。

7. 类型处理的桥梁:TypeHandler

TypeHandler是Mybatis中处理Java类型与JDBC类型相互转换的桥梁。无论是参数设置(ParameterHandler)还是结果获取(ResultSetHandler),都离不开它。

内置的智慧Mybatis为所有常见的Java类型(String,Integer,Date等)和JDBC类型都提供了默认的TypeHandler。例如,IntegerTypeHandler负责java.lang.IntegerJDBC INTEGER之间的转换。这些处理器在TypeHandlerRegistry初始化时就被注册好了。

自定义TypeHandler当我们需要处理特殊类型时,比如将数据库中的VARCHAR存储的JSON字符串映射为Java的Map或自定义对象,或者将枚举类型按自定义的code值存储,就需要自定义TypeHandler

  1. 实现TypeHandler<T>接口或继承BaseTypeHandler<T>:通常继承后者更方便,我们只需要重写四个方法:setNonNullParameter(设置参数)、getNullableResult(三个重载,从ResultSetCallableStatement或列名获取结果)。
  2. 注册:在mybatis-config.xml中通过<typeHandlers>标签注册,或者在Mapper XML的<resultMap>中指定某个列的typeHandler属性。

源码中的执行时刻

  • 参数设置:在DefaultParameterHandler.setParameters()中,它会遍历BoundSql中的ParameterMapping列表,为每个映射找到对应的TypeHandler,然后调用typeHandler.setParameter(ps, i, value, jdbcType)
  • 结果获取:在DefaultResultSetHandler.getPropertyMappingValue()中,当根据ResultMapping获取结果值时,会使用指定的或自动选择的TypeHandler,调用typeHandler.getResult(rs, columnName)来获取Java对象。

理解TypeHandler机制,不仅能解决特殊数据类型的映射问题,还能让我们更清晰地看到数据在Java世界和数据库世界之间流动的细节。例如,在处理数据库datetime字段时,是映射为java.util.Date还是java.time.LocalDateTime,选择不同的TypeHandler会有不同的行为,这需要与数据库驱动和实际业务需求相结合。

← 返回列表