news 2026/10/3 10:05:46

MyBatis缓存机制从源码到实战:一级二级缓存失效问题排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis缓存机制从源码到实战:一级二级缓存失效问题排查

1. 从“数据库改了却查出旧数据”说起

两三年没碰MyBatis源码的开发者,多半会把缓存机制背成两句面试口诀:一级缓存是SqlSession级别的,二级缓存是namespace级别的。可真到了生产环境,这两句口诀往往不够用——很多问题恰恰是“知道它有缓存,但不清楚缓存是怎么存的、什么时候失效、什么时候根本没走缓存”造成的。

我先举一个身边真实的例子。业务同事反馈:某个配置表在后台改了一条记录的status字段,然后立刻在另一个页面查列表,看到的依然是旧状态,刷新好几次都这样,过几分钟才正常。当时的直觉是前端有缓存,但清掉CDN、强刷浏览器还是老样子。后来查数据库日志,发现update确实已经提交;查应用日志,发现查询SQL也真的执行了。那问题只能出现在MyBatis这一层——数据从数据库读出来之后,被某个缓存层截胡了。

顺着线索排查下去,果然在一段循环查询的代码里,同一个SqlSession连续执行了两遍同一条查询语句,第二遍的结果没有重建,而是直接复用了第一遍的数据。这就是MyBatis一级缓存最典型的表现。

要彻底搞清楚这类问题,自然不能停留在“一级缓存是会话级”这种结论上。缓存怎么命中、什么条件清空、二级缓存又会在什么场景下坑人,这些问题,本文从源码角度和实际排查经验两个维度一起拆一遍。适合正在用MyBatis做业务开发、遇到缓存数据异常但不知道从哪入手的人,也适合准备面试、想把手写级答案升级为源码级答案的求职者。

2. 一级缓存源码真相:BaseExecutor里的localCache

二级缓存涉及CachingExecutor、装饰器、缓存实现类等一大堆东西,下一节专门讲。先从最核心的一级缓存说起,因为它的存在感最强,也最能解释前面那个“旧数据”问题。

2.1 一次查询的完整调用链

Mapper接口的方法调用,最终会进到SqlSession的selectList,再交给Executor执行。如果没有配置额外的东西,默认Executor是BaseExecutor的子类,比如SimpleExecutor或BatchExecutor。BaseExecutor源码里有一个非常关键的成员变量:

protected PerpetualCache localCache;

PerpetualCache是最基础的一个Cache实现,内部就是一个HashMap:

private final Map<Object, Object> cache = new HashMap<>();

每次查询都会进入query方法统一入口,核心逻辑是这样的:

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) { if (queryStack == 0) { list = localCache.getObject(key); if (list == null) { list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); } } }

注意queryStack这个字段。它表面上是“查询栈”,实际是用来处理嵌套查询(比如association里的select)的,防止递归调用时误判缓存。只有queryStack为0时,也就是最外层查询,才会尝试从一级缓存拿数据。

2.2 缓存Key到底由哪些东西拼出来

一级缓存的Key不是简单的SQL字符串,而是CacheKey对象。我在认真看这块源码时觉得设计很讲究,因为参数拼进去之后,同一个Mapper方法传不同参数会生成不同Key;参数相同但分页offset不同,Key也不同,不会互相污染。

CacheKey cacheKey = new CacheKey(); cacheKey.update(ms.getId()); cacheKey.update(rowBounds.getOffset()); cacheKey.update(rowBounds.getLimit()); cacheKey.update(boundSql.getSql()); for (Object value : parameterValues) { cacheKey.update(value); }

这里有个容易忽略的细节:参数列表里的每个参数值都会被update进去。如果参数是对象,实际上调的是toString。意味着:

  • 对象没重写toString,两个业务上等价的对象会生成不同Key,命中率偏低。
  • 对象重写toString时把无关字段拼进去了,也可能导致本来相同语义的数据Key对不上。

这个点经常被忽略。实际调优缓存命中率时,第一件事就该打开日志看CacheKey差异,而不是瞎调LRU大小。

2.3 一级缓存什么时候会被清空

清空一级缓存的条件主要有两个:一是执行了update类操作,二是配置了statement级别的缓存范围,每次查询结束立即清空。

BaseExecutor里update方法的开头就有一句:

public int update(MappedStatement ms, Object parameter) throws SQLException { clearLocalCache(); return doUpdate(ms, parameter); }

也就是说,同一个SqlSession里,先update再select,select不会命中旧缓存。这个设计保证了在同一会话内的基本一致性。

这里引出一个核心实践问题:Spring整合MyBatis后,每次Mapper方法调用是不是同一个SqlSession?

答案是否定的。默认情况下,SqlSessionTemplate每次执行完Mapper方法都会关闭SqlSession,下次调用再开新的。所谓“一级缓存命中”,更多发生在两种场景:同一个事务内多次查询,或者Service方法里共享同一个SqlSession。很多人非事务地测一级缓存,发现怎么都不命中,就是因为SqlSession已经不是同一个了。

2.4 localCacheScope=STATEMENT的实用价值

MyBatis的配置项localCacheScope有两个取值:SESSION(默认)和STATEMENT。

两者差别很简单,STATEMENT级别意味着每次查询结束后立即清空localCache,当前SqlSession内连续执行相同查询也会重新走数据库。

什么时候需要STATEMENT?我遇到过的场景是那种实时性要求比较高的数据查询,比如查询当前库存、查询余额,业务上不允许在一个事务里复用旧值。不想拆事务,也不想在业务代码里强制刷新缓存,直接把整个SqlSession范围改成STATEMENT,简单粗暴。

不过必须提醒:这个配置是全局的,会影响所有Mapper。如果只有个别方法需要实时数据,更精确的做法是在mapper XML里给该查询加上flushCache="true",或者把这类查询放到独立的Mapper中,避免影响其他缓存友好的查询。

3. 二级缓存的实现细节:装饰器模式与namespace级隔离

一级缓存大家多多少少遇到过,二级缓存才是生产环境最大的坑来源。下面这段尽量讲细一点,因为很多“缓存失效”问题,根源都在对二级缓存机制理解不透。

3.1 CachingExecutor是怎么参与查询的

开启了二级缓存后,Executor对象会变成这样:CachingExecutor包裹在真正执行数据库操作的Executor外层。

public Executor newExecutor(Transaction transaction, boolean autoCommit) { Executor executor = new SimpleExecutor(configuration, transaction); if (cacheEnabled) { executor = new CachingExecutor(executor); } executor = (Executor) interceptorChain.pluginAll(executor); return executor; }

所以在Executor.query被调用时,会先进入CachingExecutor的query方法。这里的大致流程是:

  1. 根据MappedStatement、参数、分页信息、SQL构建CacheKey。
  2. 判断当前语句是否配置了useCache=true。
  3. 如果是,先查二级缓存。
  4. 二级缓存没有,再交给delegate(真实Executor)去查库。
  5. 查到结果后写入二级缓存。

这就是二级缓存“先查全局,再查一级,最后查库”的执行顺序。很多开发者以为二级缓存命中后就不走一级缓存了,这个理解不完全对。CachingExecutor内部拿到结果后,同样会经过本地缓存的存取,因为一级缓存是在delegate的query方法里处理的。

3.2 cache标签背后的实现:Cache接口与装饰链

在mapper XML里加一行<cache/>,MyBatis就会为该namespace创建缓存对象。这个对象实现了org.apache.ibatis.cache.Cache接口,核心方法很少:getId、putObject、getObject、removeObject、clear、getSize。

默认实现是通过一系列装饰器叠加出来的。比如配置:

<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>

对应生成的Cache对象结构大致是:

  • 最底层PerpetualCache,内部就是HashMap。
  • 外面包LruCache,负责淘汰最久未使用的条目。
  • 再外面包LoggingCache或SerializedCache,取决于是否配置readOnly和序列化。
  • 最终被TransactionalCache2包装,负责事务提交时统一提交缓存变更。

这些装饰器都实现同一个Cache接口,所以可以任意叠加,这也是MyBatis缓存设计里比较灵活的地方。

关于四个属性的含义,我用一张表总结:

属性可选值默认含义
evictionLRU / FIFO / SOFT / WEAKLRU缓存淘汰策略
flushInterval正整数(毫秒)不设置缓存清空周期
size正整数1024最大缓存条目数
readOnlytrue / falsefalse是否直接返回对象引用

readOnly=true性能高,因为缓存直接返回对象引用,但调用方如果修改了返回对象,会污染缓存。readOnly=false默认做序列化拷贝,安全但更慢,而且要求实体类实现Serializable,否则运行时会报错。

3.3 二级缓存Key和一级缓存Key是同一套吗

是的,CacheKey的构造逻辑完全一致。所以二级缓存会区分namespace、SQL、参数等维度,不同查询之间不会相互覆盖。

真正需要注意的不是Key本身,而是二级缓存的作用域:一个namespace对应一个缓存。如果同时用userMapper和orderMapper,两者二级缓存是完全独立的对象,即使底层查询的是同一张表,也不会共享。

这直接引出了下一节要讲的脏数据问题,也是整个MyBatis缓存机制里最经典、最让人头疼的部分。

4. 多表查询下的脏数据问题:二级缓存最经典的坑

二级缓存的核心问题在于:它以namespace为边界,但业务查询通常横跨多张表。一条join查询的结果缓存到A namespace里,如果B表发生了更新,B namespace的缓存被清掉了,A namespace里那条join结果却安然无恙,下次查询依然能命中那份过期数据。

4.1 一个能稳定复现的脏数据场景

假设有user表和user_ext表,页面需要展示用户信息并关联扩展字段。在userMapper.xml里写了一条join查询,查的是user表left join user_ext表。同时,在userExtMapper.xml里写了对user_ext表的insert和update操作,也配置了二级缓存。

流程是这样:

  1. 第一次查join列表,结果写入userMapper namespace的二级缓存。
  2. 用户修改user_ext表,userExtMapper二级缓存被清空,但userMapper缓存没受影响。
  3. 再次查询join列表,userMapper缓存命中,返回旧数据。

这就是跨namespace缓存污染。你以为改了表数据,查询却一直命中旧缓存。而且这种情况在单机环境下就能复现,不需要分布式。

4.2 解决办法:cache-ref或干脆不用二级缓存

MyBatis提供了<cache-ref>标签,可以显式指定当前namespace复用另一个namespace的缓存:

<cache-ref namespace="com.example.mapper.UserExtMapper"/>

这样user和user_ext两个Mapper共用同一个缓存对象。无论哪一个执行update,整个缓存都会被清空,join查询下次就不会命中旧数据。

但cache-ref不是银弹。两个namespace共用缓存后,容量、淘汰策略完全绑定在一起,一个高频更新的子表会把整个缓存拖下水,命中率直线下降。

我在实际项目里的经验是:

  • 表之间变更频率差异大时,尽量不用二级缓存。
  • 如果一定要用,把经常变更的关联表做成cache-ref关联,用命中率的下降换数据正确性。
  • 如果业务允许一定延迟,可以依赖flushInterval定期清理,而不是完全不做关联。

顺带说一句,flushCache="true"配置在select上表示每次查询前都清空二级缓存。很多开发者以为这只是“查询不走缓存”,实际上是先清空再查询,代价非常大,生产环境要慎用。

4.3 缓存“不刷新”是清空粒度太粗还是太细

这个问题我排查过好几次,区分起来很简单:如果更新后所有查询都刷新了,说明清理粒度太粗,比如整个namespace被清空;如果更新后特定查询还是旧数据,说明清理粒度太细,没覆盖到那条查询所在的namespace。

实际项目里高频遇到的是“更新A表后,查询A+B的join结果仍是旧数据”,原因就是第二节和第三节所述的namespace隔离。排查时不要急着看SQL,先确认查询和更新分别落在哪个Mapper,它们的namespace是什么关系。

5. 分布式环境下的缓存之痛:默认缓存与第三方集成

前面讲的行为都是单机场景。一旦应用多实例部署,MyBatis自带的二级缓存就基本失去意义,因为每个JVM进程各持有一份缓存副本,任何实例上的数据更新都没法通知其他实例失效。

5.1 多实例下你遇到的现象会是什么

比较典型的场景是两台应用服务器A和B,负载均衡轮询。用户在A实例上改了配置,A的二级缓存清掉了,但B实例还缓存着旧数据。下一次请求恰好落到B,用户看到的就是旧值。

这种旧值问题比单机时更难查,因为无法稳定复现,而且日志里SQL都在执行。我在这类问题上吃过亏,后来定了一条规矩:只要应用会水平扩容,就不要用MyBatis自带的二级缓存,除非设计好了分布式失效方案。

5.2 基于Redis实现自定义Cache

如果团队确实需要用二级缓存,又有多实例需求,比较常见的做法是实现自定义Cache,把缓存数据放到Redis。

实现起来不复杂,核心是实现Cache接口。需要注意几点:

  • 构造方法必须接收String id参数,MyBatis用反射调用这个构造器创建实例,少了它直接报错。
  • putObject的key是CacheKey对象,序列化到Redis时要保证可序列化。
  • value也得可序列化,所以开启二级缓存时实体类最好都实现Serializable。
  • 多个namespace共用一个RedisCache时,key的前缀(namespace id)要设计好,避免串数据。
  • Redis的TTL和MyBatis的flushInterval是两层概念。如果完全委托Redis过期时间,需要重新设计clear和getObject的语义。

一个简化版的RedisCache实现大致长这样:

public class RedisCache implements Cache { private final String id; private final RedisTemplate<String, Object> redisTemplate; public RedisCache(String id) { this.id = id; this.redisTemplate = SpringContextHolder.getBean("redisTemplate"); } @Override public String getId() { return id; } @Override public void putObject(Object key, Object value) { redisTemplate.opsForValue().set(id + ":" + key, value, 30, TimeUnit.MINUTES); } @Override public Object getObject(Object key) { return redisTemplate.opsForValue().get(id + ":" + key); } @Override public Object removeObject(Object key) { return redisTemplate.delete(id + ":" + key); } @Override public void clear() { Set<String> keys = redisTemplate.keys(id + ":*"); if (keys != null && !keys.isEmpty()) { redisTemplate.delete(keys); } } @Override public int getSize() { return 0; } }

然后在XML里这样指定:

<cache type="com.example.common.cache.RedisCache"/>

这里要特别提醒:RedisCache的clear和removeObject行为必须在多实例下仔细设计,尤其是clear,如果通过keys命令删除大量缓存键,Redis的性能会受到明显影响。生产环境建议用scan游标方式分批删除,或者直接把namespace对应的缓存设计成独立的key,方便整体淘汰。

5.3 集成EhCache与Spring Cache怎么选

除了自己写RedisCache,还有mybatis-ehcache这类现成整合包。EhCache支持本地disk持久化,配置通过XML管理,但多实例仍需广播失效,否则和默认缓存没本质区别,所以我不把它作为分布式场景的首选。

我更建议在架构层面单独用Spring Cache管理业务缓存,而不是让MyBatis二级缓存承担太多。Spring Cache是注解驱动,可以在Service层做精细的缓存策略,还能和Redis、Caffeine自由组合。MyBatis二级缓存更适合被定位成局部优化手段,不该是数据一致性的第一道防线。

6. 排查缓存问题的实践工具箱

讲完机制,最后聊聊日常开发中怎么排查缓存问题。按下面的顺序走,大多数问题半小时内能定位。

6.1 先确认缓存到底开没开

MyBatis配置里有全局开关cacheEnabled,默认是true。如果发现二级缓存完全没生效,先查这个配置。全局开关关闭后,就算XML里写了<cache/>也没用。

然后看Mapper XML里有没有<cache/>或<cache-ref/>。注意<cache/>必须写在namespace对应的XML里,用注解开发就在接口上加@CacheNamespace。

6.2 通过日志确认命中与否

MyBatis的BaseExecutor和CachingExecutor都有日志点。开启日志后,命中二级缓存时会输出:

Cache Hit Ratio [com.example.mapper.UserMapper]: 0.75

配置打印SQL和缓存日志常见的方式:

mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: com.example.mapper: debug

Cache Hit Ratio长期偏低,说明缓存Key粒度太细或访问模式不适合缓存,需要从参数和SQL两个方向分析。

6.3 高频问题定位表

把这些年遇到过的高频问题整理成一张表,排查时直接对照:

现象可能原因处理方式
同事务内两次查询结果不一致一级缓存被clearLocalCache触发清空检查事务边界,避免中间执行无关update
更新后查询还是旧值查询的namespace缓存未被目标update清空使用cache-ref关联相关namespace
返回对象被调用方改脏readOnly=false但未做序列化拷贝实体类实现Serializable,或改readOnly=true
二级缓存完全不生效全局cacheEnabled被关闭开启全局开关,检查XML是否有cached标签
多实例下数据不一致默认缓存是JVM本地缓存集成Redis或直接关闭二级缓存
缓存命中率极低CacheKey参数包含无意义字段优化参数对象的hashCode/toString或分页设计
查询报序列化异常实体类未实现Serializable补齐Serializable

6.4 生产环境到底怎么用缓存

说句实在话,很多项目里MyBatis二级缓存是可以用“保守”两个字来描述的。如果数据更新频繁、关联查询多、多实例部署,我建议直接关闭二级缓存,只保留一级缓存。理由很简单:一级缓存受事务边界约束,和数据库事务天然绑定,脏数据概率极小;二级缓存则把缓存边界与事务边界分离开了,很容易在“性能提升”和“数据正确性”之间失去平衡。

如果确实有读多写少、数据变化慢的配置型数据,比如省份表、字典表,可以针对单个namespace开启二级缓存,同时配合flushInterval兜底。这个粒度控制起来相对安全。

最后再分享一个小技巧:判断一个查询是否真的适合进二级缓存,可以观察它的重复执行比例,也就是同一SQL在同一时间段被重复执行的次数。这个比例很高,缓存收益才明显;否则,缓存只是白白消耗内存和增加复杂度。

我个人在实际操作中最深刻的体会是:缓存机制本身不难,难的是知道你改的那条数据可能影响到了哪些查询。所以每次写join或跨表查询前,只要这张表也在别的Mapper里被更新过,我都会先问自己一句:如果这里命中缓存,结果还是新鲜的吗?多问这一句,能省下后面无数个排查之夜。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 10:05:12

AI午餐会:一种面向产业落地的跨域协同新范式

1. 这不是饭局&#xff0c;是一场被低估的AI产业切片现场“AI午餐会”这个词最近在科技圈和创投圈高频出现&#xff0c;但很多人第一反应是——这又是个营销噱头&#xff1f;不就是几个老板边吃牛排边聊大模型&#xff1f;我去年参与过三场被冠以“世界顶级”名号的AI午餐会&am…

作者头像 李华
网站建设 2026/10/3 10:03:25

SSM+Java+App毕设实战:疫情实时统计系统从零到一完整实现解析

引言&#xff1a;选题三个月后的实话 如果你正在为毕业设计发愁&#xff0c;或者已经在各大源码站翻了不下十页&#xff0c;那这篇关于 SSM Java App 方向毕设系统的长文&#xff0c;应该能帮你省下不少时间。以“全球新冠疫情实时统计系统”为例&#xff0c;它本质上不是一…

作者头像 李华
网站建设 2026/10/3 10:02:58

OSS模型托管与推理端点协同优化指南

1. 这不是“云存储”而是“模型服务的高速公路收费站” 你点开控制台&#xff0c;看到一个叫“OSS 模型端点”的服务选项&#xff0c;下意识以为是把大模型文件丢进对象存储里就完事了——这恰恰是绝大多数人踩进的第一个坑。OSS 本身不运行模型&#xff0c;它只是个超大容量、…

作者头像 李华
网站建设 2026/10/3 10:02:35

疏勒河流域shp文件标准化处理:坐标系、属性与拓扑全解析

简介&#xff1a;疏勒河流域shp文件是一套标准Shapefile格式的矢量边界数据&#xff0c;面向从事水文建模、水资源管理、生态环境监测及气候变化影响评估的研究人员与GIS从业者&#xff0c;用于解决流域尺度空间分析缺乏精确基础地理信息的问题。压缩包共8个文件&#xff0c;约…

作者头像 李华
网站建设 2026/10/3 10:02:18

Unity 2D入门实战:Ruby‘s Adventure全流程避坑手册

相信每个跟Unity官方教程做游戏的新手&#xff0c;都绕不开这个经典的2D入门项目——Ruby‘s Adventure。这个教程确实是好东西&#xff0c;麻雀虽小五脏俱全&#xff0c;从瓦片地图、角色移动、敌人AI、对话UI到音频管理全都有。但问题恰恰出在它“太经典”上&#xff1a;官方…

作者头像 李华
网站建设 2026/10/3 10:02:10

STM32+MFRC522工业级门禁系统设计实战

1. 项目概述&#xff1a;这不是一个“刷个卡就开门”的玩具&#xff0c;而是一套可落地、可扩展、可运维的嵌入式门禁系统 MFRC522STM32组合在电子爱好者圈里常被当作入门RFID项目的标配——买块开发板、接几根线、跑通例程、LED亮一下&#xff0c;就算“成功”。但真正用在自家…

作者头像 李华