java面试必问13:MyBatis 一级缓存、二级缓存:从原理到脏数据,一篇讲透

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 同生共死,以下几种情况会导致一级缓存失效:

失效场景 说明
执行增删改操作 任何 insertupdatedelete 都会自动清空当前 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

执行流程:

  1. 第一次查询用户带角色信息,结果存入 UserMapper 的二级缓存
  2. 另一个线程通过 RoleMapper 将角色名从"管理员"改为"审计员"并提交事务
  3. 再次查询同一用户时,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" 强制清空缓存(默认为 falseinsert/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 关闭。因此一级缓存仅在同一个事务内的重复查询中有效,跨事务的请求之间无法共享一级缓存------不同请求的缓存是隔离的,这也避免了一部分脏数据问题。


八、最佳实践建议

  1. 一级缓存保持默认即可,无需额外配置。注意事务边界:同一事务内的重复查询可享受缓存加速,跨事务不会产生脏读干扰。
  2. 二级缓存默认不开启 。只有满足以下所有条件时才考虑开启:
    • 业务以单表查询为主
    • 读多写少,增删改操作极少
    • 表与表之间关联较少
    • 应用为单机部署(非分布式)
  3. 多表查询场景绝对不要开启二级缓存 。除非使用 cache-ref 将所有相关表合并到同一 namespace,但需评估缓存失效频率和耦合度成本。
  4. 分布式部署用 Redis 替代原生二级缓存。实现 MyBatis Cache 接口,接入集中式缓存(如 Redis),从根本上解决缓存不一致问题。
  5. 优先优化 SQL 和索引。不要为了用缓存而用缓存,合理的数据库设计往往比缓存带来的性能提升更可靠。

总结

维度 一级缓存 二级缓存
级别 SqlSession Mapper(namespace)
默认 开启 关闭
生命周期 会话级 应用级
脏数据风险 低(多会话并发时存在) 高(多表查询/分布式)
适用场景 事务内重复查询 单表读多写少的简单业务

一句话记住缓存机制一级会话共享,命中快;二级命名空间,需慎开;多表联查脏数据,分布式下更无奈

MyBatis 缓存是面试中的高频考点,理解其原理和陷阱远比死记硬背重要。生产环境中,建议将二级缓存作为"万不得已的优化手段"而非默认选项,始终把数据一致性放在第一位。

希望这篇文章能帮你彻底掌握 MyBatis 缓存机制,从容应对面试追问,并在实际开发中避开常见坑,欢迎继续讨论。

我的个人简介最后有一段内容,感兴趣的朋友可以去找找看。那里有我日常分享的技术深度 解析和职场避坑指南,期待与您继续交流。

相关推荐
我叫黑大帅4 小时前
为什么map查找时间复杂度是O(1)?
后端·算法·面试
M ? A5 小时前
Vue 动态组件在 React 中,VuReact 会如何实现?
前端·javascript·vue.js·经验分享·react.js·面试·vureact
aq55356005 小时前
编程语言三巨头:汇编、C++与PHP大比拼
java·开发语言
我是无敌小恐龙5 小时前
Java SE 零基础入门Day01 超详细笔记(开发前言+环境搭建+基础语法)
java·开发语言·人工智能·opencv·spring·机器学习
心态与习惯6 小时前
Julia 初探,及与 C++,Java,Python 的比较
java·c++·python·julia·比较
一叶飘零_sweeeet6 小时前
优秀文章合集
java
zopple6 小时前
ThinkPHP5.x与3.x核心差异解析
java·python·php
南境十里·墨染春水7 小时前
C++ 笔记 thread
java·开发语言·c++·笔记·学习
南境十里·墨染春水7 小时前
C++ 笔记 高级线程同步原语与线程池实现
java·开发语言·c++·笔记·学习