☰
MyBatis缓存机制详解:一级缓存失效与二级缓存脏读避坑指南
2026/10/8 15:40:07 网站建设 项目流程

上一篇把 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 类型的成员变量 localCache,PerpetualCache 的实现非常简单,本质上就是一个 HashMap:put 方法存对象,get 方法取对象,除此之外没有过期时间、没有容量限制。所以一级缓存的“过期策略”就全靠 SqlSession 和对应的 Executor 生命周期来控制。

每次你调用 SqlSession.selectList 之类的操作时,底层都会经过 Executor.query()。BaseExecutor 的逻辑是:

  1. 根据查询参数构造一个 CacheKey;
  2. 先到 localCache 里查;
  3. 命中直接返回;
  4. 没有命中就去数据库查,查到后放入 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-impl=org.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/delete,MyBatis 会清空一级缓存。原因是写操作会改变数据,之前的只读缓存已经不可信了。这个行为由 BaseExecutor 的 update 方法触发,里面会调用 clearLocalCache。注意不只是修改了同一条记录才会清,是任何写操作都会把整个 localCache 清空,这个“宁可错杀不可放过”的策略我一直觉得挺实在。

第三个是查询参数或分页条件变化。offset 变了、limit 变了、参数值变了,CacheKey 跟着变,自然不命中。这个很好理解,但真正容易忽略的是:如果你的查询用了动态 SQL<if>标签,传不同参数拼出来的 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。但请注意,cacheEnabled=true只是全局开关,真正启用二级缓存还需要每个 Mapper 显式声明<cache/>。全局开关默认打开,但没有任何 Mapper 声明 cache 的话,二级缓存根本不会创建。

开启一个 Mapper 的二级缓存,你只需要在对应 XML 里加一行:

<mapper namespace="com.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 缓存),其他各种功能全是通过装饰器模式叠加的。

比如你配置readOnly=true,它会在基础缓存外面包上 SerializedCache,返回对象时先序列化到字节数组、再反序列化回来,保证每次调用方拿到的都是独立副本,避免并发修改互相干扰。你配置eviction=LRU,它会包上 LruCache,维护一个 LRU 淘汰队列。你配置blocking=true,它会包上 BlockingCache,在缓存未命中时对 key 加锁,避免大量请求同时穿透到数据库。

我整理了一张常用的装饰器对照表,面试时能说出来相当加分:

装饰器类作用
LruCache按最近最少使用策略淘汰缓存项
FifoCache先进先出淘汰
SoftCache / WeakCache基于软引用/弱引用,内存不足时自动回收
SerializedCache序列化后缓存,返回对象的副本
LoggingCache输出缓存命中率日志,调试很有用
SynchronizedCache给缓存操作加锁,线程安全
BlockingCache缓存未命中时阻塞后续线程,防止击穿
ScheduledCache支持周期性清空缓存

我在排查一个老项目时,就用 LoggingCache 的命中率日志定位过问题。MyBatis 会在日志里打印 Cache Hit Ratio,如果你发现命中率一直是个位数,缓存基本没起作用,就别忙着调 size,先检查 CacheKey 是不是被参数动态拼接搞得不稳定了。

理解装饰器链还有一个实际收益:你能判断“缓存的对象是不是同一个引用”。默认 readOnly=false 时,每次读取都会反序列化出新的对象,理论上更安全;readOnly=true 时直接返回缓存的同一个对象引用,多个线程同时拿到这个对象并修改属性,会发生不可预知的并发问题。这个细节我在生产环境里踩过,所以强烈建议你不要为了“快”去开 readOnly=true。

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 的二级缓存就会被清掉。这也是为什么读多写少的表开二级缓存收益大——一旦写操作频繁,缓存刚建好就被清空,命中率自然很难看。

另外还有一处细节值得注意:查询语句也可以设置 flushCache=true,告诉 MyBatis“这条查询执行前先清空缓存”。这个属性我在做强制刷新场景时用过:用户点了刷新按钮,前端请求一个设置了 flushCache 的查询,后台先把旧缓存清了再查库,保证拿到最新数据。算是比较冷门但实用的手段。

5. 面试题与实战方案:缓存部分的标准答法

5.1 高频问题速答表

结合这些年面试别人和被别人面过的经验,我整理了缓存这块出现频率最高的问题,可以直接作为复习提纲:

问题标准答法要点
一级缓存和二级缓存有什么区别一级缓存是 SqlSession 级别,默认开启,存在 BaseExecutor.localCache 中;二级缓存是 namespace 级别,跨会话共享,需要显式配置
为什么 Spring 环境下的一级缓存几乎不生效SqlSessionTemplate 的代理每次执行完自动关闭 SqlSession,只有 @Transactional 事务内才复用同一个会话
二级缓存的 key 是什么由 MappedStatement id、SQL、参数、RowBounds、环境信息等共同构成的 CacheKey
什么情况会导致二级缓存脏读跨表 join 查询的缓存不会因其他 namespace 写操作而清空;多租户拼接条件不体现在参数中时,CacheKey 无租户维度
readOnly=true 和 false 有什么差别true 直接返回缓存对象引用,速度快但有并发修改风险;false 走序列化返回副本,安全但性能略低
blocking 是干什么的未命中时对 key 加锁,防止大量并发请求同时穿透到数据库
为什么不建议在业务表上开二级缓存失效粒度粗、join 和租户场景易脏读、分布式环境扩展性差
二级缓存什么时候写入查询结果先放到事务缓存中,事务提交时才真正写入二级缓存

5.2 我的建议:什么项目适合开启二级缓存

聊完理论,说点落地的判断标准。我给团队定的规矩很明确:满足下面全部条件的表才能考虑开二级缓存。

第一,读多写少,写入频率极低。比如省市字典、国家代码、系统配置。你一天写一次、一秒钟读几千次,缓存收益才足够大。第二,查询语句基本不涉及动态 SQL,SQL 稳定,参数类型也稳定,保证 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 覆盖了所有隔离维度。

这篇的内容到这里就完整了。你如果正在准备面试,重点把第二章的失效场景和第四章的执行顺序拿下;如果是在实际项目中做方案,请务必先回答一个问题:数据变了,缓存怎么知道?答案不确定,就不要在业务表上开二级缓存。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询