尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

MyBatis一级缓存源码解析:从诡异数据丢失到高效Debug实践

MyBatis一级缓存源码解析:从诡异数据丢失到高效Debug实践 1. 从一次“诡异”的查询说起为什么一级缓存会“吃掉”我的数据那天下午我正在调试一个财务对账的定时任务。逻辑很简单在一个声明式事务方法里先根据订单号查询出一笔待处理的交易记录我们叫它Transaction对象然后根据业务规则修改这个对象的一些状态字段最后调用updateById方法将其更新回数据库。代码写得很顺畅单元测试也全绿了。但一上预发布环境跑批量数据对账结果总是对不上——大量记录的状态没有被正确更新。我盯着日志看了半天update语句明明执行了affected rows也是1但用SELECT查出来的数据却还是老样子。那一刻我仿佛听到了数据库在无声地嘲笑。最初的怀疑指向了数据库事务隔离级别、甚至是缓存框架。但当我祭出IDEA的Debug工具深入到MyBatis的源码层面后真相让我哭笑不得问题就出在MyBatis最基础、也最容易被忽视的一级缓存上。具体来说我的服务方法大概长这样Transactional(rollbackFor Exception.class) public void reconcileTransaction(String orderNo) { // 第一次查询事务开始时 Transaction trans transactionMapper.selectByOrderNo(orderNo); // ... 一些业务逻辑计算修改了trans对象的status和amount字段 ... trans.setStatus(RECONCILED); trans.setAmount(newAmount); // 执行更新 transactionMapper.updateById(trans); // 为了验证再次查询问题就出在这里 Transaction transAfterUpdate transactionMapper.selectByOrderNo(orderNo); // 此时transAfterUpdate 的对象状态很可能和 trans 一样但并不是数据库中的最新值 log.info(更新后状态: {}, transAfterUpdate.getStatus()); // 可能还是旧状态 }在同一个SqlSession在Spring集成中通常等同于一个数据库事务生命周期内MyBatis默认会启用一级缓存。当我第一次执行selectByOrderNo时查询结果Transaction对象会被缓存起来以查询语句的IDMapper方法全限定名和参数orderNo的哈希值作为key。当我执行updateById后这个更新操作会清空整个SqlSession内的一级缓存以防止脏读。然而关键在于“清空”这个动作。它只是移除了缓存条目但我代码中trans这个对象引用并没有变。紧接着的第二次selectByOrderNo因为缓存已被清空MyBatis会真的发起一次新的数据库查询。但是如果数据库连接池返回的是同一个Connection且事务隔离级别是REPEATABLE READMySQL默认级别或以上在这次新查询的事务视图里由于第一次查询已经建立了快照它读到的仍然是事务开始时的旧数据。更糟糕的是MyBatis一级缓存的实现PerpetualCache在缓存未命中、执行完新的数据库查询后会把结果对象放回缓存。而MyBatis默认的LocalCacheScope是SESSION这意味着这个“旧数据”的对象又会被缓存起来。但这里还有一个更隐蔽的坑MyBatis的Executor在执行query方法时如果缓存命中它默认返回的是缓存对象的引用。这意味着trans和transAfterUpdate可能根本就是同一个对象updateById操作修改了trans的属性也同时修改了缓存里的那个对象。所以即使第二次查询因为缓存清空而走了数据库拿到新数据创建了新对象但当它要放入缓存时发现key已经存在因为清空只是移除条目但key的映射关系还在这里需要仔细看源码在某些配置或代码逻辑下可能导致它直接使用了缓存中已有的对象引用即被修改过的trans而不是新查询到的对象。这个问题的根源是对MyBatis一级缓存的生命周期、失效机制以及与Spring事务、数据库隔离级别交互的理解不够透彻。解决它要么在更新后手动清除这个特定查询的缓存sqlSession.clearCache()要么在查询方法上添加flushCachetrue选项要么将localCacheScope设置为STATEMENT每个语句执行后清空缓存但最根本的是要理解对象引用与缓存之间的关系。这次踩坑让我下定决心不能只停留在会用的层面必须深入MyBatis的源码看看这些魔法背后到底是怎么运行的。而阅读源码最锋利的武器就是Debug。2. 工欲善其事配置一个高效的MyBatis源码Debug环境直接打开MyBatis的源码包就开始读很容易迷失在庞大的类结构中。我们需要一个可以实际运行、并能在关键节点打断点的“沙盒”。最好的方式就是创建一个最简单的、集成了MyBatis的Spring Boot测试项目。2.1 项目搭建与依赖引入首先用你熟悉的IDE这里以IntelliJ IDEA为例创建一个新的Spring Boot项目选择Web和MyBatis Framework依赖。或者手动在pom.xml中添加关键依赖dependencies !-- Spring Boot Starter -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId /dependency !-- MyBatis Spring Boot Starter -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version !-- 使用与你要调试的MyBatis版本对应的Starter -- /dependency !-- 数据库驱动这里用H2内存数据库方便 -- dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency !-- 方便查看执行的SQL -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies为了调试MyBatis核心源码我们需要将其源码而不是二进制的jar包引入项目。有两种方法下载源码并关联推荐从MyBatis的GitHub仓库https://github.com/mybatis/mybatis-3克隆或下载对应版本的源码。在IDEA中找到项目外部库里的mybatis-xxx.jar右键 -Download Sources。如果下载慢或失败就手动下载源码包然后对jar包右键 -Open Library Settings- 在Sources标签页添加本地源码路径。以Maven源码形式依赖在pom.xml中添加MyBatis核心依赖并指定classifier为sources。但这种方式可能会引入大量编译问题不推荐。提示确保你下载的MyBatis源码版本与你项目使用的mybatis-spring-boot-starter内嵌的MyBatis核心版本一致。可以在Maven依赖树中查看。2.2 准备测试用例与数据在src/test/java下创建一个简单的测试类。我们用一个最简单的User实体和对应的Mapper来做实验。User.java:Data // 使用Lombok public class User { private Long id; private String name; private Integer age; }UserMapper.java:Mapper public interface UserMapper { Select(SELECT * FROM user WHERE id #{id}) User selectById(Long id); Update(UPDATE user SET name#{name}, age#{age} WHERE id#{id}) int updateById(User user); }在src/test/resources下创建schema.sql和data.sql让H2数据库启动时自动建表和插入测试数据。schema.sql:CREATE TABLE IF NOT EXISTS user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100), age INT );data.sql:INSERT INTO user(id, name, age) VALUES (1, 张三, 20);2.3 关键Debug断点设置指南有了可运行的环境下一步就是知道在哪里打断点。MyBatis的核心执行流程可以概括为接口代理 - SQL会话 - 执行器 - 语句处理器 - JDBC。我们的断点应该沿着这条主线布设。入口点代理类MyBatis使用JDK动态代理为Mapper接口生成代理对象。断点可以打在org.apache.ibatis.binding.MapperProxy.invoke方法上。这里可以看Mapper方法如何被拦截。调度中心SqlSession代理对象最终会调用SqlSession的方法。断点打在org.apache.ibatis.session.defaults.DefaultSqlSession的selectOne、update等方法上。这是面向用户的API入口。核心大脑ExecutorSqlSession将工作委托给Executor。这是核心中的核心缓存、事务、语句执行都在这里协调。重点在org.apache.ibatis.executor.BaseExecutor的query和update方法。在query方法里可以看到一级缓存Local Cache的查询逻辑list resultHandler null ? (ListE) localCache.getObject(key) : null;。在update方法里可以看到执行更新后清空本地缓存的逻辑clearLocalCache();。语句执行StatementHandlerExecutor会使用StatementHandler来操作JDBC的Statement对象。可以断点org.apache.ibatis.executor.statement.PreparedStatementHandler的query和update方法看SQL是如何被预编译和设置参数的。结果集映射ResultSetHandler这是将JDBC的ResultSet转换成Java对象的地方。断点org.apache.ibatis.executor.resultset.DefaultResultSetHandler的handleResultSets方法这里是ORM魔法发生的关键。SQL源头MappedStatement在Executor的query方法中会获取MappedStatement对象。它包含了SQL语句、入参映射、出参映射等所有解析后的信息。可以看看它是如何被获取和使用的。在IDEA的Breakpoints窗口中可以对这些断点设置条件例如只在执行特定的Mapper方法通过方法名判断时才触发避免被其他无关的SQL干扰。2.4 开启MyBatis完整日志为了在Debug时能直观看到SQL执行和缓存情况需要在application.yml中配置完整的MyBatis日志。这能让我们在控制台看到MyBatis执行的每一步。logging: level: # 你项目对应的Mapper接口包路径设为DEBUG com.example.demo.mapper: DEBUG # MyBatis核心类的日志设为TRACE或DEBUG能看到缓存命中、SQL参数等细节 org.apache.ibatis: TRACE # 显示JDBC层面的操作包括事务提交、回滚 java.sql.Connection: DEBUG java.sql.Statement: DEBUG java.sql.PreparedStatement: DEBUG配置好后运行测试你会在日志中看到类似这样的缓存命中信息Cache Hit Ratio [com.example.mapper.UserMapper]: 0.5以及详细的SQL执行日志。结合这些日志和断点就能像看一场慢放的电影一样观察MyBatis的完整执行链路。3. 庖丁解牛核心执行流程的源码级拆解环境就绪让我们启动Debug运行一个简单的userMapper.selectById(1L)顺着代码执行流深入MyBatis的腹地。3.1 旅程的起点MapperProxy与动态代理当我们调用userMapper.selectById(1L)时我们调用的并不是一个真正的实现类对象而是一个由JDK动态代理生成的MapperProxy实例。断点会首先落在MapperProxy.invoke方法。// org.apache.ibatis.binding.MapperProxy public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { try { // 判断是否为Object类的方法如toString hashCode等直接执行 if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } else { // 核心将方法调用转换为对SqlSession的调用 return cachedInvoker(method).invoke(proxy, method, args, sqlSession); } } catch (Throwable t) { throw ExceptionUtil.unwrapThrowable(t); } }cachedInvoker(method)会获取或创建一个MapperMethodInvoker。对于默认的接口方法会使用PlainMethodInvoker它内部封装了一个MapperMethod对象。MapperMethod是这里的关键它像一个方法指令解析器内部有两个重要属性SqlCommand包含了SQL语句的ID如com.example.mapper.UserMapper.selectById和类型SELECT,INSERT等。MethodSignature包含了方法的返回类型、参数注解等信息。invoke方法的最终会调用mapperMethod.execute(sqlSession, args)。在这个execute方法里会根据SqlCommand的类型决定是调用sqlSession.selectOne、selectList、insert、update还是delete。我们的selectById注解是Select所以会走到sqlSession.selectOne分支。实操心得这里解释了为什么MyBatis的Mapper接口不需要实现类。动态代理 MapperMethod指令解析的模式是MyBatis轻量化和灵活性的基石。Debug时可以重点观察MapperMethod是如何根据方法签名和SQL注解精确路由到SqlSession的不同方法的。3.2 指挥中枢SqlSession与Executor的交接DefaultSqlSession.selectOne方法实际上会调用selectList并取返回列表的第一个元素。我们进入selectList。// org.apache.ibatis.session.defaults.DefaultSqlSession public E ListE selectList(String statement, Object parameter, RowBounds rowBounds) { try { // 1. 根据statement id获取MappedStatement它包含了该SQL的所有配置信息 MappedStatement ms configuration.getMappedStatement(statement); // 2. 调用Executor执行查询注意这里传递了ResultHandler为null return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER); } catch (Exception e) { throw ExceptionFactory.wrapException(Error querying database. Cause: e, e); } finally { ErrorContext.instance().reset(); } }SqlSession在这里扮演了一个**门面Facade**的角色它接收请求获取对应的SQL指令描述MappedStatement然后就把任务交给了真正的执行引擎——Executor。Executor是一个接口默认的实现是CachingExecutor装饰器模式用于二级缓存和SimpleExecutor/ReuseExecutor/BatchExecutor基础执行器。在Spring集成环境下由于我们通常开启了缓存所以实际用的是CachingExecutor。注意Debug时观察executor变量的具体类型。CachingExecutor内部持有一个delegate指向SimpleExecutor等基础执行器。查询流程会先经过CachingExecutor检查二级缓存再委托给基础执行器检查一级缓存并执行JDBC。3.3 缓存与执行的核心BaseExecutor.query方法这是整个查询流程最核心的方法。我们以SimpleExecutor的父类BaseExecutor的query方法为例。// org.apache.ibatis.executor.BaseExecutor public E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { // ... 错误上下文设置等 ... ListE list; try { // 1. 检查一级缓存Local Cache list resultHandler null ? (ListE) localCache.getObject(key) : null; if (list ! null) { // 缓存命中处理存储过程输出参数略 handleLocallyCachedOutputParameters(ms, key, parameter, boundSql); } else { // 2. 缓存未命中从数据库查询 list queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); } } finally { queryStack--; } // ... 后续处理 ... return list; }关键点1CacheKey的生成key参数是决定缓存是否命中的唯一标识。它是在上层方法中通过createCacheKey方法生成的。CacheKey的update方法依次将以下信息加入哈希计算MappedStatement的IDRowBounds的offset和limit传递给JDBC的SQL字符串BoundSql.getSql()传递给JDBC的参数值列表环境IDEnvironment 这意味着只要上述任何一项不同比如参数值从1变成2就会生成不同的CacheKey也就不会命中缓存。关键点2一级缓存的生命周期localCache是一个PerpetualCache对象本质上就是一个HashMap。它的生命周期与SqlSession绑定。在Spring中SqlSession的生命周期通常与事务同步通过SqlSessionTemplate管理。这就是为什么在同一个事务内多次相同查询会命中缓存。关键点3queryFromDatabase这是缓存未命中时的实际查询路径private E ListE queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { ListE list; // 1. 先往缓存里放一个占位符防止并发重复查询 localCache.putObject(key, EXECUTION_PLACEHOLDER); try { // 2. 执行真正的查询doQuery是抽象方法由子类实现 list doQuery(ms, parameter, rowBounds, resultHandler, boundSql); } finally { // 3. 移除占位符 localCache.removeObject(key); } // 4. 将查询结果放入一级缓存 localCache.putObject(key, list); // ... 存储过程输出参数处理 ... return list; }踩坑记录这里的list是查询返回的集合对象。如果返回的是单个对象如selectOnelist里也只有一个元素。这个对象会被直接存入缓存localCache。这就是文章开头那个“诡异”问题的根源之一如果后续操作修改了这个缓存对象的属性那么下次缓存命中时你拿到的就是被修改过的、脏的对象。MyBatis默认不会对缓存对象进行深拷贝。3.4 语句执行与结果映射SimpleExecutor.doQueryBaseExecutor.queryFromDatabase调用了抽象方法doQuery由子类实现。我们看SimpleExecutor.doQuery。// org.apache.ibatis.executor.SimpleExecutor public E ListE doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException { Statement stmt null; try { Configuration configuration ms.getConfiguration(); // 1. 创建StatementHandler (RoutingStatementHandler会根据语句类型路由到具体的Handler) StatementHandler handler configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql); // 2. 准备Statement获取连接、预编译SQL、设置参数 stmt prepareStatement(handler, ms.getStatementLog()); // 3. 执行查询并由ResultSetHandler处理结果集 return handler.query(stmt, resultHandler); } finally { closeStatement(stmt); } }步骤1创建StatementHandlerStatementHandler是处理JDBC Statement的核心。RoutingStatementHandler是一个路由代理根据MappedStatement的statementTypeSTATEMENT,PREPARED,CALLABLE创建对应的PreparedStatementHandler最常用、SimpleStatementHandler或CallableStatementHandler。步骤2prepareStatement这个方法里完成了获取数据库连接、预编译SQL、设置参数的关键步骤。private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException { Statement stmt; // 获取连接事务相关连接可能从Transaction中获取 Connection connection getConnection(statementLog); // 预编译SQL创建PreparedStatement stmt handler.prepare(connection, transaction.getTimeout()); // 设置SQL参数 handler.parameterize(stmt); return stmt; }handler.parameterize(stmt)会调用ParameterHandler.setParameters。这里就是#{}参数被替换成?并且值被设置到PreparedStatement的地方。Debug时可以深入看DefaultParameterHandler看它如何利用TypeHandler将Java类型的参数转换成JDBC类型的值。步骤3handler.query与结果映射最终PreparedStatementHandler.query方法执行ps.execute()然后调用ResultSetHandler.handleResultSets。ResultSetHandler是ORM的魔术师。DefaultResultSetHandler.handleResultSets方法非常复杂但核心逻辑是获取MappedStatement中定义的ResultMap。遍历ResultSet。根据ResultMap的规则通过反射创建目标类型的对象并将ResultSet中的列值映射到对象的属性上。处理嵌套查询association,collection等高级映射。经验技巧当遇到复杂的ResultMap映射出错或者TypeHandler转换异常时在DefaultResultSetHandler的applyPropertyMappings或getPropertyMappingValue方法打断点可以清晰地看到每个属性是如何被赋值的快速定位是哪个字段、哪个TypeHandler出了问题。4. 深入肌理一级缓存的陷阱与二级缓存的迷思理解了基本流程我们再回头深入探讨缓存这个“坑王”。4.1 一级缓存Local Cache的失效场景与“伪失效”一级缓存默认开启作用域为SqlSession。但以下情况会导致它“失效”或“被清空”执行增删改操作UPDATE, INSERT, DELETE这是最明确的失效。在BaseExecutor.update方法中执行完doUpdate后会立即调用clearLocalCache()。这就是为什么文章开头的update操作后缓存被清空了。手动清空缓存调用sqlSession.clearCache()。配置localCacheScopeSTATEMENT这样配置后每次查询后都会清空缓存一级缓存几乎失效。提交或回滚事务SqlSession的commit和rollback方法也会调用clearLocalCache()。在Spring中事务提交时会发生。但有一种“伪失效”情况需要警惕跨SqlSession的查询不会共享一级缓存。在Spring中即使是在同一个业务方法里如果你通过Transactional注解开启了新事务或者在某些AOP切面中创建了新的SqlSession那么两次查询可能不在同一个SqlSession里自然无法命中缓存。Debug时可以观察SqlSession的identityHashCode来判断是否是同一个会话。4.2 对象引用与缓存污染一个隐蔽的Bug之源这是比缓存失效更棘手的问题。我们通过一个Debug实验来重现。假设我们执行以下代码片段// 假设一级缓存中存在 keyK1, valueUser(id1, nameOldName) User user1 userMapper.selectById(1L); // 第一次查询从DB加载放入缓存 user1.setName(ModifiedName); // 修改了对象属性但未更新数据库 User user2 userMapper.selectById(1L); // 第二次查询命中缓存 System.out.println(user1 user2); // 输出 true是同一个对象 System.out.println(user2.getName()); // 输出 ModifiedName而不是数据库里的OldName在BaseExecutor.query方法中当缓存命中时list ! null它直接返回了localCache.getObject(key)。这个getObject方法对于PerpetualCacheHashMap实现来说就是map.get(key)返回的是对象的引用。所以user1和user2指向堆内存中的同一个User对象。对user1属性的修改直接影响到了缓存中的值进而影响了user2。解决方案设置localCacheScopeSTATEMENT彻底避免会话级缓存带来的对象共享问题但牺牲了性能。返回不可变对象或防御性拷贝在实体类中提供拷贝构造函数或使用工具类进行深拷贝但这对框架是侵入式的。最实用的建议意识到并规避。在事务内避免修改从MyBatis查询返回的实体对象后再将其作为参数传递给其他可能依赖其原始值的逻辑。或者在需要修改后立即执行更新操作并清楚缓存已被清空。Debug验证你可以在PerpetualCache.getObject方法处打断点观察返回的对象地址。也可以在修改user1的属性后再次Debug查询流程看缓存命中后返回的list中的对象地址是否与user1相同。4.3 二级缓存Second Level Cache的工作机制与风险二级缓存是Mapper级别的缓存多个SqlSession可以共享。它的配置在Mapper XML文件中通过cache/标签开启。启用过程在Mapper.xml中添加cache/。MyBatis在解析XML时会为该Mapper创建一个Cache对象默认是PerpetualCache并用装饰器如LruCache,ScheduledCache,SerializedCache等包装它。当使用CachingExecutor时在执行query前会先尝试从MappedStatement对应的Cache中获取。工作流程CachingExecutor.querypublic E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { // 1. 获取该MappedStatement对应的缓存 Cache cache ms.getCache(); if (cache ! null) { // 2. 判断是否需要刷新缓存如配置了flushCachetrue flushCacheIfRequired(ms); if (ms.isUseCache() resultHandler null) { // 3. 从二级缓存获取 ListE list (ListE) tcm.getObject(cache, key); if (list null) { // 4. 二级缓存未命中委托给被装饰的Executor查询 list delegate.query(ms, parameter, rowBounds, resultHandler, key, boundSql); // 5. 将查询结果放入二级缓存 tcm.putObject(cache, key, list); } return list; } } // 无缓存配置直接委托查询 return delegate.query(ms, parameter, rowBounds, resultHandler, key, boundSql); }关键点tcm是TransactionalCacheManager它管理着事务上下文中的缓存。二级缓存的值是在事务提交时才真正写入底层Cache的TransactionalCache.commit方法。这是为了保证事务隔离性如果事务回滚缓存也不会被污染。二级缓存的巨大风险脏读这是最大的问题。如果ServiceA更新了某条数据但事务未提交ServiceB另一个SqlSession从二级缓存中读到的还是旧数据。虽然MyBatis通过TransactionalCache在事务提交后才真正写入缓存来缓解但在分布式或复杂事务场景下依然难以保证绝对一致性。序列化与反序列化为了让缓存能被跨会话共享存入二级缓存的对象必须是可序列化的。MyBatis默认使用SerializedCache装饰器它会在存储时序列化对象读取时反序列化。这带来了额外性能开销且要求所有实体类实现Serializable接口。更关键的是反序列化得到的是一个新对象这避免了“对象引用”问题但也意味着你修改查询返回的对象不会影响缓存中的副本。缓存粒度粗二级缓存是Mapper级别的一个Mapper中任何一个update语句执行并提交都会清空整个Mapper的缓存。这对于更新频繁的应用缓存命中率会很低。个人建议在大多数分布式、高并发的Web应用中不建议启用MyBatis的二级缓存。数据一致性难以保证性能提升有限且不稳定。对于读多写少、数据一致性要求不高的场景可以考虑使用更专业、更可控的集中式缓存如Redis并通过MyBatis的缓存接口进行集成或者直接在业务层处理缓存逻辑。5. 举一反三从源码看日常开发中的最佳实践与避坑指南通过源码Debug我们不仅看到了流程更理解了设计背后的权衡。这些理解能直接指导我们写出更健壮的代码。5.1 Mapper接口与XML的绑定原理为什么名字要对上在DebugConfiguration.getMappedStatement时你会发现它用一个MapString, MappedStatement来存储所有语句key就是statement id。对于XML方式id是namespace . id对于注解方式id是接口的全限定名 . 方法名。绑定过程解析阶段MyBatis启动时XMLMapperBuilder会解析Mapper.xml文件为每一个select|insert|update|delete标签创建一个MappedStatement对象以其namespace id为key存入Configuration。注册阶段MapperRegistry会为每个Mapper接口创建动态代理工厂MapperProxyFactory。运行时当调用Mapper接口方法时MapperProxy会组合接口全限定名和方法名作为statement id去Configuration里查找对应的MappedStatement。所以如果XML文件的namespace写错了或者select的id和接口方法名对不上在启动时并不会报错因为XML和接口是独立解析的但在运行时调用方法时就会因为找不到MappedStatement而抛出BindingException。这就是为什么要求XML的namespace必须对应接口的全限定名SQL的id必须对应接口方法名。Debug技巧遇到Invalid bound statement (not found)错误时可以在MapperProxy.invoke方法里打断点查看它生成的statement id是什么然后去Configuration.mappedStatements这个Map里看看是否存在对应的key就能快速定位是命名空间错误还是方法名错误。5.2 #{}与${}的底层差异不仅仅是防注入在ParameterHandler.setParameters方法里你能清晰地看到两者的区别。#{}会被解析为JDBC的预编译占位符?。ParameterHandler会使用对应的TypeHandler在ps.setXXX()的时候将参数值安全地设置进去。这是安全的能防止SQL注入。${}在SQL解析阶段SqlSourceBuilder处理时会直接进行字符串替换拼接到最终的SQL字符串中。ParameterHandler不会处理它。这存在SQL注入风险但也带来了灵活性比如动态指定表名、列名。// 示例ORDER BY ${orderByColumn} // 如果orderByColumn来自用户输入name; DROP TABLE user; --那么SQL将变成 ORDER BY name; DROP TABLE user; -- // 而#{}则会将其转换为 ORDER BY ?数据库会报错因为ORDER BY后不能接参数。最佳实践永远优先使用#{}。只有在动态表名、列名等无法使用预编译参数的情况下才考虑使用${}并且必须对输入值进行严格的白名单校验绝对不能让用户输入直接拼接。5.3 插件Plugin与拦截器的原理如何织入执行流程MyBatis的插件机制是其扩展性的核心。它基于JDK动态代理。在Configuration初始化时会为四大核心组件Executor,StatementHandler,ParameterHandler,ResultSetHandler创建代理。拦截点插件可以拦截这些组件的方法调用。例如一个分页插件可能会拦截Executor.query方法在SQL执行前修改SQL语句加上LIMIT或者拦截ResultSetHandler.handleResultSets方法对结果进行额外处理。Debug观察你可以在Configuration的newExecutor,newStatementHandler,newParameterHandler,newResultSetHandler方法上打断点。看看插件链InterceptorChain是如何包装原始对象的。最终你拿到的Executor等对象是一个被多层代理包裹的对象。当调用其方法时会经过所有注册的插件的intercept方法。编写自定义插件实现Interceptor接口在intercept方法中编写你的逻辑用Invocation.proceed()来调用链中的下一个插件或原始方法。通过Intercepts和Signature注解指定要拦截的类和方法。应用场景除了经典的分页插件插件还可用于SQL执行时间监控在StatementHandler.update/query前后记录时间。数据脱敏在ResultSetHandler处理结果集后对特定字段进行脱敏。强制读写分离根据方法名或注解在Executor层面将查询路由到从库。Debug插件执行流程能帮你更好地理解AOP在MyBatis中的实现也能在插件出错时快速定位问题。5.4 事务管理MyBatis如何与Spring事务协同工作在纯MyBatis中你需要手动调用sqlSession.commit()。但在Spring中我们通常使用Transactional注解。这背后的桥梁是SqlSessionTemplate和SpringManagedTransaction。关键类SqlSessionTemplate是SqlSession的实现也是线程安全的。它内部并不持有SqlSession而是通过SqlSessionHolder从Spring的TransactionSynchronizationManager中获取与当前事务绑定的SqlSession。SpringManagedTransactionMyBatis的事务管理器它从Spring的DataSourceUtils获取连接并将连接的生命周期交给Spring管理。Debug流程在Transactional方法内第一次调用Mapper方法时SqlSessionTemplate会发现当前线程没有绑定的SqlSession于是通过SqlSessionFactory创建一个新的SqlSession同时会创建一个SpringManagedTransaction。这个SqlSession会被绑定到当前线程通过TransactionSynchronizationManager.registerSynchronization。后续在同事务内的所有数据库操作都会获取到这个相同的SqlSession。这就是一级缓存能在Spring事务内生效的原因。当方法执行成功Spring事务管理器会触发提交最终调用SqlSession.commit()-Executor.commit()-Transaction.commit()。如果发生异常Spring会触发回滚调用SqlSession.rollback()。你可以在SqlSessionTemplate的getSqlSession方法和SpringManagedTransaction的commit/rollback方法上打断点观察整个事务同步和资源绑定的过程。理解这一点对于解决事务相关的一级缓存问题、连接泄露问题至关重要。
返回列表