ARTICLE DETAIL

资讯详情

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

MyBatis缓存机制详解:一级缓存失效与二级缓存脏读避坑指南

MyBatis缓存机制详解:一级缓存失效与二级缓存脏读避坑指南 上一篇把 MyBatis 的映射原理和日常开发套路讲完了这一篇正好聊缓存——因为你去搜 mybatis 面试题或者啃 mybatis 源码绕不开的就是一级缓存和二级缓存这两兄弟。我先把最反直觉的结论放在这里MyBatis 的缓存设计非常巧妙但它在 Spring Boot 项目里的一级缓存经常是“有开关、不生效”二级缓存又常常成为脏读事故的源头。这篇文章就是要把这两件事的来龙去脉彻底讲透顺便把缓存相关的高频面试题也一次答清楚。本篇适合已经会写 Mapper 接口、但想知道底层到底怎么跑的读者。不管你是准备面试还是要在这类多商户商城之类的项目里排查数据串问题读完都应该能独立回答缓存存在哪、缓存什么时候生效、缓存为什么会失效、什么情况下千万别开二级缓存。1. 为什么 MyBatis 要设计两层缓存先看一条 SQL 的完整代价1.1 没有缓存时一次查询到底做了什么很多新人以为“查一次数据库”就是网络发个请求、拿个结果回来其实一条 SQL 的完整链路比你想象的长得多。数据库端要做语法解析、语义分析、生成执行计划、打开表、读取数据页如果走了普通索引可能还要回表应用端也不轻松要拿到 JDBC 连接连接池分配对象、通过协议发送查询、把 ResultSet 逐行遍历并反射映射成 Java 对象。我实测过一个典型场景一个订单列表查询SQL 本身 30ms但一秒钟被用户点了十几次数据库连接和应用的 CPU 就一直在空转。这里有一个关键事实查询结果在短时间内往往是稳定的。用户反复查同一份数据每一次都重新走一遍完整链路实际上浪费了绝大部分资源。MyBatis 的缓存就是为了削减这部分重复代价而设计的——它把“SQL 对应的结果”保留在内存中下次同一条 SQL 再来时直接返回内存里的结果不再访问数据库。当然缓存不是免费的午餐。它牺牲的是业务一致性如果数据库里的数据已经变了而缓存还没更新用户看到的依然是旧数据。MyBatis 的缓存策略本质上就是在“少查一次库”和“看到新数据”之间做取舍这个理解会贯穿整篇文章。1.2 两级缓存的分工一个管会话一个管映射器MyBatis 的缓存体系分两层。第一层是一级缓存Local Cache作用范围是 SqlSession。你可以把 SqlSession 理解为一次数据库会话同一个会话里执行同一条 SQL第二次直接从会话内存取结果。它默认开启不需要任何配置。第二层是二级缓存作用范围是 Mapper 的 namespace也就是一个 Mapper XML 文件对应的所有操作共享一份缓存。它跨 SqlSession 生效第一个会话查询完把结果放进 namespace 缓存第二个会话查同一条 SQL即使它不是同一个 SqlSession也能直接命中。二级缓存默认是“可开启但实际未开启”需要在 Mapper 配置里显式声明。我早期排查问题时就犯过一个错误以为一级缓存是“请求级别”的后来才发现一级缓存严格绑定 SqlSession一旦会话关闭缓存立即没了。实际项目里 SqlSession 的生命周期极短所以一级缓存真正的表现和教科书上的描述差距很大。这个坑在第二章展开。还有一点值得先说明MyBatis 的缓存是 SQL 级别的不是业务对象级别的。它缓存的是某一条 MappedStatement 执行后的结果列表而不是你脑子里的“用户对象”“订单对象”。这个特性直接决定了哪些场景适合缓存、哪些场景一定会出问题后面我会重点讲。既然知道了两级缓存的边界我们就可以逐个击破先看一级缓存为什么容易失效再看二级缓存为什么容易出脏读。2. 一级缓存生命边界比你想的短Spring 环境下几乎“名存实亡”2.1 一级缓存到底存在哪个对象里一级缓存不是一个独立的组件它就藏在执行器里。BaseExecutor 内部维护了一个 PerpetualCache 类型的成员变量 localCachePerpetualCache 的实现非常简单本质上就是一个 HashMapput 方法存对象get 方法取对象除此之外没有过期时间、没有容量限制。所以一级缓存的“过期策略”就全靠 SqlSession 和对应的 Executor 生命周期来控制。每次你调用 SqlSession.selectList 之类的操作时底层都会经过 Executor.query()。BaseExecutor 的逻辑是根据查询参数构造一个 CacheKey先到 localCache 里查命中直接返回没有命中就去数据库查查到后放入 localCache。这意味着同一个 SqlSession 里同一句 SQL、同样的参数第二次查询不会真的发出数据库请求。我自己验证过这段逻辑你可以用原生 SqlSession 跑一下SqlSession session sqlSessionFactory.openSession(); UserMapper mapper session.getMapper(UserMapper.class); System.out.println(mapper.selectById(1).getName()); System.out.println(mapper.selectById(1).getName()); session.close();打开日志你会发现第一条输出了完整的 JDBC 查询日志第二条只有一行普通日志没有 Preparing、没有 Parameters。如果你在 application.yml 里配了mybatis.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl这个区别非常直观。这就是一级缓存发生作用的现场。2.2 CacheKey 到底由什么组成为什么“同 SQL 同参数”才命中严格来说一级缓存命中不只看“是不是同一句 SQL”而是看 CacheKey 是不是相等。我翻过源码CacheKey 的计算主要拼接了这些内容MappedStatement 的 id也就是 namespace 方法名RowBounds 的 offset 和 limit通过 BoundSql 得到的完整 SQL 文本参数值本身当前 environment 的 id。你可能已经注意到参数值也参与 CacheKey 计算。这意味着就算 SQL 一模一样只要你传的参数对象内容不同CacheKey 就不同缓存也不会命中。反过来只要 SQL 相同、参数值相同哪怕传进来的是两个不同的参数对象缓存也能命中。这个设计的用意是保证缓存语义正确参数变了结果很可能变绝不能把不同参数的结果混淆。所以面试里问到“MyBatis 一级缓存的 key 是什么”你可以直接答MappedStatement id SQL 参数 RowBounds environment 组成的 CacheKey。如果你只答“SQL 和参数”不能说错但不够完整。2.3 四个最常见的失效场景每一个都对应一段真实踩坑经历一级缓存听着简单失效条件却非常多我遇到过的生产事故基本都能归到下面四类。第一个是SqlSession 关闭或更换。原生开发里你每次 openSession 都是新会话上一个会话的缓存随之销毁。这没什么好说的缓存边界就是会话边界。第二个是执行了写操作。同一个 SqlSession 里如果你先 select 再 insert/update/deleteMyBatis 会清空一级缓存。原因是写操作会改变数据之前的只读缓存已经不可信了。这个行为由 BaseExecutor 的 update 方法触发里面会调用 clearLocalCache。注意不只是修改了同一条记录才会清是任何写操作都会把整个 localCache 清空这个“宁可错杀不可放过”的策略我一直觉得挺实在。第三个是查询参数或分页条件变化。offset 变了、limit 变了、参数值变了CacheKey 跟着变自然不命中。这个很好理解但真正容易忽略的是如果你的查询用了动态 SQLif标签传不同参数拼出来的 SQL 文本都不一样CacheKey 当然不同一级缓存也就形同虚设。第四个是Spring 托管下的会话周期变化这属于最常见的“一级缓存失效”场景单独起一节讲。2.4 Spring mybatis-spring 下的真实情况一级缓存默认几乎帮不上忙这是很多人在面试时栽跟头的地方。理论上 MyBatis 一级缓存是默认开启的但在 Spring Boot 项目里你写的 Service 按理说应该能享受一级缓存实际上大部分情况下享受不到。原因在 mybatis-spring 的 SqlSessionTemplate。它返回的 SqlSession 是一个动态代理这个代理有个特点每个方法执行完之后会自动把当前 SqlSession 关闭。SqlSession 关了Executor 和 localCache 也就没了下次请求再来又是全新的会话和全新的缓存。你调一次 Mapper 方法一级缓存生命周期就结束一次命中率自然接近零。唯一能改变这个局面的途径是事务。当一个方法被Transactional包裹时Spring 的事务管理器会把一个 SqlSession 绑定到当前事务上下文里整个事务期间复用同一个会话。这种情况下事务内多次查询同一条 SQL一级缓存才真正生效。我自己在一个报表模块里测试过把两条相同的查询放进同一个事务方法第一条有 JDBC 日志第二条没有去掉事务注解两条都有 JDBC 日志。这就是“一级缓存在 Spring 环境下名存实亡”的由来。有人问那能不能故意用Transactional来“蹭”一级缓存我的建议是别这么做。你用事务是为了保证一致性不是为了少查一次数据库事务会带来连接长时间占用、锁范围扩大等问题。真要降低查询频率往下看二级缓存或者引入外部缓存更合理。3. 二级缓存namespace 级别的双刃剑配置前必须想清楚3.1 一步一步开启二级缓存全局开关只是前提二级缓存比一级缓存复杂得多也危险得多。先破除一个误区很多人以为 MyBatis 默认就开启了二级缓存因为配置里有个cacheEnabled默认值是 true。但请注意cacheEnabledtrue只是全局开关真正启用二级缓存还需要每个 Mapper 显式声明cache/。全局开关默认打开但没有任何 Mapper 声明 cache 的话二级缓存根本不会创建。开启一个 Mapper 的二级缓存你只需要在对应 XML 里加一行mapper namespacecom.example.mapper.OrderMapper cache/ /mapper这样 MyBatis 就会为 OrderMapper 这个 namespace 创建缓存对象并把所有查询结果默认放进二级缓存。你还可以在cache标签上配置几个关键属性这些属性在面试里经常被逐个追问属性默认值作用我的建议evictionLRU缓存淘汰策略可选 LRU、FIFO、SOFT、WEAK默认 LRU 就够了flushInterval无缓存刷新周期单位毫秒不设置意味着没有时间失效只在写操作时清空size1024缓存可容纳的条目数别设太大避免内存膨胀readOnlyfalse是否只读缓存生产环境建议保持 false原因见下blockingfalse是否使用阻塞缓存防止并发击穿缓存穿透严重时可以开启type无自定义 Cache 实现类全限定名通常不需要配置完这些后还有一个硬性要求查询返回的实体类必须实现 Serializable 接口。因为二级缓存默认会把对象序列化后再存取出来时反序列化生成一个新对象。如果不实现 Serializable运行时会直接抛 NotSerializableException。这一点在我见过的报错里出现频率极高排查方向却总被人忽视。3.2 从 PerpetualCache 到装饰器链读懂 Cache 接口的扩展模式要理解二级缓存的本质你得知道它内部其实是一堆 Cache 对象层层包裹的结构。MyBatis 有一个 Cache 接口默认实现是 PerpetualCache一种简单的 HashMap 缓存其他各种功能全是通过装饰器模式叠加的。比如你配置readOnlytrue它会在基础缓存外面包上 SerializedCache返回对象时先序列化到字节数组、再反序列化回来保证每次调用方拿到的都是独立副本避免并发修改互相干扰。你配置evictionLRU它会包上 LruCache维护一个 LRU 淘汰队列。你配置blockingtrue它会包上 BlockingCache在缓存未命中时对 key 加锁避免大量请求同时穿透到数据库。我整理了一张常用的装饰器对照表面试时能说出来相当加分装饰器类作用LruCache按最近最少使用策略淘汰缓存项FifoCache先进先出淘汰SoftCache / WeakCache基于软引用/弱引用内存不足时自动回收SerializedCache序列化后缓存返回对象的副本LoggingCache输出缓存命中率日志调试很有用SynchronizedCache给缓存操作加锁线程安全BlockingCache缓存未命中时阻塞后续线程防止击穿ScheduledCache支持周期性清空缓存我在排查一个老项目时就用 LoggingCache 的命中率日志定位过问题。MyBatis 会在日志里打印 Cache Hit Ratio如果你发现命中率一直是个位数缓存基本没起作用就别忙着调 size先检查 CacheKey 是不是被参数动态拼接搞得不稳定了。理解装饰器链还有一个实际收益你能判断“缓存的对象是不是同一个引用”。默认 readOnlyfalse 时每次读取都会反序列化出新的对象理论上更安全readOnlytrue 时直接返回缓存的同一个对象引用多个线程同时拿到这个对象并修改属性会发生不可预知的并发问题。这个细节我在生产环境里踩过所以强烈建议你不要为了“快”去开 readOnlytrue。3.3 不完全缓存的一致性讨论为什么说二级缓存是脏读事故高发区二级缓存最让我警惕的地方是它的失效很“局部”。MyBatis 的缓存清理是以 namespace 为单位进行的当你执行 OrderMapper 里的 insert/update/delete 时只会清 OrderMapper 这个 namespace 的二级缓存。听起来挺合理但实际业务里 SQL 往往是跨表查询的。典型场景OrderMapper 里有一条select o.*, u.nickname from order o left join user u结果被二级缓存缓存了。然后 UserMapper 里改了用户昵称它的写操作只清了 UserMapper namespace 的缓存OrderMapper 里那条 join 查询的缓存依然是旧昵称。于是用户改了昵称订单列表里还是老名字这种脏读一旦发生非常难排查因为单看 OrderMapper 的代码和数据都是“对的”。多商户跨境商城这类项目更容易踩坑。很多基于 mybatis 的多商户系统会引入租户插件后台拦截 SQL、根据当前租户动态拼接 tenant_id 条件。如果这个租户条件是通过拦截器加的而不是用户查询参数里显式传的那 CacheKey 里就根本不含租户维度。此时商户 A 查过的数据商户 B 再来查同一条 SQL极可能直接命中商户 A 的缓存结果这就是我真实遇到过的跨租户数据串用事故。我需要强调一个容易误判的点如果你的租户 ID 本来就作为查询参数传进来比如selectById(id, tenantId)那么 tenantId 会参与 CacheKey 计算不同租户用到的是各自的缓存不会串数据但代价是缓存条目变多、命中率下降。真正的风险藏在那些“参数里看不到租户上下文”的实现方式里。基于这些经历我对二级缓存的态度始终保守只适合那种“单点写入、全量读取、基本不做跨表 join”的表比如字典表、省份表、系统配置表。业务表尤其是带 join、带复杂的权限上下文、带租户拼接条件的查询开二级缓存前一定要反复权衡。分布式环境里我更推荐直接用 Redis 或 Caffeine 做应用层缓存可控性高得多。4. 源码视角一次查询是怎么在缓存里穿行的4.1 Executor 的组装CachingExecutor 如何包住 BaseExecutor前面讲的都是现象和配置这节我们从源码层面走一遍完整流程把一级缓存和二级缓存串成一个整体。你在 Spring 里注入的 Mapper 接口最终调用的是 MapperProxy 的 invoke 方法它把方法调用转成 SqlSession 的 select/update 操作。DefaultSqlSession 拿到 statement 后会从 Configuration 里取出对应的 Executor然后调用 executor.query()。这个 Executor 不是直接暴露的简单执行器。在 Configuration.newExecutor() 里有一段逻辑如果全局 cacheEnabled 为 true且当前 Mapper 有配置缓存就会用 CachingExecutor 把 BaseExecutor 包一层形成“二级缓存在外、一级缓存在内”的结构。CachingExecutor 是一个装饰器它的 query 方法先尝试二级缓存如果二级缓存没有命中再调用被包裹的 BaseExecutor.query()由后者访问一级缓存一级缓存也没有才真正访问数据库。所以一次查询的完整路径就是二级缓存 - 一级缓存 - 数据库 - 写入一级缓存 - 写入二级缓存事务提交时。这个顺序我面试时经常反着考别人很多人只记得“先查二级再查一级”却说不清二级缓存什么时候写入。4.2 TransactionalCacheManager 与提交时机为什么别人总看不到我的新数据二级缓存比一级缓存多了一个很关键的设计它要等事务提交后才真正生效。CachingExecutor 内部维护了一个 TransactionalCacheManager它管理着多个 TransactionalCache。查询时如果命中二级缓存直接从缓存返回没命中就读数据库把结果放进一个临时 TransactionalCache 里。等事务提交时TransactionalCache 才把临时数据真正刷入底层的 PerpetualCache如果事务回滚这些临时数据直接丢弃。这个设计的价值很实际如果查询结果在事务未提交时就被其他会话看到你可能会读到别人事务里尚未提交的脏数据。延迟写入能保证缓存里的数据都是已提交数据。但这个特性也会造成一个现象同一个事务内部第二次查同一条 SQL 可能查不到刚放进二级缓存的数据而是仍走一级缓存。因为二级缓存的数据要等 commit 才可见事务内自己都看不见自己刚提交前的缓存数据。我遇到过有人写了单元测试事务没提交就直接验证二级缓存命中结果怎么都不对排查了半天才反应过来是提交时机的问题。4.3 缓存失效的源码级确认写操作到底清了哪一层看完了命中再看失效。写操作的清理链路是两层分别处理的。BaseExecutor.update() 在执行任何 insert/update/delete 之前会调用 clearLocalCache() 清空一级缓存。这一步保证当前会话内后续查询看不到旧结果。CachingExecutor 层面则会在执行写操作前检查 MappedStatement 的 flushCache 属性如果为 true就调用cache.clear()把这个 namespace 下的二级缓存整个清空。默认情况下Mapper 里的 insert/update/delete 语句的 flushCache 默认就是 true所以只要有写操作当前 namespace 的二级缓存就会被清掉。这也是为什么读多写少的表开二级缓存收益大——一旦写操作频繁缓存刚建好就被清空命中率自然很难看。另外还有一处细节值得注意查询语句也可以设置 flushCachetrue告诉 MyBatis“这条查询执行前先清空缓存”。这个属性我在做强制刷新场景时用过用户点了刷新按钮前端请求一个设置了 flushCache 的查询后台先把旧缓存清了再查库保证拿到最新数据。算是比较冷门但实用的手段。5. 面试题与实战方案缓存部分的标准答法5.1 高频问题速答表结合这些年面试别人和被别人面过的经验我整理了缓存这块出现频率最高的问题可以直接作为复习提纲问题标准答法要点一级缓存和二级缓存有什么区别一级缓存是 SqlSession 级别默认开启存在 BaseExecutor.localCache 中二级缓存是 namespace 级别跨会话共享需要显式配置为什么 Spring 环境下的一级缓存几乎不生效SqlSessionTemplate 的代理每次执行完自动关闭 SqlSession只有 Transactional 事务内才复用同一个会话二级缓存的 key 是什么由 MappedStatement id、SQL、参数、RowBounds、环境信息等共同构成的 CacheKey什么情况会导致二级缓存脏读跨表 join 查询的缓存不会因其他 namespace 写操作而清空多租户拼接条件不体现在参数中时CacheKey 无租户维度readOnlytrue 和 false 有什么差别true 直接返回缓存对象引用速度快但有并发修改风险false 走序列化返回副本安全但性能略低blocking 是干什么的未命中时对 key 加锁防止大量并发请求同时穿透到数据库为什么不建议在业务表上开二级缓存失效粒度粗、join 和租户场景易脏读、分布式环境扩展性差二级缓存什么时候写入查询结果先放到事务缓存中事务提交时才真正写入二级缓存5.2 我的建议什么项目适合开启二级缓存聊完理论说点落地的判断标准。我给团队定的规矩很明确满足下面全部条件的表才能考虑开二级缓存。第一读多写少写入频率极低。比如省市字典、国家代码、系统配置。你一天写一次、一秒钟读几千次缓存收益才足够大。第二查询语句基本不涉及动态 SQLSQL 稳定参数类型也稳定保证 CacheKey 规律。第三没有跨 namespace 的 join因为 join 结果的一致性没法靠单 namespace 缓存保证。第四实体类能实现 Serializable并且你能接受返回对象是反序列化副本。第五项目还是单体应用、节点数不多。如果是分布式集群不同节点的缓存各自为政数据一致性更不可控我建议直接不再考虑 MyBatis 二级缓存改用 Redis 做统一缓存。多商户跨境商城这类项目的核心诉求是数据隔离而二级缓存恰恰很难感知租户上下文我不建议在这些项目里开二级缓存除非你能确保租户 ID 一定作为参数参与查询。如果确实需要缓存可以考虑在 Service 层自己加 Caffeine 或 Redis按租户维度显式拼缓存 key至少出了问题你能自己掌控失效时机。还有一个小技巧排查缓存问题时把logging.level.你的Mapper包名debug打开配合 LoggingCache 的命中率日志可以清楚看到哪些查询命中缓存、哪些穿透到数据库。很多时候你以为缓存没生效其实是 project 里根本没创建 namespace 的 Cache或 CacheKey 一直在变日志一开基本现原形。写在你开始配置之前最后说一句实践经验。我第一次在生产环境开二级缓存是在一个报表模块里当时单机测试命中率 90% 以上感觉非常理想。上线之后才发现报表查询带了一堆动态条件CacheKey 几乎每一次都不同命中率跌到 20%二级缓存反而占了不少内存。后来我彻底关掉它改用查询结果在 Service 层做短周期缓存效果反而更稳。另一个印象深刻的教训是多商户场景。当时接到一个工单反馈商户 B 能看到商户 A 的订单数据。查了很久发现就是二级缓存把租户上下文丢了参数里根本没有商户维度。那次之后我给自己定了一条原则任何涉及数据隔离的系统默认禁用二级缓存除非你能从代码上证明缓存 key 覆盖了所有隔离维度。这篇的内容到这里就完整了。你如果正在准备面试重点把第二章的失效场景和第四章的执行顺序拿下如果是在实际项目中做方案请务必先回答一个问题数据变了缓存怎么知道答案不确定就不要在业务表上开二级缓存。
返回列表