ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MyBatis 缓存双刃剑:一级缓存作用域、二级缓存失效与跨会话脏读生产避坑

MyBatis 缓存双刃剑:一级缓存作用域、二级缓存失效与跨会话脏读生产避坑 MyBatis 缓存双刃剑一级缓存作用域、二级缓存失效与跨会话脏读生产避坑1. 先看一个真实困惑改了数据库接口为什么还返回旧值假设现在有一个订单查询接口逻辑很简单根据订单号查订单返回给前端。压测时发现一个奇怪现象运营在后台把订单状态从“待支付”改成了“已取消”但用户刷新页面看到的仍然是“待支付”。重启应用之后又正常了。排查过程通常是这样的先确认数据库里确实已经改了再确认接口没有加本地缓存然后怀疑是不是 MyBatis 缓存搞的鬼。答案往往就是——是。MyBatis 自带两级缓存本意是减少数据库往返。但缓存本质上是“把一份数据复制到离调用方更近的地方”复制就意味着存在多个副本副本之间就可能不一致。MyBatis 的缓存之所以容易出问题不是因为它设计得差而是因为它把“作用域”这件事藏在了 SqlSession 和命名空间这两个概念里而这两个概念在日常写 Mapper 接口时几乎不会被直接感知。本文要解决的就是让读者能明确回答三个问题这份缓存在哪个范围里生效它在什么时刻被清掉在什么条件下会导致另一个会话读到过期数据把这三个问题答清楚绝大多数缓存相关的线上问题都能定位。2. 一句话模型与全局流转图先记住一个最小模型MyBatis 的缓存是按“会话”和“命名空间”两级来切分的一级缓存跟着 SqlSession 走二级缓存跟着 Mapper 的命名空间走。所谓 SqlSession可以先把它理解成“一次数据库会话的上下文”它持有一个数据库连接或从连接池借来的连接也持有这一轮操作产生的缓存。所谓命名空间就是 Mapper 接口或 XML 文件的全限定名比如com.example.mapper.OrderMapper二级缓存挂在这个名字下面被所有使用同一命名空间的 SqlSession 共享。把整体拆成三部分来看执行入口Mapper 接口方法被调用MyBatis 通过动态代理生成实现类最终落到SqlSession的selectList、selectOne等方法上。查询处理链Executor负责调度缓存与数据库访问查询先看二级缓存再看一级缓存都没有才查库。缓存存储一级缓存是PerpetualCache放在BaseExecutor的localCache字段里二级缓存由TransactionalCacheManager管理最终写入配置的 Cache 实现。一次查询的流转顺序可以用下面这张图概括Mapper 接口方法调用 | v MapperProxy 动态代理 | v SqlSession.selectList(...) | v Executor.query(...) | -- 1. 先查二级缓存 (CachingExecutor) | 命中则直接返回不再往下走 | -- 2. 再查一级缓存 (BaseExecutor.localCache) | 命中则直接返回不再往下走 | -- 3. 查数据库 | v 结果写入一级缓存 | v 事务提交时写入二级缓存这张图里有两个关键点后文会反复回到它们身上。第一二级缓存排在一级缓存前面也就是说如果二级缓存命中了一级缓存根本不会被访问。第二查库的结果是先放进一级缓存事务提交的时候才通过TransactionalCacheManager刷进二级缓存。这两点决定了后文所有的不一致场景。3. 一级缓存作用域是 SqlSession不是方法也不是线程3.1 作用域到底是什么一级缓存默认开启且无法通过简单配置关闭只能通过设置localCacheScope改变行为。它的生命周期完全绑定在一个SqlSession上SqlSession 创建时缓存创建SqlSession 关闭时缓存销毁。在 Spring 集成环境下这一点最容易被误解。因为 Spring 会把 SqlSession 的生命周期和事务绑定所以在没有事务的情况下每次 Mapper 调用都会创建并关闭一个 SqlSession一级缓存实际上“用完即弃”两次调用之间不会共享。在同一个Transactional方法内整个方法共享同一个 SqlSession因此同一个查询执行两次第二次不会发 SQL。这解释了很多人的困惑为什么在 Service 里循环调用同一个 Mapper 方法有的场景只发一条 SQL有的场景发了很多条。差别往往就在于循环体内是否处于同一个事务。3.2 最小可运行示例验证一级缓存命中下面这个示例的目标是在同一个 SqlSession 中连续执行两次完全相同的查询通过日志观察 SQL 只发送一次。前置环境JDK 8 及以上、Maven、MySQL 5.7 及以上MySQL 中有一张orders表和若干测试数据。准备表与数据CREATETABLEorders(idBIGINTPRIMARYKEY,order_noVARCHAR(64)NOTNULL,statusVARCHAR(16)NOTNULL,amountDECIMAL(10,2)NOTNULL);INSERTINTOorders(id,order_no,status,amount)VALUES(1,NO-1001,WAIT_PAY,99.00),(2,NO-1002,CANCELED,199.00);Java 示例使用原生的 SqlSession 而不用 Spring便于观察生命周期importorg.apache.ibatis.session.SqlSession;importorg.apache.ibatis.session.SqlSessionFactory;importorg.apache.ibatis.session.SqlSessionFactoryBuilder;importjava.io.InputStream;importjava.util.List;publicclassLocalCacheDemo{publicstaticvoidmain(String[]args)throwsException{InputStreaminLocalCacheDemo.class.getResourceAsStream(/mybatis-config.xml);SqlSessionFactoryfactorynewSqlSessionFactoryBuilder().build(in);SqlSessionsessionfactory.openSession();try{OrderMappermappersession.getMapper(OrderMapper.class);ListOrderfirstmapper.selectByStatus(WAIT_PAY);System.out.println(first size first.size());ListOrdersecondmapper.selectByStatus(WAIT_PAY);System.out.println(second size second.size());System.out.println(same object? (firstsecond));}finally{session.close();}}}对应的 Mapper 接口与 XML 片段简化代码只展示关键部分mappernamespacecom.example.mapper.OrderMapperselectidselectByStatusresultTypecom.example.entity.OrderSELECT id, order_no AS orderNo, status, amount FROM orders WHERE status #{status}/select/mapper关键步骤与预期结果关闭日志时看不到 SQL打开 MyBatis 的 debug 日志后会看到selectByStatus只输出了一条Preparing与Parameters。两次调用返回的集合内容相同说明第二次结果来自缓存。容易改错的地方如果在两次调用之间执行了session.clearCache()或者调用了session.commit()那么第二次会重新查询。这里还要纠正一个常见误解一级缓存缓存的是查询结果对象本身不是重新组装的新对象。也就是说两次查询返回的可能就是同一批 Java 对象引用。这一点在下面的脏读场景里非常关键。3.3 什么条件下缓存会被清掉一级缓存不是永久有效的以下几种情况都会导致它被清理SqlSession 调用clearCache()。执行了任意update、insert、delete语句。哪怕更新的不是被缓存的那张表也会清空整个一级缓存。事务被提交或回滚时MyBatis 会调用flushCacheIfRequired在大多数场景下清空本地缓存。这里最容易误解的是“更新别的表为什么影响我的查询”。原因是 MyBatis 用的是“粗粒度清理”它不跟踪表级依赖关系只知道“这次会话发生过写操作之前读的结果可能不再可靠”于是整体清空。这是典型的用简单性换精确性代价是命中率下降但换来了更低的出错概率。4. CacheKey凭什么判断“这两次查询是同一个查询”缓存能命中前提是 MyBatis 能判断出“这次查询和上次查询一样”。这个判断不靠 SQL 字符串简单比较而是靠CacheKey这个对象。一个 CacheKey 由多个因素参与计算常见的有参与因素说明为什么必须参与statementId即命名空间加语句 id不同 Mapper 方法可能生成相同 SQL查询 SQL 文本去掉动态 SQL 变化后的最终 SQLSQL 不同结果必然不同参数值每个占位符对应的实际值参数不同结果集不同分页参数RowBounds 的 offset 与 limit同一 SQL 不同页结果是不同数据环境/数据源标识在部分场景下参与防止多数据源串用缓存CacheKey 实现了equals和hashCode只有当所有参与因素都相等时才算命中。这个设计本身没有问题问题在于它只比较“查询语句层面”的条件不比较数据库的真实状态。如果数据库数据变了但查询语句和参数都没变CacheKey 依然相等缓存依然命中。4.1 一个容易踩的坑动态 SQL 导致 CacheKey 变化考虑一个常见写法根据传入条件拼 SQL。如果传入的status为 nullif teststatus ! null不拼接最终 SQL 与上次不同CacheKey 不同缓存不命中——这是符合预期的。但反过来如果两个不同的业务参数恰好拼出了完全一样的最终 SQL那么它们就会共享同一个缓存条目。例如selectByStatus(WAIT_PAY)和另一个业务方法内部也生成了WHERE status WAIT_PAY的 SQL如果 statementId 相同就会命中同一份缓存。这种“不同业务入参相同最终 SQL”的场景是缓存串味的隐性来源。结论很直接不要把语义不同的查询共用同一个 statementId也不要依赖参数组合去“恰好”复用缓存。需要复用就显式设计需要隔离就明确拆开。5. 二级缓存命名空间维度失效时机藏在事务里5.1 二级缓存怎么开、存在哪里二级缓存默认关闭需要在 Mapper XML 里加cache/或者用CacheNamespace注解。它挂在命名空间上因此同一个 Mapper 的所有 SqlSession 共享这份缓存。它的存储位置由cache的type属性决定默认是PerpetualCache本质是一个 Map。这意味着默认情况下缓存放在单个 JVM 的堆内存里。跨 JVM 的两个服务实例之间不会共享跨进程更不会。这一点必须先说清楚因为很多“二级缓存能解决分布式一致性”的想法从一开始就不成立。二级缓存的写入时机非常特殊查询结果先写入TransactionalCacheManager的事务缓存区而不是立刻写进真正的缓存。只有在事务提交时才把事务缓存区里的内容刷进真正的 Cache。如果事务回滚这些内容会被丢弃。查询命中数据库 | v 结果放入 TransactionalCache.entriesToAddOnCommit | v 事务提交 commit() | v flushPendingEntries: 写入真正的 Cache这个设计的目的是防止“事务未提交的数据进入共享缓存被其他会话读到”。但从下面可以看出它并没有完全杜绝脏读问题。5.2 二级缓存的失效时机二级缓存默认在“同一命名空间内发生写操作”时被整体清空。也正因为它挂在命名空间上一个命名空间内的任何写操作都会清掉该命名空间的全部二级缓存。触发动作一级缓存二级缓存同一 SqlSession 内再次相同查询命中先检查可能命中任意写操作立即整体清空提交后清空对应命名空间SqlSession 关闭销毁不影响事务回滚清空事务缓存区丢弃跨命名空间写操作清空默认不感知表中最后一行是重灾区如果两个 Mapper 操作同一张表其中一个 Mapper 的写操作默认不会清掉另一个 Mapper 的二级缓存因为它们属于不同命名空间。这是“脏读”的一个典型来源。5.3 跨会话脏读是怎么发生的把场景写具体一些。假设有两个请求同时进来请求 A会话 A执行一个查询开启事务读到订单状态为“待支付”结果暂存于事务缓存区。请求 B会话 B在另一个事务里把订单状态更新为“已取消”并提交。请求 A 的事务此时才提交它把之前读到的“待支付”结果刷进二级缓存。结果是二级缓存里存的是“待支付”而数据库里已经是“已取消”。后续任何命中这份缓存的查询都会拿到过期数据直到该命名空间再次发生写操作把缓存清掉。问题的根源在于二级缓存的事务缓存区只在提交时刷入但它没有校验“在我读之后数据是否已经被别人改过”。这就是“跨会话脏读”的成因——它不需要数据库隔离级别出问题而是缓存自己制造了一种“时间倒流”。6. 完整示例二复现二级缓存的跨会话脏读这个示例的目标是让读者亲手复现上面描述的时序而不是停留在概念上。前置条件MySQL 中已存在orders表MyBatis 配置文件里开启日志Mapper 的 XML 中加上cache/。关键配置片段mappernamespacecom.example.mapper.OrderMappercacheevictionLRUflushInterval60000size512readOnlytrue/selectidselectByIdresultTypecom.example.entity.OrderSELECT id, order_no AS orderNo, status, amount FROM orders WHERE id #{id}/selectupdateidupdateStatusUPDATE orders SET status #{status} WHERE id #{id}/update/mapper复现代码importorg.apache.ibatis.session.SqlSession;publicclassSecondLevelStaleReadDemo{publicstaticvoidmain(String[]args)throwsException{SqlSessionFactoryfactorySqlSessionFactoryHolder.get();SqlSessions1factory.openSession();OrderMapperm1s1.getMapper(OrderMapper.class);Orderbeforem1.selectById(1L);System.out.println(会话1读取:before.getStatus());SqlSessions2factory.openSession();OrderMapperm2s2.getMapper(OrderMapper.class);m2.updateStatus(1L,CANCELED);s2.commit();s2.close();System.out.println(会话2已提交更新);s1.commit();System.out.println(会话1提交其读取结果被刷入二级缓存);s1.close();SqlSessions3factory.openSession();OrderMapperm3s3.getMapper(OrderMapper.class);Orderafterm3.selectById(1L);System.out.println(会话3读取:after.getStatus());s3.close();}}关键步骤与可观察结果会话 1 先读拿到“WAIT_PAY”。因为事务还没提交这份结果没有立刻进二级缓存。会话 2 更新并提交它自身的写操作会清空对应命名空间的二级缓存但此时二级缓存里本来就没有会话 1 的数据所以这次清空是“空清”。会话 1 提交它把之前读到的“WAIT_PAY”刷进二级缓存。此时数据库里的真实值是“CANCELED”。会话 3 打开后查询二级缓存命中拿到“WAIT_PAY”。预期输出中会话 3 读到的是旧值。若把会话 1 改成先提交再读输出就会是新值。易改错的地方如果不给cache/配置readOnlytrue返回的是序列化副本读者可能误以为“对象不同就不算脏读”但业务值依然是旧的。另外如果用 Spring 的Transactional会话 1 对应的事务边界更难观察建议先用原生 SqlSession 做实验。7. 完整示例三生产可用的缓存边界设计前两个示例证明了机制这一个示例解决“那到底该怎么办”。目标是在一个读多写少、按订单号查询的场景里做出可控的缓存策略。策略选择不用 MyBatis 二级缓存改为在 Service 层使用显式的、带过期时间的缓存如 Caffeine 或 Redis并由写操作主动失效。保留一级缓存默认行为因为它在单个事务内不会引入跨会话问题。接口与实现简化代码展示关键逻辑publicinterfaceOrderQueryService{OrdergetByOrderNo(StringorderNo);voidupdateStatus(StringorderNo,Stringstatus);}publicclassOrderQueryServiceImplimplementsOrderQueryService{privatefinalOrderMapperorderMapper;privatefinalCacheString,Ordercache;publicOrderQueryServiceImpl(OrderMapperorderMapper,CacheString,Ordercache){this.orderMapperorderMapper;this.cachecache;}OverridepublicOrdergetByOrderNo(StringorderNo){Ordercachedcache.getIfPresent(orderNo);if(cached!null){returncached;}OrderorderorderMapper.selectByOrderNo(orderNo);if(order!null){cache.put(orderNo,order);}returnorder;}OverridepublicvoidupdateStatus(StringorderNo,Stringstatus){orderMapper.updateStatusByOrderNo(orderNo,status);cache.invalidate(orderNo);}}关键步骤与预期结果读路径先查显式缓存未命中才走数据库。缓存 key 直接用业务主键orderNo语义清晰不会出现 CacheKey 层面的串味。写路径先写库再失效缓存保证写后可读到最新值读路径下一次会回源。容易改错的地方先失效缓存再写库会在两个操作之间留下一个“缓存已空但库未更新”的窗口另一个请求可能把旧值再填回缓存。正确顺序是先写库、后失效。这个示例也顺带说明了为什么很多团队最终放弃二级缓存不是它完全不能用而是它的失效时机依赖事务边界而事务边界在很多业务代码里并不直观。8. 设计取舍什么时候用什么时候坚决不用把结论写成条件式判断当你的数据是只读或极少变更的字典、配置类数据并且查询集中在同一个命名空间二级缓存可以降低数据库压力。当你的数据是会被多张 Mapper 交叉读写的业务表不建议使用二级缓存因为跨命名空间的失效不会自动发生。当你的应用是多实例部署默认的堆内二级缓存只能各自维护一份一致性完全无法保证即使换成 Redis 实现也需要自己处理失效广播。当你需要精确的失效控制优先在 Service 层使用显式缓存把 key 和失效逻辑握在自己手里。当你需要单个事务内的重复查询去重一级缓存已经足够不需要额外引入任何东西。这里还有一个常见误解值得单独指出很多人认为“配置了flushCachetrue的查询就不会走缓存”。实际上写操作本身就会清缓存把查询也标成 flushCache 通常是过度操作会白白牺牲命中率。方案一致性性能收益适用场景一级缓存默认会话内一致事务内去重所有场景不用额外配置二级缓存默认实现较弱事务提交才生效同命名空间跨会话受益只读数据、单实例二级缓存 Redis 实现依赖失效广播多实例可共享有专门缓存治理能力的团队Service 层显式缓存由业务控制灵活大多数业务读多写少场景9. MyBatis-Plus 场景下的注意点MyBatis-Plus 在 MyBatis 之上做了封装但它没有改变缓存的作用域模型。使用BaseMapper时statementId 是自动生成的比如selectById、selectList。这意味着只要条件构造器Wrapper生成的最终 SQL、参数、statementId 相同CacheKey 就可能相同一级缓存依然会命中。逻辑删除、自动填充、乐观锁都会在 SQL 层面追加条件。这些条件参与了最终 SQL 的生成因此也会进入 CacheKey 的计算不会导致缓存误命中但会让 SQL 文本变化从而降低命中率。多租户插件会在 SQL 中追加租户条件。如果租户字段是从上下文动态取的那么不同租户生成的 SQL 不同CacheKey 不同缓存不会串。但一旦你在缓存之外的地方复用了结果对象就要小心对象共享带来的隐式污染。用 Wrapper 构建查询的示例简化代码LambdaQueryWrapperOrderwrappernewLambdaQueryWrapper();wrapper.eq(Order::getStatus,WAIT_PAY);ListOrderlistorderMapper.selectList(wrapper);这段代码对应的最终 SQL 与参数都会参与 CacheKey。工程上建议大量重复查询尽量走稳定的语句避免每次拼接出不同 SQL 文本从而保证缓存命中的可预期性。10. 常见误区与排障清单10.1 常见误区误区一二级缓存是分布式的。默认实现是 JVM 堆内 Map多实例不共享。误区二关了二级缓存就万事大吉。一级缓存依然存在如果在一个长事务里读旧数据仍可能出问题。误区三更新后立刻查到新值就等于缓存没问题。同一会话内因为写操作清空了一级缓存看起来正常换个会话再看才可能暴露脏读。误区四clearCache()能解决所有问题。它只能清当前会话跨会话的二级缓存该有问题还是有。10.2 排障清单遇到“查到旧数据”的问题按以下顺序排查确认数据在数据库中的真实值排除业务写路径本身没落库。观察同一 SqlSession 内是否重复查询却只发一条 SQL判断是否命中一级缓存。检查是否配置了二级缓存cache/是否存在。检查写操作是否发生在同一个命名空间跨命名空间写不会清缓存。检查事务边界读取和提交是否跨越了其他会话的写操作这是脏读的高发区。用日志确认二级缓存的读写与刷新时机必要时临时关闭二级缓存在生产灰度验证。看到旧值 | v 库里的值对吗? --否-- 写路径问题 | 是 v 同一事务内重复查询吗? --是-- 一级缓存命中属预期 | 否 v 开了二级缓存吗? --否-- 检查是否其他地方缓存了对象 | 是 v 写操作在同一命名空间吗? --否-- 跨命名空间未失效属已知缺陷 | 是 v 读取事务是否跨越了别人的写提交? --是-- 跨会话脏读需改缓存策略11. 面试/复盘问题一级缓存的作用域是什么Spring 事务存在与否会带来什么差异CacheKey 由哪些因素决定为什么参数相同但最终 SQL 不同会导致缓存不命中二级缓存为什么要在事务提交时才刷入这样做解决了什么问题又遗留了什么问题跨命名空间的写操作为什么默认不会清对方缓存这是设计缺陷还是刻意的取舍如果让你在一个多实例、读多写少的系统里设计缓存你会把缓存放哪一层为什么12. 总结把全文收回成一张框架图核心就是三句话一级缓存跟着 SqlSession 走事务内有效、会话内复用销毁即消失。二级缓存跟着命名空间走事务提交才刷入跨会话共享但跨命名空间不失效。CacheKey 只比较查询条件不比较数据库状态所以缓存的一致性必须由业务自己负责。再给一个决策清单如果你的数据几乎不变可以谨慎使用二级缓存如果数据会被多个 Mapper 交叉写请关闭二级缓存并在 Service 层做显式缓存如果只是想在事务内去重一级缓存已经够用无需额外配置。最后强调一句缓存不是“加得越多越快”而是“每多一份副本就多一处需要治理的一致性问题”。理解 MyBatis 这两级缓存的作用域和失效时机本质上是在理解“副本在哪里、什么时候被替换”。13. 参考资料MyBatis 官方文档https://mybatis.org/mybatis-3/MyBatis 官方文档缓存章节https://mybatis.org/mybatis-3/sqlmap-xml.htmlMyBatis-Plus 官方文档https://baomidou.com/MySQL 官方文档事务隔离级别https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html《MyBatis 技术内幕》作者刘增辉
返回列表