ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

深入MyBatis核心源码:八大模块解析与实战应用

深入MyBatis核心源码:八大模块解析与实战应用

1. 从“会用”到“懂它”:为什么我们要啃MyBatis源码这块硬骨头

用了这么多年MyBatis,增删改查写得飞起,动态SQL也玩得挺溜,但每次面试官问起“MyBatis的一级缓存和二级缓存有什么区别”、“#{}和${}的底层处理机制”、“插件是怎么拦截并修改SQL的”,心里是不是还是会咯噔一下?或者,当线上出现一个诡异的SQL执行问题,日志打了,参数看了,配置查了,还是找不到头绪,最后只能靠重启大法或者玄学调试?这些场景,恰恰暴露了我们停留在“会用”层面的局限性。MyBatis作为Java持久层的事实标准之一,其设计之精妙,远不止于一个简单的ORM框架。深入其核心源码,不是为了炫技,而是为了在关键时刻,能像外科医生一样精准定位问题,能像架构师一样理解其设计取舍,从而写出更健壮、更高性能的代码。今天,我们不谈那些边边角角,就聚焦于构成MyBatis骨架与灵魂的八大核心源码模块,带你从“黑盒使用者”转变为“白盒掌控者”。

2. 基石构建:Configuration与SqlSessionFactory的初始化迷宫

很多人以为配置MyBatis就是写个mybatis-config.xml和一堆mapper.xml,然后程序就能跑了。但你知道从那一堆XML文件到内存中一个可用的、线程安全的SqlSessionFactory,中间经历了多少道“工序”吗?这个过程,就封装在Configuration类和SqlSessionFactoryBuilder的构建过程中。

2.1 Configuration:MyBatis的“中央大脑”

你可以把Configuration对象想象成MyBatis运行时的“中央配置库”和“元数据中心”。它不是一个简单的Map容器,而是一个高度结构化、承载了框架所有核心信息的对象。

核心属性解析:

  • environments: 存储数据源和事务管理器配置。这里有个关键点,虽然配置中可以定义多个环境(如dev、test),但一个SqlSessionFactory只能对应一个Environment。这决定了你的应用运行时连接的是哪个数据库。
  • mappedStatements: 这是重中之重。它是一个Map,键是namespace.id(如com.xxx.UserMapper.selectById),值是一个MappedStatement对象。每一个在mapper.xml中定义的<select><insert>等标签,都会被解析并封装成一个MappedStatement。这个对象包含了这条SQL语句的所有信息:SQL源码、参数映射、结果映射、缓存配置、语句类型等。
  • caches: 二级缓存的空间。它以namespace为键,存储着各个Mapper命名空间对应的Cache对象。
  • resultMapsparameterMaps: 存储着复杂的结果集映射和参数映射定义。parameterMaps现在基本被parameterType@Param注解替代,但架构上仍保留。
  • interceptorChain: 插件拦截器链。所有配置的插件(Interceptor)都会在这里被组装成一个链,这是插件机制能够工作的基础。

初始化流程的“暗坑”:初始化并不是简单地读取XML。XMLConfigBuilderXMLMapperBuilder这两个解析器承担了繁重的工作。它们使用了XPath解析XML,但更关键的是,它们调用了一系列的*Handler(如XMLStatementBuilder)来构建最终的MappedStatement

注意:解析mapper.xml时,MyBatis会检查是否允许出现重复的id。默认情况下,不同XML文件中的id可以相同,只要namespace不同。但如果你在同一个namespace下(比如通过<mapper resource><mapper class>两种方式引入了同一个Mapper接口),出现了重复的id,框架在初始化时就会抛出异常。这个检查就发生在XMLMapperBuilder的解析阶段。

2.2 SqlSessionFactoryBuilder:工厂的缔造者

这个类通常只用一个方法:build(InputStream inputStream)。它的工作看似简单,但内部流程严谨:

  1. 创建XMLConfigBuilder解析主配置文件。
  2. 调用XMLConfigBuilder.parse(),返回一个Configuration对象。此时,所有配置已加载完毕。
  3. 用这个Configuration对象,实例化一个DefaultSqlSessionFactory

为什么是DefaultSqlSessionFactoryMyBatis提供了SqlSessionFactory接口,而DefaultSqlSessionFactory是其默认且唯一的实现。它持有Configuration实例,所有创建SqlSession的请求都由此发出。这里的设计体现了工厂模式,将复杂的SqlSession创建过程封装起来,对外提供统一接口。

一个实战中的初始化问题:有时你会遇到“Invalid bound statement (not found)”这个经典错误。90%的情况是mapper.xml没有被正确加载到Configuration.mappedStatements中。排查时,除了检查文件路径,更应该去思考初始化流程:你的mapper.xml文件是否在mybatis-config.xml<mappers>标签中正确声明?如果是Spring Boot,@MapperScan的路径是否覆盖了你的Mapper接口?理解Configuration的构建过程,能让你快速定位到问题出在“资源加载”这一环,而不是盲目地去改SQL。

3. 会话管理:SqlSession与Executor的协作共舞

拿到了SqlSessionFactory,下一步就是获取SqlSession来执行操作。SqlSession是MyBatis的核心接口,你可以把它看作一次数据库会话的顶层抽象。但真正干活的,是它背后的“执行引擎”——Executor

3.1 SqlSession:门面(Facade)模式的典范

DefaultSqlSession是默认实现。它本身不直接执行SQL,而是一个“调度员”或“门面”。它持有ConfigurationExecutor的引用。当你调用sqlSession.selectOne(“statementId”, param)时,它主要做三件事:

  1. 根据statementIdConfiguration里获取对应的MappedStatement
  2. 将参数传递给Executor去执行。
  3. 返回Executor执行的结果。

这种设计的好处是职责清晰。SqlSession负责管理会话生命周期(如提交、回滚、关闭)和提供用户友好的API,而将具体的执行逻辑委托给Executor

3.2 Executor:执行策略的指挥家

Executor才是执行层的核心。MyBatis采用了策略模式,提供了几种不同的执行器:

  • SimpleExecutor: 最简单的执行器。每次执行都会创建一个新的Statement对象,用完后立即关闭(除非是ReuseExecutor或批处理场景下的特殊处理)。它不重用Statement
  • ReuseExecutor: 重用执行器。它会在一个SqlSession生命周期内,缓存PreparedStatement对象,以SQL语句为key。当执行相同的SQL时,会尝试重用缓存的PreparedStatement。这能减少数据库驱动创建PreparedStatement的开销。
  • BatchExecutor: 批处理执行器。它将所有更新操作(insert, update, delete)缓存起来,等到调用flushStatements()或提交事务时,一次性发送给数据库执行,可以大幅提升批量操作的性能。

如何选择执行器?在创建SqlSession时,可以通过参数指定。在Spring集成中,通常使用默认的SimpleExecutor。对于需要批量操作的场景,可以在代码中手动获取BatchExecutor。理解它们的区别,有助于你在特定场景下做出性能优化。

更重要的是,Executor是插件(Interceptor)拦截的主要目标之一。插件可以拦截Executorqueryupdate方法,从而在SQL执行前后插入自定义逻辑(如分页、数据权限过滤)。Executor的接口设计(清晰的query,update,commit,rollback等方法)为插件扩展提供了完美的切入点。

3.3 二级缓存与CachingExecutor:装饰器模式的应用

如果你在配置中开启了二级缓存(<cache/>),MyBatis并不会直接使用上述的基础执行器,而是会使用CachingExecutor

CachingExecutor是一个装饰器(Decorator)。它内部持有一个Executordelegate(可能是SimpleExecutor等)。它的工作流程是:

  1. 当执行查询时,先根据CacheKey(由MappedStatement Id、参数、分页信息等计算得出)去二级缓存中查找。
  2. 如果命中,直接返回缓存结果。
  3. 如果未命中,则调用delegate.query()方法去数据库查询,然后将结果存入二级缓存,再返回。

CachingExecutor的存在,使得缓存逻辑与基础执行逻辑解耦。你可以轻松地开启或关闭缓存,而不影响核心的执行流程。这也是MyBatis设计中“开闭原则”的体现。

4. 语句执行:StatementHandler与ParameterHandler的精密配合

Executor决定要执行一条SQL时,它会将任务进一步下发给StatementHandler。这是真正与JDBCStatement打交道的层面。

4.1 StatementHandler:JDBC操作的封装者

StatementHandler负责创建Statement对象、参数化、执行SQL、处理结果集。它也有不同的实现,对应不同的Statement类型:

  • PreparedStatementHandler: 处理PreparedStatement,这是最常用的,支持预编译和参数替换。
  • SimpleStatementHandler: 处理普通的Statement
  • CallableStatementHandler: 处理CallableStatement,用于调用存储过程。

ExecutordoQuery()方法中,关键几步是:

  1. 获取Configuration
  2. 根据MappedStatement创建对应的StatementHandler
  3. 调用StatementHandler.prepare()创建Statement
  4. 调用StatementHandler.parameterize()设置参数。
  5. 调用StatementHandler.query()执行并返回结果。

4.2 ParameterHandler:参数映射的魔术师

StatementHandler.parameterize()方法内部,其实是调用了ParameterHandler.setParameters()ParameterHandler(默认实现是DefaultParameterHandler)的任务,就是根据你在Mapper方法中传入的参数,以及MappedStatement中定义的参数映射关系,将Java对象中的属性值,正确地设置到JDBCPreparedStatement的占位符(?)上。

这里就涉及到#{}${}的本质区别:

  • #{}: MyBatis会将其解析为一个JDBC的预编译占位符?ParameterHandler会使用PreparedStatement.setXXX()方法来安全地设置参数值,能有效防止SQL注入。
  • ${}: MyBatis会将其视为字符串直接替换(String Substitution)。在SqlSource构建SQL时,${}内的内容会被直接替换成对应的参数值字符串。这里没有使用PreparedStatement的参数化设置,因此存在SQL注入风险,通常只用于动态指定表名、列名等非值参数。

理解ParameterHandler的工作,你就明白了为什么#{}是安全的,而直接拼接字符串或滥用${}是危险的。

5. 结果处理:ResultSetHandler的映射艺术

SQL执行完毕,拿到ResultSet后,如何将其转换成我们定义的Java对象(或ListMap)?这就是ResultSetHandler的职责。

DefaultResultSetHandler是这个过程的灵魂。它的handleResultSets()方法逻辑非常复杂,但核心步骤清晰:

  1. 遍历ResultSet:可能有多结果集(存储过程返回)。
  2. 获取ResultMap:从MappedStatement中取得描述结果映射规则的ResultMap
  3. 创建结果对象:根据ResultMaptype属性,通过反射或TypeHandler实例化目标对象。
  4. 自动映射:如果开启了autoMapping,会尝试根据数据库返回的列名(下划线转驼峰后)去匹配对象属性名,并调用TypeHandler进行类型转换和赋值。
  5. 根据ResultMap映射:处理<result>标签定义的显式映射,包括复杂属性(association, collection)的嵌套查询或嵌套结果处理。
  6. 处理嵌套查询(N+1问题之源):如果ResultMap中定义了<association><collection>且其select属性指向另一个查询,这里会触发额外的查询。这就是著名的“N+1查询问题”的源头。优化方式通常是使用“连接查询+嵌套结果映射”。

TypeHandler的桥梁作用: 在整个映射过程中,TypeHandler(类型处理器)扮演了Java类型与JDBC类型之间转换的桥梁。无论是ParameterHandler设置参数,还是ResultSetHandler读取结果,凡是涉及到类型转换,都离不开它。MyBatis内置了常用类型的处理器,你也可以为自定义类型(如枚举)实现自己的TypeHandler

理解ResultSetHandler,你就能明白为什么MyBatis的resultMap功能如此强大,也能在遇到映射失败、属性为null、或者性能问题时,知道该从何处着手排查。

6. 脚本解析:SqlSource与BoundSql的动态SQL引擎

MyBatis最强大的特性之一就是动态SQL。而将那些带有<if>,<where>,<foreach>的XML标签,最终变成一条可执行的JDBC SQL字符串,就是SqlSourceBoundSql的功劳。

6.1 SqlSource:SQL的源头工厂

SqlSource是一个接口,代表一条SQL语句的源头。根据Mapper中SQL的编写方式,MyBatis会创建不同类型的SqlSource

  • DynamicSqlSource: 对应包含动态SQL标签(OGNL表达式)的SQL。它需要经过解析才能确定最终的SQL字符串。
  • RawSqlSource: 对应静态的、不含动态标签的SQL。它在初始化时就被解析成静态SQL,性能更高。
  • ProviderSqlSource: 对应通过@SelectProvider等注解提供的SQL。
  • StaticSqlSource: 最终所有SqlSource都会被解析成StaticSqlSource,它持有最终可执行的SQL字符串和参数映射信息。

6.2 动态SQL的解析过程

DynamicSqlSource为例,其getBoundSql方法会:

  1. 创建DynamicContext上下文,它持有参数对象。
  2. 调用SqlNode(如IfSqlNode,ForEachSqlNode)的apply方法。这些SqlNode构成了一个树形结构,它们会根据OGNL表达式从参数对象中求值,动态地决定是否将自身包含的SQL片段拼接到上下文中。
  3. 最终,DynamicContext中拼接出了完整的SQL字符串。
  4. 使用SqlSourceBuilder对这个字符串进行第二次解析,将所有的#{}占位符解析出来,生成ParameterMapping列表,并最终创建一个StaticSqlSource

6.3 BoundSql:可执行SQL的最终形态

无论是哪种SqlSource,调用其getBoundSql(parameterObject)方法后,都会返回一个BoundSql对象。这个对象是一次SQL执行的最终描述,它包含:

  • sql: 已经完成动态解析、可以直接交给JDBC执行的SQL字符串(其中#{}已被替换成?)。
  • parameterMappings:ParameterMapping列表,描述了每个?对应的参数属性名、JavaType、JdbcType等信息。
  • parameterObject: 用户传入的原始参数对象。

ExecutorStatementHandler拿到的就是BoundSql对象。理解这个过程,你就明白了动态SQL是如何“动”起来的,以及为什么我们在调试时打印的SQL和XML中写的不完全一样。

7. 缓存体系:一级缓存与二级缓存的深度剖析

缓存是提升性能的利器,但用不好就是“坑”器。MyBatis的两级缓存设计需要透彻理解。

7.1 一级缓存:SqlSession级别的“工作备忘录”

  • 范围:默认开启,且无法关闭。其作用域是一个SqlSession(一次数据库会话)。
  • 实现:在BaseExecutor中有一个localCache属性(一个PerpetualCache对象)。它的Key是CacheKey(由MappedStatement Id、参数、分页等计算得出)。
  • 生命周期:与SqlSession共存亡。当执行insertupdatedeletecommitrollback或手动调用clearCache()时,该SqlSession的一级缓存会被清空。
  • 工作模式:在同一个SqlSession中,连续两次执行相同的查询(相同的statementId和参数),第二次会直接返回缓存的结果,不会访问数据库。

坑点提示

  1. 分布式环境无效:一级缓存是会话级别的,在Web应用中,通常一个请求对应一个SqlSession(Spring中常与事务绑定),请求结束就关闭了,所以无法跨请求共享。
  2. 脏读风险:如果两个操作共享同一个SqlSession(比如在同一个事务中),第一个操作查询了数据,第二个操作修改了同一条数据但未提交,此时第一个操作再次查询,拿到的还是缓存中的旧数据。因此,在涉及更新的操作后,MyBatis会自动清空本SqlSession的缓存。

7.2 二级缓存:Mapper命名空间级别的“共享黑板”

  • 范围:需要手动在mapper.xml中配置<cache/>标签来开启。作用域是一个namespace(通常是一个Mapper接口)。
  • 实现:底层也是PerpetualCache,但被CachingExecutor装饰。它存储在Configuration对象的caches这个Map中。
  • 生命周期:与应用生命周期一致(除非配置了刷新策略)。当执行了来自同一个namespace的任意insertupdatedelete语句后,该namespace下的所有缓存会被清空。
  • 序列化要求:因为缓存可能被序列化到磁盘或跨会话共享,所以存入二级缓存的实体类必须实现Serializable接口

核心工作机制

  1. 事务提交后生效:二级缓存的数据是在SqlSession执行commit()close()时,才被真正刷入缓存池。这意味着,如果你在事务中查询了数据,但在事务提交前,其他会话是看不到这份缓存的。
  2. 跨会话共享:不同的SqlSession只要操作同一个Mapper,就可以共享二级缓存。

严重注意事项

  • 多表关联查询的陷阱:如果UserMapper中有一个查询关联了Order表,结果被缓存。当OrderMapper执行了一个更新操作,它只会清空OrderMapper命名空间下的缓存,而UserMapper的缓存依然存在,导致读到脏数据。因此,涉及多表关联的查询,要谨慎使用二级缓存,或者使用<cache-ref>来建立缓存引用关系,但这会带来管理复杂度。
  • 数据一致性:二级缓存适用于读远多于写、且数据实时性要求不高的场景(如配置数据)。对于金融、交易等强一致性要求的场景,通常不建议开启。

8. 插件机制:Interceptor与责任链的扩展魔法

MyBatis的插件(Plugin)机制是其框架扩展性的体现,允许用户在不修改框架源码的情况下,介入核心组件的执行过程。其核心是责任链模式动态代理

8.1 拦截器签名与@Intercepts注解

定义一个插件,需要实现Interceptor接口,并使用@Intercepts@Signature注解来声明你要拦截哪个对象的哪个方法。

@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}) }) public class MyPlugin implements Interceptor { // ... }

@Signature指明了拦截点:type(目标类,如Executor,StatementHandler,ParameterHandler,ResultSetHandler)、method(方法名)、args(方法参数类型列表)。

8.2 Plugin.wrap():动态代理的包装器

在MyBatis初始化时,会遍历所有配置的插件,并调用Plugin.wrap(target, this)方法。这个方法会:

  1. 获取目标对象target的所有接口。
  2. 检查当前插件的@Intercepts签名,判断是否需要拦截该目标对象。
  3. 如果需要,则使用JDK动态代理,创建一个代理对象。这个代理对象的InvocationHandler就是Plugin类本身。
  4. Plugininvoke方法会判断当前调用的方法是否在拦截范围内。如果是,则调用插件的intercept方法;否则,直接调用原方法。

这就形成了一条代理链:假设配置了插件A和B。MyBatis创建Executor实例后,先用A去wrap,得到一个代理对象proxyA;再用B去wrapproxyA,得到最终的代理对象proxyB。当调用Executor.query()时,请求先到达B的intercept,B可以决定是否调用invocation.proceed()将请求传递给链中的下一个(即A),A再决定是否传递给真正的Executor对象。这就是责任链模式。

8.3 实战:实现一个简单的SQL执行时间监控插件

理解了原理,实现一个插件就很简单了。下面是一个记录慢SQL的插件示例:

@Intercepts({ @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}), @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}) }) public class SlowSqlInterceptor implements Interceptor { private long threshold = 1000; // 慢查询阈值,单位毫秒 @Override public Object intercept(Invocation invocation) throws Throwable { long start = System.currentTimeMillis(); try { // 继续执行调用链 return invocation.proceed(); } finally { long end = System.currentTimeMillis(); long time = end - start; if (time > threshold) { // 获取MappedStatement和BoundSql以记录详细信息 MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; BoundSql boundSql; if (invocation.getArgs().length > 5) { boundSql = (BoundSql) invocation.getArgs()[5]; } else { // 对于update方法,参数结构不同,需要从MappedStatement获取 Object parameter = invocation.getArgs()[1]; boundSql = ms.getBoundSql(parameter); } System.err.println(String.format("慢SQL警告:执行耗时[%dms],语句ID[%s],SQL[%s]", time, ms.getId(), boundSql.getSql())); } } } @Override public Object plugin(Object target) { // 使用Plugin工具类创建代理 return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { // 可以从配置中读取阈值 String thresholdStr = properties.getProperty("threshold"); if (thresholdStr != null) { this.threshold = Long.parseLong(thresholdStr); } } }

mybatis-config.xml中配置:

<plugins> <plugin interceptor="com.example.SlowSqlInterceptor"> <property name="threshold" value="500"/> </plugin> </plugins>

这个插件拦截了Executorqueryupdate方法,在执行前后计算耗时,并打印出慢SQL的详细信息。通过这个例子,你可以看到插件机制的强大之处:无需修改MyBatis源码,就能无侵入地增强其功能。常见的分页插件(如PageHelper)、数据权限过滤插件,都是基于此机制实现的。理解它,你就能为自己的项目定制各种强大的扩展功能。

返回列表