深入解析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语句的所有信息唯一的IDnamespace 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对象并组织成一棵树。例如对于这样一段SQLselect idfindActiveBlogWithTitleLike resultTypeBlog SELECT * FROM BLOG WHERE state ‘ACTIVE’ if testtitle ! null AND title like #{title} /if /select解析器会生成一个MixedSqlNode它包含两个子节点一个StaticTextSqlNode内容为SELECT * FROM BLOG WHERE state ‘ACTIVE’。一个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。每个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字符串。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为例看它的工作实例化Statement调用connection.prepareStatement(sql)传入BoundSql里的sql字段此时已经是带?的。参数设置这是最精巧的一步。它调用ParameterHandler.setParameters(ps)。ParameterHandler默认实现DefaultParameterHandler会遍历BoundSql.parameterMappings对于每一个ParameterMapping做两件事值提取使用MetaObjectMyBatis提供的反射工具从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类型系统的基石。它的接口很简单核心就是两个方法setParameterJava - JDBC和getResultJDBC - Java。MyBatis为所有常见的Java类型String,Integer,Date等和JDBC类型都内置了对应的TypeHandler。它的强大之处在于可扩展性。比如我们想在一个VARCHAR字段里存储一个JSON字符串在Java实体中则对应一个MapString, Object属性。我们只需要自定义一个TypeHandlerMappedTypes(Map.class) MappedJdbcTypes(JdbcType.VARCHAR) public class JsonMapTypeHandler extends BaseTypeHandlerMapString, Object { private static final ObjectMapper objectMapper new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, MapString, Object parameter, JdbcType jdbcType) throws SQLException { // Java Map - JSON String - JDBC VARCHAR ps.setString(i, objectMapper.writeValueAsString(parameter)); } Override public MapString, 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对象以提高性能。正是有了MetaObjectMyBatis的动态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 interceptorcom.example.MyInterceptor property namesomeProperty value100/ /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) { // 返回一个新的BoundSqlsql字段被替换为分页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就会直接从缓存返回结果。失效时机增删改操作同一个SqlSession内执行了任何insert、update、delete语句Executor会清空整个localCache。这是因为这些操作可能改变了数据为了保证数据一致性必须失效缓存。这是最核心的失效机制。手动清空调用sqlSession.clearCache()。关闭SqlSession会话结束缓存自然销毁。执行了select但配置了flushCachetrue在select标签上设置flushCache”true”会使该查询执行前先清空一级和二级缓存。执行了select但配置了useCachefalse在select标签上设置useCache”false”会使该查询结果不放入一级和二级缓存。常见误区与坑点一级缓存容易导致“脏读”。考虑这个场景在同一个SqlSession里你先查询了一个用户id1然后另一个线程或另一个服务在数据库中修改了这个用户的数据。接着你在同一个SqlSession里再次查询id1的用户你拿到的还是旧数据因为命中了一级缓存。因此在Web应用中通常会将SqlSession的生命周期与一次请求绑定如Spring的SqlSessionTemplate请求结束就关闭这样一级缓存的作用范围就很有限既利用了缓存加速重复查询又避免了跨请求的脏数据问题。如果你的场景是批处理或长会话需要特别注意这一点。6.2 二级缓存Mapper级别的“共享缓存”二级缓存是默认关闭的。需要在mappernamespace级别或select语句级别通过cache属性或cache/标签显式开启。数据结构二级缓存存储在Configuration对象中是一个HashMapString, Cachekey是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引用数目等属性进行管理。二级缓存与一级缓存的交互顺序当查询时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”。理解生命周期和边界是解决这类问题的关键。