当前位置: 首页 > news >正文

深入解析MyBatis源码:从动态SQL到插件机制的核心原理与实践

1. 从“会用”到“懂它”:我为什么要啃MyBatis源码

干了这么多年Java后端,MyBatis绝对是绕不开的“老朋友”。从最早的iBATIS时代用过来,到后来Spring Boot集成MyBatis Plus,配置个数据源、写个Mapper接口、在XML里拼个动态SQL,这套流程闭着眼睛都能走完。面试的时候,也能把一级缓存、二级缓存、插件机制这些概念背得滚瓜烂熟。但说实话,很长一段时间里,我对MyBatis的认知都停留在“会用”的层面。直到有一次,线上出了个诡异的Bug:一个复杂的多表关联查询,在预发环境跑得好好的,一上生产就偶尔超时,日志里看到的SQL明明一模一样。我们折腾了好久,加索引、调参数,效果都不明显。最后没办法,只能硬着头皮去跟MyBatis的执行过程。那是我第一次真正意义上“调试”MyBatis源码,从SqlSessionselectList方法一步步跟进去,穿过层层代理和拦截器,最终在ParameterHandler设置参数的那个环节发现,生产环境某个字段的值因为字符集问题,在构建BoundSql时,生成的预编译SQL参数占位符?所对应的实际参数值发生了微妙的改变,导致数据库执行计划走了另一条糟糕的路径。问题解决后,我忽然意识到,过去那种“面向搜索引擎编程”和“背诵面试题”的学习方式,在解决深层、复杂问题时是多么无力。真正能让你心里有底、快速定位问题的,是对核心机制的理解。所以,我决定系统性地啃一遍MyBatis源码,目标不是成为源码贡献者,而是为了在下次遇到问题时,能像老中医一样,望闻问切,直指病灶。这篇文章,就是我这趟“源码之旅”的笔记和思考,适合那些已经熟练使用MyBatis,但总感觉隔着一层纱,想掀开看看里面究竟是怎么运转的同行。

2. 庖丁解牛:MyBatis的核心骨架与启动流程

很多人看源码,一上来就扎进最复杂的动态SQL或者插件机制里,很容易迷失。我的经验是,先摸清骨架,再研究肌肉和神经。MyBatis的骨架,其实就是它的配置加载会话管理体系。这部分的入口,通常就是我们熟悉的SqlSessionFactoryBuilder.build()方法。

2.1 配置文件是如何被“吃进去”的?

我们写的mybatis-config.xml和一堆Mapper.xml,在MyBatis眼里就是一堆需要被解析、验证并转化为内存中可操作对象的资源。这个过程主要由XMLConfigBuilderXMLMapperBuilder这两个类完成。

XMLConfigBuilder负责解析全局配置文件。它会按顺序处理<properties>,<settings>,<typeAliases>,<plugins>,<environments>,<mappers>等节点。这里有个非常关键的细节:解析顺序是有意义的。比如,<properties>会最先被解析,因为后续的<settings>里的${}占位符需要用它来替换。<typeAliases>要在解析Mapper之前完成注册,这样Mapper里才能用短别名引用类型。

// 一个简化的解析流程示意 public Configuration parse() { // 1. 解析<properties> propertiesElement(root.evalNode("properties")); // 2. 解析<settings>,并用上一步的properties替换占位符 settingsElement(root.evalNode("settings")); // 3. 解析<typeAliases> typeAliasesElement(root.evalNode("typeAliases")); // 4. 解析<plugins> (拦截器) pluginElement(root.evalNode("plugins")); // 5. 解析<environments> (数据源和事务管理器) environmentsElement(root.evalNode("environments")); // 6. 解析<mappers> mapperElement(root.evalNode("mappers")); return configuration; }

XMLMapperBuilder则专门对付一个个Mapper文件。它会解析<namespace>,<cache>,<resultMap>,<sql>,<select|insert|update|delete>等语句节点。每一个<select>这样的节点,最终都会被封装成一个MappedStatement对象,这个对象是MyBatis执行过程的核心指令单元,它包含了这条SQL语句的所有信息:唯一的ID(namespace + id)、SQL源码、参数映射、结果映射、缓存策略、语句类型等。

注意:很多人会忽略<sql>片段和<include>的解析过程。XMLMapperBuilder在解析时,会先把所有<sql>片段收集到一个Map里(key是namespace + sqlId)。当遇到<include>时,它并不是简单地做字符串替换,而是会递归地解析被引用的<sql>片段,并处理其中的${}和动态SQL标签(如<if>),最后将解析好的SQL片段节点“嫁接”到当前语句的解析树中。这个过程保证了<include>的灵活性和动态性。

2.2 SqlSessionFactory与SqlSession:会话管理的艺术

SqlSessionFactory是个工厂,它的唯一职责就是创建SqlSession。而SqlSession,你可以把它理解为一次数据库会话的上下文。这是MyBatis里最重要的接口之一,我们常用的selectOneinsertcommit等方法都定义在这里。

默认的实现类DefaultSqlSession本身并不干太多“脏活累活”,它更像一个调度中心。它内部持有了Configuration(所有配置信息)和Executor(真正的执行器)。当我们调用sqlSession.selectList(“com.xxx.UserMapper.selectById”, 1)时,DefaultSqlSession会:

  1. 根据语句ID从Configuration里获取对应的MappedStatement
  2. 将参数对象和MappedStatement交给Executor去执行。
  3. 接收Executor返回的List结果。

这里引出了两个关键设计:

  • 线程安全性SqlSession实例是非线程安全的。这意味着你不能在多个线程间共享同一个SqlSession。最佳实践是在每个请求或方法作用域内获取并使用它,用完后立刻关闭。Spring集成时,SqlSessionTemplate通过将SqlSession绑定到当前线程的ThreadLocal上来管理这个生命周期,让我们感觉像是用了单例,实则背后是线程隔离的。
  • 执行器Executor:这是真正的“发动机”。MyBatis有三种基本的执行器:SIMPLE(简单执行)、REUSE(重用PreparedStatement)、BATCH(批处理)。Executor的职责包括创建StatementHandler、处理缓存(一级缓存就在这)、管理事务等。插件(Interceptor)也正是通过拦截Executor的方法来实现功能的。

启动流程的最后,所有解析好的MappedStatementResultMapTypeHandler等都存放在那个全局唯一的Configuration对象里。这个对象是只读的,在MyBatis运行期间充当了“中央知识库”的角色。至此,MyBatis完成了从静态配置文件到动态运行时对象的转化,整装待发。

3. 动态SQL的魔法:从XML标签到可执行语句

动态SQL是MyBatis最吸引人的特性之一。我们写在XML里的<if>,<choose>,<foreach>,最终是如何变成一条条能在数据库里执行的SQL语句的呢?秘密就在于OGNL表达式SqlNode树形结构

3.1 解析阶段:构建SqlNode树

XMLScriptBuilder解析动态SQL标签时,它并不会立即生成最终的SQL字符串。相反,它会根据不同的标签,创建不同类型的SqlNode对象,并组织成一棵树。

例如,对于这样一段SQL:

<select id="findActiveBlogWithTitleLike" resultType="Blog"> SELECT * FROM BLOG WHERE state = ‘ACTIVE’ <if test="title != null"> AND title like #{title} </if> </select>

解析器会生成一个MixedSqlNode,它包含两个子节点:

  1. 一个StaticTextSqlNode,内容为SELECT * FROM BLOG WHERE state = ‘ACTIVE’
  2. 一个IfSqlNode,它内部又包含:
    • 一个test表达式:“title != null”
    • 一个子SqlNodeStaticTextSqlNode),内容为AND title like #{title}

<choose>/<when>/<otherwise><trim><where><set><foreach>也都有各自对应的SqlNode实现类(ChooseSqlNode,TrimSqlNode,WhereSqlNode,SetSqlNode,ForEachSqlNode)。<where><set>本质上是特殊的<trim>

3.2 执行阶段:动态应用与SQL拼接

当真正执行这条语句时,DynamicSqlSource(动态SQL语句对应的SqlSource实现)会开始工作。它拿到用户传入的参数对象,然后调用根SqlNode(通常是MixedSqlNode)的apply方法。

apply方法会传入一个DynamicContext对象,这个对象持有参数上下文和一个StringJoiner(用于拼接SQL)。每个SqlNodeapply方法会根据自己的逻辑,决定是否向DynamicContext中追加SQL片段。

  • IfSqlNode.apply():它会用OGNL引擎去计算test属性里的表达式(如title != null)。OGNL会从DynamicContext绑定的参数对象里,根据表达式去获取title属性的值。如果表达式结果为true,则调用其子SqlNodeapply方法,将AND title like #{title}拼接到SQL中;如果为false,就什么都不做。
  • ForEachSqlNode.apply():它会解析collection表达式(如“list”),从参数中拿到集合,然后遍历。每次遍历,都会为当前迭代项创建一个新的上下文(PrefixedContext),将item(如“item”)、index(如“index”)等变量绑定进去,再让子SqlNode在这个新上下文中应用,从而生成像(#{item.id}, #{item.name})这样的片段,并自动处理逗号分隔。

踩坑心得:动态SQL的解析依赖于OGNL。OGNL在访问嵌套属性(如user.address.city)时非常方便,但也要注意性能和安全。我曾遇到过一个性能问题,在一个循环次数很多的<foreach>里,test表达式非常复杂,导致OGNL解析开销巨大。后来我们把一些可以在Java层判断的逻辑提前处理,只把必要的布尔值传给MyBatis,性能提升立竿见影。另外,OGNL表达式理论上可以执行任意代码,绝对不要让用户可控的输入直接进入test表达式,这可能导致表达式注入漏洞。

3.3 参数替换与BoundSql的生成

当所有动态SqlNodeapply完毕后,DynamicContext中就得到了一条完整的、带有#{}占位符的原始SQL字符串。注意,这里还不是最终发给JDBC的SQL。

接下来,DynamicSqlSource会使用SqlSourceBuilder对这个字符串进行第二次解析。这次解析的目标是处理#{}${}

  • 对于#{},解析器会将其替换成?,并创建一个ParameterMapping对象,记录属性路径(如“title”)、Java类型、JDBC类型等信息。这些ParameterMapping最终会存入BoundSql对象。
  • 对于${},解析器会直接进行OGNL求值,将结果以字符串形式拼接到SQL中。这就是为什么${}存在SQL注入风险,因为它直接改变了SQL语句的结构。

最终,DynamicSqlSource会返回一个BoundSql对象。这个对象包含了:

  • sql:已经将#{}替换为?、将${}替换为实际值的、可以直接交给PreparedStatement的SQL字符串。
  • parameterMappingsParameterMapping列表,对应每个?
  • parameterObject:用户传入的原始参数对象。

至此,一条动态SQL完成了从声明式标签到可执行语句的蜕变。BoundSql将被传递给StatementHandler,进入下一阶段的参数设置和结果处理。

4. 执行引擎深处:StatementHandler与TypeHandler的协奏

拿到BoundSql后,Executor会把执行任务委托给StatementHandler。如果说Executor是总经理,那StatementHandler就是负责具体项目的项目经理。它负责创建Statement对象、设置参数、执行SQL、处理结果集。而在这个过程中,默默无闻但又至关重要的“专家顾问”就是TypeHandler

4.1 StatementHandler的三板斧

MyBatis有几种StatementHandlerSimpleStatementHandler(用于静态Statement)、PreparedStatementHandler(用于预编译PreparedStatement,最常用)、CallableStatementHandler(用于存储过程)。我们以PreparedStatementHandler为例看它的工作:

  1. 实例化Statement:调用connection.prepareStatement(sql),传入BoundSql里的sql字段(此时已经是带?的)。
  2. 参数设置:这是最精巧的一步。它调用ParameterHandler.setParameters(ps)ParameterHandler(默认实现DefaultParameterHandler)会遍历BoundSql.parameterMappings,对于每一个ParameterMapping,做两件事:
    • 值提取:使用MetaObject(MyBatis提供的反射工具)从parameterObject中,按照property属性路径(如“user.address.postCode”)把值取出来。
    • 类型转换与设置:将取出的Java对象,交给对应的TypeHandler,由TypeHandler调用PreparedStatement.setXXX(parameterIndex, value)方法,将值设置到对应的?占位符上。
  3. 执行与结果处理:执行ps.execute()。然后,将返回的ResultSet交给ResultSetHandler(结果集处理器)处理。ResultSetHandler会遍历结果集的每一行,根据MappedStatement中定义的ResultMap,通过TypeHandler将JDBC类型(ResultSet.getXXX)转换回Java对象,并组装成最终的List或单个对象返回。

4.2 TypeHandler:跨类型系统的桥梁

TypeHandler是MyBatis类型系统的基石。它的接口很简单,核心就是两个方法:setParameter(Java -> JDBC)和getResult(JDBC -> Java)。MyBatis为所有常见的Java类型(String,Integer,Date等)和JDBC类型都内置了对应的TypeHandler

它的强大之处在于可扩展性。比如,我们想在一个VARCHAR字段里存储一个JSON字符串,在Java实体中则对应一个Map<String, Object>属性。我们只需要自定义一个TypeHandler

@MappedTypes(Map.class) @MappedJdbcTypes(JdbcType.VARCHAR) public class JsonMapTypeHandler extends BaseTypeHandler<Map<String, Object>> { private static final ObjectMapper objectMapper = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, Map<String, Object> parameter, JdbcType jdbcType) throws SQLException { // Java Map -> JSON String -> JDBC VARCHAR ps.setString(i, objectMapper.writeValueAsString(parameter)); } @Override public Map<String, Object> getNullableResult(ResultSet rs, String columnName) throws SQLException { // JDBC VARCHAR -> JSON String -> Java Map String json = rs.getString(columnName); return json == null ? null : objectMapper.readValue(json, Map.class); } // ... 其他重载方法 }

然后在mybatis-config.xml中注册,或者在字段上通过@Result注解指定。之后,所有这个属性的读写,MyBatis都会自动使用我们这个TypeHandler来转换,业务代码完全无感。

实操技巧:在处理枚举类型时,MyBatis默认使用EnumTypeHandler,它存储和读取的是枚举的name()。如果你希望存枚举的ordinal()(索引)或者自定义的code值,就需要用EnumOrdinalTypeHandler或者自定义TypeHandler。我推荐使用MyBatis-Plus的通用枚举功能,或者自定义一个基于codeTypeHandler,这样数据库里存的是简洁的数字或字符串,程序里用的是有意义的枚举对象,清晰又方便。

4.3 MetaObject:反射的优雅封装

上面提到ParameterHandlerMetaObject来取值。MetaObject是MyBatis提供的一个用于优雅、高效操作对象属性的工具类。它封装了Java反射、BeanInfo、甚至MapCollection的操作,提供了一套统一的API。

比如,对于parameterObject是一个User对象,User里有一个Address属性,Address里有一个city属性。要获取city的值,用MetaObject只需要:

MetaObject metaObject = SystemMetaObject.forObject(parameterObject); String city = (String) metaObject.getValue(“address.city”);

它内部会智能地判断每一步是getter方法、Mapget还是直接字段访问,并缓存反射的Method对象以提高性能。正是有了MetaObject,MyBatis的动态SQL、参数映射、结果映射才能如此灵活地处理各种复杂的对象结构。

5. 插件机制:在关键流程上“动手术”

MyBatis的插件(Plugin)机制,是其扩展性的王牌。它允许我们在MyBatis执行的核心流程中插入自定义逻辑。很多人知道它强大,但对其实现原理一知半解。其实,它的核心就是JDK动态代理责任链模式

5.1 可拦截的四大组件

MyBatis明确声明,插件只能拦截四大组件的以下方法:

  • Executor(update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
  • ParameterHandler(getParameterObject, setParameters)
  • ResultSetHandler(handleResultSets, handleOutputParameters)
  • StatementHandler(prepare, parameterize, batch, update, query)

为什么是它们?因为它们是MyBatis执行流程中职责最单一、边界最清晰的四个关键节点。拦截它们,就等于控制了SQL执行的“准备”、“参数化”、“执行”、“结果处理”全链路。

5.2 插件的工作原理:层层代理

假设我们写了一个插件,在mybatis-config.xml里配置了:

<plugins> <plugin interceptor="com.example.MyInterceptor"> <property name="someProperty" value="100"/> </plugin> </plugins>

在启动阶段,XMLConfigBuilder解析到这个配置,会实例化我们的MyInterceptor类,并调用其setProperties方法传入属性。然后,它会将这个拦截器实例添加到ConfigurationInterceptorChain(拦截器链)中。

关键来了:当Configuration去创建ExecutorParameterHandlerResultSetHandlerStatementHandler这四大组件的实例时(比如在newExecutornewParameterHandler等方法里),它会调用InterceptorChain.pluginAll(target)方法。

public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target = interceptor.plugin(target); // 对target进行代理包装 } return target; // 返回被层层代理后的对象 }

我们的拦截器需要实现Interceptor接口,其中plugin方法通常直接调用Plugin.wrap(target, this)Plugin类是MyBatis提供的工具类,它利用JDK动态代理,生成一个实现了目标对象相同接口的代理对象。这个代理对象的InvocationHandler就是Plugin本身。

当代理对象的方法被调用时,会触发Plugin.invoke()。它会检查:

  1. 当前被调用的方法,是否在我们拦截器声明的@Intercepts注解范围内。
  2. 如果是,则先执行拦截器的intercept方法(在这里我们可以编写前置/后置逻辑,甚至改变参数、返回值,或完全跳过原方法)。
  3. 如果不是,则直接反射调用目标对象的原方法。

因为拦截器链是顺序包装的,所以最终我们得到的组件实例,可能是这样一个“套娃”结构:代理3(代理2(代理1(原始对象)))。执行时,调用顺序是:代理3.intercept -> 代理2.intercept -> 代理1.intercept -> 原始对象.method

5.3 编写一个实用的分页插件

理解了原理,我们来看一个简化版分页插件的思路。它的目标是拦截Executor的查询方法,在执行前修改SQL,加上LIMIT ?, ?

@Intercepts({ @Signature(type = Executor.class, method = “query”, args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class SimplePageInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; Object parameter = args[1]; RowBounds rowBounds = (RowBounds) args[2]; // 1. 判断是否需要分页(RowBounds不是默认值) if (rowBounds == null || rowBounds == RowBounds.DEFAULT) { return invocation.proceed(); // 不分页,直接执行原方法 } // 2. 获取原始BoundSql BoundSql boundSql = ms.getBoundSql(parameter); String originalSql = boundSql.getSql(); // 3. 改造SQL,拼接分页语句(这里以MySQL为例) String pagedSql = originalSql + “ LIMIT “ + rowBounds.getOffset() + “, “ + rowBounds.getLimit(); // 4. 创建一个新的MappedStatement(关键!) // 由于MappedStatement是全局配置,不能直接修改。需要基于原ms创建一个新的。 MappedStatement newMs = copyMappedStatement(ms, new BoundSqlSqlSource(boundSql, pagedSql)); // 5. 替换参数中的MappedStatement args[0] = newMs; // 6. 将RowBounds重置为默认值,防止被其他插件或默认逻辑再次处理 args[2] = RowBounds.DEFAULT; // 7. 继续执行调用链 return invocation.proceed(); } // 工具方法:复制MappedStatement并替换SqlSource private MappedStatement copyMappedStatement(MappedStatement ms, SqlSource newSqlSource) { // ... 使用MappedStatement.Builder重新构建,这是一个复杂但固定的模式 } // 内部类,用于包装新的SQL static class BoundSqlSqlSource implements SqlSource { private BoundSql boundSql; private String sql; public BoundSqlSqlSource(BoundSql boundSql, String sql) { this.boundSql = boundSql; this.sql = sql; } @Override public BoundSql getBoundSql(Object parameterObject) { // 返回一个新的BoundSql,sql字段被替换为分页SQL,其他映射信息沿用旧的 return new BoundSql(boundSql.getConfiguration(), sql, boundSql.getParameterMappings(), parameterObject); } } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { // 读取配置 } }

深度思考:插件非常强大,但也要慎用。第一,它通过动态代理实现,会有一定的性能开销(虽然通常可忽略)。第二,插件会改变MyBatis的默认行为,如果多个插件相互影响,调试起来会非常困难。第三,像上面分页插件中创建新MappedStatement的操作,如果频繁调用且不做缓存,可能会影响性能。在实际项目中,对于分页、数据权限过滤、SQL执行时间监控、多租户数据隔离等横切关注点,插件是绝佳的解决方案。但务必确保插件逻辑简洁、高效,并做好文档记录。

6. 缓存体系:一级与二级缓存的精妙设计

缓存是提升数据库访问性能的利器,MyBatis提供了一级缓存和二级缓存。理解它们的作用域失效机制,是避免踩坑的关键。

6.1 一级缓存:SqlSession级别的“工作内存”

一级缓存是默认开启无法关闭的。它的生命周期与SqlSession相同。

  • 数据结构:在BaseExecutorExecutor的基类)中,有一个PerpetualCache类型的localCache字段。它本质上就是一个HashMap
  • 如何工作:当执行查询时,Executor会以CacheKey(由MappedStatement Id、SQL、参数值、分页信息等计算得出)作为key,查询结果作为value,存入localCache。下次在同一个SqlSession内执行完全相同的查询(相同的CacheKey),就会直接从缓存返回结果。
  • 失效时机
    1. 增删改操作:同一个SqlSession内执行了任何insertupdatedelete语句,Executor会清空整个localCache。这是因为这些操作可能改变了数据,为了保证数据一致性,必须失效缓存。这是最核心的失效机制。
    2. 手动清空:调用sqlSession.clearCache()
    3. 关闭SqlSession:会话结束,缓存自然销毁。
    4. 执行了select但配置了flushCache=true:在<select>标签上设置flushCache=”true”,会使该查询执行前先清空一级(和二级)缓存。
    5. 执行了select但配置了useCache=false:在<select>标签上设置useCache=”false”,会使该查询结果不放入一级(和二级)缓存。

常见误区与坑点:一级缓存容易导致“脏读”。考虑这个场景:在同一个SqlSession里,你先查询了一个用户(id=1),然后另一个线程(或另一个服务)在数据库中修改了这个用户的数据。接着,你在同一个SqlSession里再次查询id=1的用户,你拿到的还是旧数据,因为命中了一级缓存。因此,在Web应用中,通常会将SqlSession的生命周期与一次请求绑定(如Spring的SqlSessionTemplate),请求结束就关闭,这样一级缓存的作用范围就很有限,既利用了缓存加速重复查询,又避免了跨请求的脏数据问题。如果你的场景是批处理或长会话,需要特别注意这一点。

6.2 二级缓存:Mapper级别的“共享缓存”

二级缓存是默认关闭的。需要在<mapper>namespace级别或<select>语句级别通过cache属性或<cache/>标签显式开启。

  • 数据结构:二级缓存存储在Configuration对象中,是一个HashMap<String, Cache>,key是Mapper的namespace。每个Cache实例(如PerpetualCache)可以被多个SqlSession共享。
  • 如何工作:当某个SqlSession执行一个查询后,如果该语句配置了使用二级缓存,在结果返回给用户之前,它会被提交到TransactionalCache(一个装饰器,用于管理事务提交时的缓存操作)中。只有当SqlSession执行了commit()close()时,TransactionalCache才会真正将数据写入底层的共享Cache。其他SqlSession在执行相同查询时,就可以从共享Cache中获取数据。
  • 失效时机
    1. 执行了来自同一个namespaceinsertupdatedelete语句,并成功提交后,该namespace下的所有二级缓存会被清空。
    2. <select>上配置了flushCache=”true”
    3. <select>上配置了useCache=”false”
    4. 通过<cache>标签的eviction(淘汰策略,如LRU)、flushInterval(刷新间隔)、size(引用数目)等属性进行管理。

二级缓存与一级缓存的交互顺序:当查询时,MyBatis会先查看二级缓存,如果没有,再查一级缓存,如果还没有,最后才去查数据库。拿到数据后,先放入一级缓存,在SqlSession提交/关闭时,再同步到二级缓存。

严重警告:二级缓存是跨SqlSession的,这意味着它可能带来严重的数据一致性问题。如果多个SqlSession(可能来自不同线程甚至不同应用节点)操作同一份数据,一个节点更新了数据并清空了自己的二级缓存,但其他节点的二级缓存并不会自动失效。因此,在分布式、高并发环境下,使用内置的二级缓存需要极其谨慎。通常,我们只在以下场景考虑使用:1. 数据几乎不变(如字典表)。2. 应用是单机部署。3. 能够接受一定的数据延迟。更常见的做法是,禁用MyBatis的二级缓存,使用集中式的、支持分布式一致性的缓存方案,如Redis,并通过MyBatis插件或AOP等方式集成。

7. 与Spring的集成:SqlSessionTemplate与事务管理

现在几乎没人会单独使用MyBatis了,都是和Spring/Spring Boot集成。集成后,我们几乎不再手动创建SqlSessionFactorySqlSession,而是直接注入Mapper接口。这背后的魔法,主要靠SqlSessionTemplateMapperScannerConfigurer(或@MapperScan)来实现。

7.1 SqlSessionTemplate:线程安全的会话代理

SqlSessionTemplate是Spring-MyBatis集成的核心类。它实现了SqlSession接口,但并不是一个真正的SqlSession,而是一个代理

  • 线程安全:它内部并不持有SqlSession实例,而是通过SqlSessionHolder将其与当前线程绑定(存储到TransactionSynchronizationManager的ThreadLocal资源中)。每次调用getSqlSession()方法时,它会先检查当前线程是否已经绑定了一个(在事务中),如果有就直接返回;如果没有,则从SqlSessionFactory新建一个,并可能根据配置决定是否将其放入事务同步管理器。这样就保证了每个线程使用的SqlSession是独立的,实现了线程安全。
  • 会话管理:它负责SqlSession的生命周期。在非事务环境下,通常每次执行Mapper方法,都会创建一个新的SqlSession,执行完毕后立即关闭(SqlSessionInterceptor拦截器负责此逻辑)。这正符合MyBatis推荐的“每次请求创建并关闭”的模式。在Spring管理的事务中,SqlSession会在事务开始时创建,并绑定到线程,事务提交或回滚后才关闭。
  • 异常转换:它将MyBatis抛出的PersistenceException转换为Spring的DataAccessException体系,方便统一处理。

7.2 Mapper接口如何变成Bean的?

我们定义的UserMapper接口,既没有实现类,也没有被@Repository标注,Spring是怎么把它变成一个Bean并注入到Service里的呢?这要归功于MapperScannerConfigurer@MapperScan注解。

它们会扫描指定的包路径,找到所有继承了Mapper接口(或标记了@Mapper注解)的接口。然后,通过MapperFactoryBean为每一个接口动态地创建一个Spring Bean。MapperFactoryBean在初始化时,会使用SqlSessionTemplategetMapper方法。而SqlSessionTemplate.getMapper()最终调用的是Configuration.getMapper()

Configuration里维护了一个MapperRegistry,它的getMapper方法会为指定的接口类型,使用JDK动态代理创建一个MapperProxy对象。这个MapperProxy就是Mapper接口的真正实现。当我们调用userMapper.selectById(1)时,实际上调用的是MapperProxy.invoke()方法。这个方法会:

  1. 判断调用的方法是Object自带的方法(如toString)还是默认方法,如果是,直接处理。
  2. 否则,根据接口名和方法名,拼接出MappedStatement的ID(如“com.example.UserMapper.selectById”)。
  3. 然后,调用SqlSessionTemplate的对应方法(如selectOne),并传入这个ID和参数。

至此,一个完整的调用链路就形成了:Service->MapperProxy->SqlSessionTemplate->Executor->Database

7.3 事务管理集成

Spring的声明式事务(@Transactional)能够完美管理MyBatis操作,核心在于DataSourceTransactionManager和前面提到的SqlSession线程绑定机制。

  1. 当进入一个被@Transactional标记的方法时,Spring会开启一个事务,并获取数据库连接。
  2. SqlSessionTemplate在需要SqlSession时,发现当前线程已有事务,就会尝试获取与当前事务绑定的SqlSession(通过TransactionSynchronizationManager)。
  3. 这个SqlSession使用的数据库连接,就是事务管理器中那个连接。这样,该事务内的所有MyBatis操作,都在同一个数据库连接和事务上下文中进行。
  4. 方法执行成功,Spring提交事务,SqlSession被关闭(连接归还连接池)。如果发生异常,Spring回滚事务。

集成中的坑:最常见的问题是一级缓存与事务的冲突。在Spring的默认配置下,SqlSession的生命周期和事务一致。这意味着在一个事务方法中,多次查询同一个数据,只会第一次访问数据库,后续都走一级缓存。这通常是符合预期的。但如果你在一个只读事务@Transactional(readOnly = true))中,先执行了一个查询,然后在同一个事务内,通过其他方式(比如JPA、JDBC Template)修改了同一条数据,再执行第二次查询,你依然会读到缓存里的旧数据。因为一级缓存没有被清空。这种情况下,可以考虑在查询方法上添加@Transactional(propagation = Propagation.REQUIRES_NEW)开启新事务,或者直接在该查询语句上配置flushCache=”true”。理解生命周期和边界,是解决这类问题的关键。

http://www.jsqmd.com/news/1388019/

相关文章:

  • Python静态分析实战:从Flake8到MyPy,提升代码质量与安全
  • 为什么越来越多的企业选择深圳优秀网站建设公司作为数字化转型的首选伙伴?揭秘背后的深层逻辑与避坑指南
  • 2026年北京门头沟区保暖服饰源头工厂靠谱推荐:马员外服饰全产业链实力解析 - 科技快讯
  • 2026年宁波韩国留学机构怎么选?6个选择维度与核验方法 - 科技焦点
  • 青岛网站建设华夏:深耕本土数字土壤,用真诚与专业重塑企业网络生命线,打造经得起时间考验的互联网名片
  • 心雨在线高端网站建设网页设计怎么做才能让你的网站真正体现品牌价值与专业度
  • Java中this关键字的底层原理与应用实践
  • 2026年北京通州区保暖服饰源头工厂靠谱推荐:马员外服饰全产业链实力解析 - 小随科技
  • 精密医疗设备出口海运场景中,强制要求防潮型重型定制纸箱的国际物流规范与货损防控逻辑是什么?
  • 2026甄选:北京美梦有限文化公司——黑色墨盒回收领域的资源循环服务解析 - 卓企推荐
  • 统计软件怎么用?从零掌握数据统计分析的核心操作指南
  • CodeBuddy 安装 Superpowers 插件 - 必用的 12 个 skills
  • 打造你的专属Shell:从零实现命令行解释器
  • 高空外墙清洗机器人哪个好:【凌度智能】越障强劲 - 秋山寄远
  • 前端多会话管理:基于localStorage实现状态隔离与持久化
  • 电子商务网站建设也管理与运营背后的那些坑与机会深度解析
  • 2026年北京东城区保暖服饰源头工厂靠谱推荐:马员外服饰全产业链实力解析 - 小随科技
  • ROS2 Action通信:C++实现长时任务管理与反馈机制
  • 结晶干燥不是“收尾活“:一个老兵30年的放大真相
  • 六大匿名树洞一周实测复盘 四款国产免费倾诉平台深度横评 - nuanyin
  • Vue+SpringBoot项目在麒麟系统上的完整部署实战指南
  • 赛级比熊犬舍选购测评:正规犬舍筛选与购宠避坑指南 - Full19
  • 源代码论文分享|热门网游推荐网站的设计与开发!
  • Git学习笔记:GitHub Contents API 完全指南,用 Go 轻松管理仓库文件 - PC2005
  • 内存冷热标记技术:原理、实现与应用场景解析
  • AI技术栈剧变下的开发者行动指南:多模态Agent与工程化实践
  • 能源行业新员工入职信息自动录入:基于AI Agent与ERP集成的全链路数智化实践
  • 2026年北京东城区保暖服饰源头工厂靠谱推荐:马员外服饰全产业链实力解析 - 科技快讯
  • 2026年北京门头沟区保暖服饰源头工厂靠谱推荐:马员外服饰全产业链实力解析 - 子柔传媒
  • 智能体运行时核心机制:循环、路由与上下文的设计与工程实践