MyBatis 一级缓存、二级缓存:从原理到脏数据,一篇讲透
面试官:"MyBatis 的缓存机制是怎样的?一级缓存和二级缓存有什么区别?"
你:"一级缓存是 SqlSession 级别的,默认开启;执行增删改操作会自动清空缓存。二级缓存是 Mapper 级别,需要手动开启,可以在多个 SqlSession 之间共享数据。但二级缓存有个大坑------多表关联查询时非常容易产生脏数据,所以不建议在复杂业务场景中开启。"
面试官:"那二级缓存为什么容易产生脏数据?有什么解决方案吗?"
你:"......"
很多人知道两级缓存的存在,也能说出"一级缓存默认开启、二级缓存需手动配置"这类结论。但一追问"为什么二级缓存会有脏数据问题""分布式部署下怎么处理"就含糊了。本文从原理到源码,彻底讲透 MyBatis 缓存机制。
一、缓存是什么?为什么需要缓存?
缓存的本质是将频繁访问的数据临时存储在快速存储介质(通常是内存)中,当再次需要这些数据时直接从缓存获取,避免重复查询数据库,从而提升系统响应速度。
MyBatis 作为持久层框架,提供了两级缓存机制来优化查询性能:
- 一级缓存(Local Cache) :SqlSession 级别,默认开启
- 二级缓存(Second Level Cache) :Mapper(namespace)级别,默认关闭,需手动开启
查询时,MyBatis 按 二级缓存 → 一级缓存 → 数据库 的顺序逐级查找。下面分别展开讲解。
二、一级缓存:SqlSession 级别的"短期记忆"
1. 什么是一级缓存?
一级缓存是 MyBatis 默认开启的缓存机制,作用范围是 SqlSession 级别。每个 SqlSession 都有自己的缓存区域,当在同一 SqlSession 中执行相同 SQL 查询时,第二次查询不会发送到数据库,直接从缓存中获取结果。
java
SqlSession session = sqlSessionFactory.openSession();
UserMapper mapper = session.getMapper(UserMapper.class);
User user1 = mapper.findById(1L); // 第一次查询 → 走数据库
User user2 = mapper.findById(1L); // 第二次查询 → 走一级缓存,不走数据库
System.out.println(user1 == user2); // true,同一个对象引用
session.close();
2. 底层实现原理
一级缓存在底层由 PerpetualCache 类实现,其内部就是一个简单的 HashMap,将查询的特征值作为 key,查询结果作为 value 存储。
MyBatis 在开启一个数据库会话时,会创建 SqlSession 对象,该对象内部持有 Executor 对象,而 Executor 又持有一个 PerpetualCache 对象。当会话结束时,这些对象一并释放。
缓存 Key 的生成基于以下信息:
- MappedStatement 的 ID(如
UserMapper.findById) - 查询的 SQL 语句
- 参数值
- 分页信息(RowBounds)
3. 一级缓存的生命周期与失效场景
一级缓存与 SqlSession 同生共死,以下几种情况会导致一级缓存失效:
| 失效场景 | 说明 |
|---|---|
| 执行增删改操作 | 任何 insert、update、delete 都会自动清空当前 SqlSession 的一级缓存 |
| 手动清空缓存 | 调用 sqlSession.clearCache() |
| SqlSession 关闭 | session.close() 后缓存释放 |
| 查询条件不同 | 即使同一 SqlSession,SQL 或参数不同也不会命中 |
4. 一级缓存的脏数据问题
一级缓存虽然默认开启且使用简单,但在多 SqlSession 并发访问的场景下存在脏数据风险。
当两个不同的 SqlSession(通常是两个线程/请求)操作同一条记录时:
- SqlSessionA 第一次查询后将数据缓存在自己的一级缓存中
- SqlSessionB 更新了数据库中的数据并提交
- SqlSessionA 再次查询相同数据时,仍然从自己的一级缓存中读取旧数据,产生脏读
由于一级缓存是基于 SqlSession 的,SqlSessionB 的更新操作只能清空自己的缓存,无法刷新 SqlSessionA 的缓存。
解决方案:
-
将一级缓存作用域改为
STATEMENT级别,每次查询后自动清空缓存(相当于禁用一级缓存):xml<settings> <setting name="localCacheScope" value="STATEMENT"/> </settings>
三、二级缓存:Mapper 级别的"长期记忆"
1. 什么是二级缓存?
二级缓存是 Mapper(namespace)级别的缓存,数据可以在多个 SqlSession 之间共享。当一个 SqlSession 关闭或提交事务时,该会话中一级缓存的数据会被写入二级缓存,后续其他 SqlSession 查询相同数据时可直接从二级缓存获取。
2. 如何开启二级缓存?
开启二级缓存需要三步:
第一步:在 mybatis-config.xml 中开启全局缓存开关 (默认即为 true)
xml
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
第二步:在 Mapper.xml 中添加 <cache> 标签
xml
<mapper namespace="com.example.mapper.UserMapper">
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="false"/>
</mapper>
第三步:实体类实现 Serializable 接口
java
public class User implements Serializable {
private Long id;
private String name;
// getter/setter
}
<cache> 标签的可选属性说明:
| 属性 | 可选值 | 说明 |
|---|---|---|
eviction |
LRU(默认)、FIFO、SOFT、WEAK | 缓存回收策略 |
flushInterval |
毫秒数 | 缓存刷新间隔,默认无 |
size |
正整数(默认1024) | 最大缓存对象数 |
readOnly |
true/false | true:返回对象相同实例,性能高但不可修改;false(默认):返回拷贝,安全但较慢 |
3. 二级缓存的工作原理
二级缓存同样基于 PerpetualCache,底层也是 HashMap。与一级缓存不同的是,二级缓存的作用域是 Mapper 的同一个 namespace,不同 SqlSession 执行相同 namespace 下的相同 SQL 时,可以共享缓存数据。
查询时,MyBatis 的执行顺序为:二级缓存 → 一级缓存 → 数据库。
四、二级缓存的脏数据问题(重点)
二级缓存最大的坑在于:多表关联查询时极容易出现脏数据。这也是面试官最常追问的地方。
1. 根本原因:按 namespace 隔离
MyBatis 的二级缓存按 namespace(即 Mapper 文件)隔离。多表关联查询的结果只缓存在其中一个 Mapper 的 namespace 中,而更新操作往往在另一个 Mapper 中执行,两者 namespace 不互通,导致更新操作无法触发关联缓存的失效。
2. 典型场景演示
假设有两个 Mapper:
UserMapper:查询用户及其角色信息(selectUserWithRole)RoleMapper:更新角色名称(updateRoleName)
执行流程:
- 第一次查询用户带角色信息,结果存入
UserMapper的二级缓存 - 另一个线程通过
RoleMapper将角色名从"管理员"改为"审计员"并提交事务 - 再次查询同一用户时,
UserMapper的缓存并未被清空,直接返回旧的"管理员"角色名
3. 分布式部署下的缓存不一致
MyBatis 原生二级缓存基于 JVM 本地内存(PerpetualCache),每个应用实例都有独立的缓存副本。在分布式部署场景下:
- 用户 A 在实例 1 更新数据,实例 1 清空自己的缓存
- 用户 B 下次请求落到实例 2,实例 2 的缓存中仍然是旧数据
- 各节点之间的缓存无法同步
这不是"缓存没刷新",而是"压根就没法刷新别人家的缓存"。分布式部署的应用不建议开启 MyBatis 原生二级缓存。
4. 多表查询脏数据的解决方案
| 方案 | 实现方式 | 优缺点 |
|---|---|---|
| cache-ref | 使用 <cache-ref namespace="..."/> 让多个 Mapper 共享同一 namespace 的缓存 |
能解决跨 namespace 刷新问题,但多个表共享同一缓存后,任意表的更新都会清空整个缓存,缓存失效频率急剧升高,namespace 耦合度增加,维护成本高 |
| 禁用二级缓存 | 不对涉及多表查询的 Mapper 开启二级缓存 | 最安全,无脏数据风险,但查询频繁的场景性能会下降 |
| 使用第三方缓存(Redis 等) | 实现 MyBatis Cache 接口,将缓存后端替换为 Redis | 彻底解决分布式缓存同步问题,但需要额外开发成本 |
| 定时刷新 | 配置 flushInterval 让缓存定期自动清空 |
简单,但数据一致性只能做到最终一致,适合对实时性要求不高的场景 |
五、查询顺序与缓存联动
MyBatis 查询时遵循以下顺序:
1. 查询二级缓存(CachingExecutor)
2. 如果二级缓存未命中,查询一级缓存(BaseExecutor.localCache)
3. 如果一级缓存也未命中,查询数据库
4. SqlSession 关闭时,一级缓存中的数据写入二级缓存
需要注意的是:
- 执行任何增删改操作,一级缓存和二级缓存同时失效
- 可通过
flushCache="true"强制清空缓存(默认为false,insert/update/delete语句默认为true)
六、一级缓存 vs 二级缓存 对比总结
| 对比维度 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用范围 | SqlSession 级别 | Mapper(namespace)级别 |
| 默认状态 | ✅ 默认开启,不可关闭 | ❌ 默认关闭,需手动开启 |
| 生命周期 | 与 SqlSession 同生共死 | 与 SqlSessionFactory 同生共死 |
| 缓存存储 | 本地 HashMap(PerpetualCache) | 本地 HashMap,可替换为第三方实现 |
| 共享范围 | 同一 SqlSession 内共享 | 多个 SqlSession 间共享 |
| 失效触发 | 增删改、clearCache()、会话关闭 |
增删改(提交事务后)、flushInterval、手动清空 |
| 脏数据风险 | 跨 SqlSession 并发时存在 | 多表查询/分布式部署时严重 |
| 适用场景 | 事务内的重复查询 | 读多写少、单表查询为主的简单业务 |
七、面试高频追问
Q1:为什么一级缓存无法关闭?
从 MyBatis 源码设计来看,一级缓存是 SqlSession 内部 Executor 的基础功能,没有提供关闭开关。如果确实不需要,可以通过设置 localCacheScope=STATEMENT 让每次查询后清空缓存,达到等效关闭的效果。
Q2:二级缓存为什么要求实体类实现 Serializable?
MyBatis 的二级缓存支持将缓存数据写入磁盘(如缓存回收时的持久化),并且在 readOnly=false 时需要返回对象的拷贝(通过序列化/反序列化实现)。因此要求实体类实现 Serializable 接口,否则会抛出异常。
Q3:二级缓存可以跨 Mapper 共享吗?
可以。通过 <cache-ref namespace="other.Mapper"/> 配置,让当前 Mapper 引用其他 Mapper 的缓存配置,两个 Mapper 共享同一 namespace 的缓存。但这会导致缓存粒度变大,任意表的更新都会清空整个缓存,频繁更新时会大大降低缓存命中率。
Q4:生产中到底该不该用二级缓存?
谨慎使用,甚至建议禁用。二级缓存的设计存在结构性缺陷------按 namespace 隔离但业务查询往往跨表,这种割裂导致脏数据问题很难彻底规避。在实际项目中,更推荐以下策略:
- 默认关闭二级缓存,只在明确适合的场景(如读多写少的单表字典表)选择性开启
- 需要缓存时,优先考虑 Redis 等外部缓存,由业务层主动控制缓存的写入和失效
- 对于高性能查询,优化 SQL 和数据库索引往往比引入缓存带来的收益更大、风险更小
Q5:一级缓存在 Spring 整合环境下还能用吗?
Spring 整合 MyBatis 时,默认每个 SqlSession 对应一个事务,事务结束后 SqlSession 关闭。因此一级缓存仅在同一个事务内的重复查询中有效,跨事务的请求之间无法共享一级缓存------不同请求的缓存是隔离的,这也避免了一部分脏数据问题。
八、最佳实践建议
- 一级缓存保持默认即可,无需额外配置。注意事务边界:同一事务内的重复查询可享受缓存加速,跨事务不会产生脏读干扰。
- 二级缓存默认不开启 。只有满足以下所有条件时才考虑开启:
- 业务以单表查询为主
- 读多写少,增删改操作极少
- 表与表之间关联较少
- 应用为单机部署(非分布式)
- 多表查询场景绝对不要开启二级缓存 。除非使用
cache-ref将所有相关表合并到同一 namespace,但需评估缓存失效频率和耦合度成本。 - 分布式部署用 Redis 替代原生二级缓存。实现 MyBatis Cache 接口,接入集中式缓存(如 Redis),从根本上解决缓存不一致问题。
- 优先优化 SQL 和索引。不要为了用缓存而用缓存,合理的数据库设计往往比缓存带来的性能提升更可靠。
总结
| 维度 | 一级缓存 | 二级缓存 |
|---|---|---|
| 级别 | SqlSession | Mapper(namespace) |
| 默认 | 开启 | 关闭 |
| 生命周期 | 会话级 | 应用级 |
| 脏数据风险 | 低(多会话并发时存在) | 高(多表查询/分布式) |
| 适用场景 | 事务内重复查询 | 单表读多写少的简单业务 |
一句话记住缓存机制 :一级会话共享,命中快;二级命名空间,需慎开;多表联查脏数据,分布式下更无奈。
MyBatis 缓存是面试中的高频考点,理解其原理和陷阱远比死记硬背重要。生产环境中,建议将二级缓存作为"万不得已的优化手段"而非默认选项,始终把数据一致性放在第一位。
希望这篇文章能帮你彻底掌握 MyBatis 缓存机制,从容应对面试追问,并在实际开发中避开常见坑,欢迎继续讨论。
我的个人简介最后有一段内容,感兴趣的朋友可以去找找看。那里有我日常分享的技术深度 解析和职场避坑指南,期待与您继续交流。