深入解析MyBatis源码:从动态SQL到插件机制的核心原理与实践
1. 从“会用”到“懂它”:我为什么要啃MyBatis源码
干了这么多年Java后端,MyBatis绝对是绕不开的“老朋友”。从最早的iBATIS时代用过来,到后来Spring Boot集成MyBatis Plus,配置个数据源、写个Mapper接口、在XML里拼个动态SQL,这套流程闭着眼睛都能走完。面试的时候,也能把一级缓存、二级缓存、插件机制这些概念背得滚瓜烂熟。但说实话,很长一段时间里,我对MyBatis的认知都停留在“会用”的层面。直到有一次,线上出了个诡异的Bug:一个复杂的多表关联查询,在预发环境跑得好好的,一上生产就偶尔超时,日志里看到的SQL明明一模一样。我们折腾了好久,加索引、调参数,效果都不明显。最后没办法,只能硬着头皮去跟MyBatis的执行过程。那是我第一次真正意义上“调试”MyBatis源码,从SqlSession的selectList方法一步步跟进去,穿过层层代理和拦截器,最终在ParameterHandler设置参数的那个环节发现,生产环境某个字段的值因为字符集问题,在构建BoundSql时,生成的预编译SQL参数占位符?所对应的实际参数值发生了微妙的改变,导致数据库执行计划走了另一条糟糕的路径。问题解决后,我忽然意识到,过去那种“面向搜索引擎编程”和“背诵面试题”的学习方式,在解决深层、复杂问题时是多么无力。真正能让你心里有底、快速定位问题的,是对核心机制的理解。所以,我决定系统性地啃一遍MyBatis源码,目标不是成为源码贡献者,而是为了在下次遇到问题时,能像老中医一样,望闻问切,直指病灶。这篇文章,就是我这趟“源码之旅”的笔记和思考,适合那些已经熟练使用MyBatis,但总感觉隔着一层纱,想掀开看看里面究竟是怎么运转的同行。
2. 庖丁解牛:MyBatis的核心骨架与启动流程
很多人看源码,一上来就扎进最复杂的动态SQL或者插件机制里,很容易迷失。我的经验是,先摸清骨架,再研究肌肉和神经。MyBatis的骨架,其实就是它的配置加载和会话管理体系。这部分的入口,通常就是我们熟悉的SqlSessionFactoryBuilder.build()方法。
2.1 配置文件是如何被“吃进去”的?
我们写的mybatis-config.xml和一堆Mapper.xml,在MyBatis眼里就是一堆需要被解析、验证并转化为内存中可操作对象的资源。这个过程主要由XMLConfigBuilder和XMLMapperBuilder这两个类完成。
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里最重要的接口之一,我们常用的selectOne、insert、commit等方法都定义在这里。
默认的实现类DefaultSqlSession本身并不干太多“脏活累活”,它更像一个调度中心。它内部持有了Configuration(所有配置信息)和Executor(真正的执行器)。当我们调用sqlSession.selectList(“com.xxx.UserMapper.selectById”, 1)时,DefaultSqlSession会:
- 根据语句ID从
Configuration里获取对应的MappedStatement。 - 将参数对象和
MappedStatement交给Executor去执行。 - 接收
Executor返回的List结果。
这里引出了两个关键设计:
- 线程安全性:
SqlSession实例是非线程安全的。这意味着你不能在多个线程间共享同一个SqlSession。最佳实践是在每个请求或方法作用域内获取并使用它,用完后立刻关闭。Spring集成时,SqlSessionTemplate通过将SqlSession绑定到当前线程的ThreadLocal上来管理这个生命周期,让我们感觉像是用了单例,实则背后是线程隔离的。 - 执行器Executor:这是真正的“发动机”。MyBatis有三种基本的执行器:
SIMPLE(简单执行)、REUSE(重用PreparedStatement)、BATCH(批处理)。Executor的职责包括创建StatementHandler、处理缓存(一级缓存就在这)、管理事务等。插件(Interceptor)也正是通过拦截Executor的方法来实现功能的。
启动流程的最后,所有解析好的MappedStatement、ResultMap、TypeHandler等都存放在那个全局唯一的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,它包含两个子节点:
- 一个
StaticTextSqlNode,内容为SELECT * FROM BLOG WHERE state = ‘ACTIVE’。 - 一个
IfSqlNode,它内部又包含:- 一个
test表达式:“title != null”。 - 一个子
SqlNode(StaticTextSqlNode),内容为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)。每个SqlNode的apply方法会根据自己的逻辑,决定是否向DynamicContext中追加SQL片段。
IfSqlNode.apply():它会用OGNL引擎去计算test属性里的表达式(如title != null)。OGNL会从DynamicContext绑定的参数对象里,根据表达式去获取title属性的值。如果表达式结果为true,则调用其子SqlNode的apply方法,将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的生成
当所有动态SqlNode都apply完毕后,DynamicContext中就得到了一条完整的、带有#{}占位符的原始SQL字符串。注意,这里还不是最终发给JDBC的SQL。
接下来,DynamicSqlSource会使用SqlSourceBuilder对这个字符串进行第二次解析。这次解析的目标是处理#{}和${}。
- 对于
#{},解析器会将其替换成?,并创建一个ParameterMapping对象,记录属性路径(如“title”)、Java类型、JDBC类型等信息。这些ParameterMapping最终会存入BoundSql对象。 - 对于
${},解析器会直接进行OGNL求值,将结果以字符串形式拼接到SQL中。这就是为什么${}存在SQL注入风险,因为它直接改变了SQL语句的结构。
最终,DynamicSqlSource会返回一个BoundSql对象。这个对象包含了:
sql:已经将#{}替换为?、将${}替换为实际值的、可以直接交给PreparedStatement的SQL字符串。parameterMappings:ParameterMapping列表,对应每个?。parameterObject:用户传入的原始参数对象。
至此,一条动态SQL完成了从声明式标签到可执行语句的蜕变。BoundSql将被传递给StatementHandler,进入下一阶段的参数设置和结果处理。
4. 执行引擎深处:StatementHandler与TypeHandler的协奏
拿到BoundSql后,Executor会把执行任务委托给StatementHandler。如果说Executor是总经理,那StatementHandler就是负责具体项目的项目经理。它负责创建Statement对象、设置参数、执行SQL、处理结果集。而在这个过程中,默默无闻但又至关重要的“专家顾问”就是TypeHandler。
4.1 StatementHandler的三板斧
MyBatis有几种StatementHandler:SimpleStatementHandler(用于静态Statement)、PreparedStatementHandler(用于预编译PreparedStatement,最常用)、CallableStatementHandler(用于存储过程)。我们以PreparedStatementHandler为例看它的工作:
- 实例化Statement:调用
connection.prepareStatement(sql),传入BoundSql里的sql字段(此时已经是带?的)。 - 参数设置:这是最精巧的一步。它调用
ParameterHandler.setParameters(ps)。ParameterHandler(默认实现DefaultParameterHandler)会遍历BoundSql.parameterMappings,对于每一个ParameterMapping,做两件事:- 值提取:使用
MetaObject(MyBatis提供的反射工具)从parameterObject中,按照property属性路径(如“user.address.postCode”)把值取出来。 - 类型转换与设置:将取出的Java对象,交给对应的
TypeHandler,由TypeHandler调用PreparedStatement.setXXX(parameterIndex, value)方法,将值设置到对应的?占位符上。
- 值提取:使用
- 执行与结果处理:执行
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的通用枚举功能,或者自定义一个基于code的TypeHandler,这样数据库里存的是简洁的数字或字符串,程序里用的是有意义的枚举对象,清晰又方便。
4.3 MetaObject:反射的优雅封装
上面提到ParameterHandler用MetaObject来取值。MetaObject是MyBatis提供的一个用于优雅、高效操作对象属性的工具类。它封装了Java反射、BeanInfo、甚至Map和Collection的操作,提供了一套统一的API。
比如,对于parameterObject是一个User对象,User里有一个Address属性,Address里有一个city属性。要获取city的值,用MetaObject只需要:
MetaObject metaObject = SystemMetaObject.forObject(parameterObject); String city = (String) metaObject.getValue(“address.city”);它内部会智能地判断每一步是getter方法、Map的get还是直接字段访问,并缓存反射的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方法传入属性。然后,它会将这个拦截器实例添加到Configuration的InterceptorChain(拦截器链)中。
关键来了:当Configuration去创建Executor、ParameterHandler、ResultSetHandler、StatementHandler这四大组件的实例时(比如在newExecutor、newParameterHandler等方法里),它会调用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()。它会检查:
- 当前被调用的方法,是否在我们拦截器声明的
@Intercepts注解范围内。 - 如果是,则先执行拦截器的
intercept方法(在这里我们可以编写前置/后置逻辑,甚至改变参数、返回值,或完全跳过原方法)。 - 如果不是,则直接反射调用目标对象的原方法。
因为拦截器链是顺序包装的,所以最终我们得到的组件实例,可能是这样一个“套娃”结构:代理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相同。
- 数据结构:在
BaseExecutor(Executor的基类)中,有一个PerpetualCache类型的localCache字段。它本质上就是一个HashMap。 - 如何工作:当执行查询时,
Executor会以CacheKey(由MappedStatement Id、SQL、参数值、分页信息等计算得出)作为key,查询结果作为value,存入localCache。下次在同一个SqlSession内执行完全相同的查询(相同的CacheKey),就会直接从缓存返回结果。 - 失效时机:
- 增删改操作:同一个
SqlSession内执行了任何insert、update、delete语句,Executor会清空整个localCache。这是因为这些操作可能改变了数据,为了保证数据一致性,必须失效缓存。这是最核心的失效机制。 - 手动清空:调用
sqlSession.clearCache()。 - 关闭SqlSession:会话结束,缓存自然销毁。
- 执行了
select但配置了flushCache=true:在<select>标签上设置flushCache=”true”,会使该查询执行前先清空一级(和二级)缓存。 - 执行了
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中获取数据。 - 失效时机:
- 执行了来自同一个namespace的
insert、update、delete语句,并成功提交后,该namespace下的所有二级缓存会被清空。 - 在
<select>上配置了flushCache=”true”。 - 在
<select>上配置了useCache=”false”。 - 通过
<cache>标签的eviction(淘汰策略,如LRU)、flushInterval(刷新间隔)、size(引用数目)等属性进行管理。
- 执行了来自同一个namespace的
二级缓存与一级缓存的交互顺序:当查询时,MyBatis会先查看二级缓存,如果没有,再查一级缓存,如果还没有,最后才去查数据库。拿到数据后,先放入一级缓存,在SqlSession提交/关闭时,再同步到二级缓存。
严重警告:二级缓存是跨
SqlSession的,这意味着它可能带来严重的数据一致性问题。如果多个SqlSession(可能来自不同线程甚至不同应用节点)操作同一份数据,一个节点更新了数据并清空了自己的二级缓存,但其他节点的二级缓存并不会自动失效。因此,在分布式、高并发环境下,使用内置的二级缓存需要极其谨慎。通常,我们只在以下场景考虑使用:1. 数据几乎不变(如字典表)。2. 应用是单机部署。3. 能够接受一定的数据延迟。更常见的做法是,禁用MyBatis的二级缓存,使用集中式的、支持分布式一致性的缓存方案,如Redis,并通过MyBatis插件或AOP等方式集成。
7. 与Spring的集成:SqlSessionTemplate与事务管理
现在几乎没人会单独使用MyBatis了,都是和Spring/Spring Boot集成。集成后,我们几乎不再手动创建SqlSessionFactory或SqlSession,而是直接注入Mapper接口。这背后的魔法,主要靠SqlSessionTemplate和MapperScannerConfigurer(或@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在初始化时,会使用SqlSessionTemplate的getMapper方法。而SqlSessionTemplate.getMapper()最终调用的是Configuration.getMapper()。
Configuration里维护了一个MapperRegistry,它的getMapper方法会为指定的接口类型,使用JDK动态代理创建一个MapperProxy对象。这个MapperProxy就是Mapper接口的真正实现。当我们调用userMapper.selectById(1)时,实际上调用的是MapperProxy.invoke()方法。这个方法会:
- 判断调用的方法是
Object自带的方法(如toString)还是默认方法,如果是,直接处理。 - 否则,根据接口名和方法名,拼接出
MappedStatement的ID(如“com.example.UserMapper.selectById”)。 - 然后,调用
SqlSessionTemplate的对应方法(如selectOne),并传入这个ID和参数。
至此,一个完整的调用链路就形成了:Service->MapperProxy->SqlSessionTemplate->Executor->Database。
7.3 事务管理集成
Spring的声明式事务(@Transactional)能够完美管理MyBatis操作,核心在于DataSourceTransactionManager和前面提到的SqlSession线程绑定机制。
- 当进入一个被
@Transactional标记的方法时,Spring会开启一个事务,并获取数据库连接。 SqlSessionTemplate在需要SqlSession时,发现当前线程已有事务,就会尝试获取与当前事务绑定的SqlSession(通过TransactionSynchronizationManager)。- 这个
SqlSession使用的数据库连接,就是事务管理器中那个连接。这样,该事务内的所有MyBatis操作,都在同一个数据库连接和事务上下文中进行。 - 方法执行成功,Spring提交事务,
SqlSession被关闭(连接归还连接池)。如果发生异常,Spring回滚事务。
集成中的坑:最常见的问题是一级缓存与事务的冲突。在Spring的默认配置下,
SqlSession的生命周期和事务一致。这意味着在一个事务方法中,多次查询同一个数据,只会第一次访问数据库,后续都走一级缓存。这通常是符合预期的。但如果你在一个只读事务(@Transactional(readOnly = true))中,先执行了一个查询,然后在同一个事务内,通过其他方式(比如JPA、JDBC Template)修改了同一条数据,再执行第二次查询,你依然会读到缓存里的旧数据。因为一级缓存没有被清空。这种情况下,可以考虑在查询方法上添加@Transactional(propagation = Propagation.REQUIRES_NEW)开启新事务,或者直接在该查询语句上配置flushCache=”true”。理解生命周期和边界,是解决这类问题的关键。
