MyBatis 缓存机制详解
一、缓存机制概述
MyBatis 作为 Java 常用的 ORM 框架,提供了缓存机制来减少数据库查询次数,提升性能。但缓存机制在使用不当时容易引发脏数据问题,理解其原理非常重要。
MyBatis 只有两级缓存
重要概念
MyBatis 只有两级缓存,不存在"三级缓存"的说法。
| 缓存级别 | 作用域 | 开启方式 | 共享范围 |
|---|---|---|---|
| 一级缓存 | SqlSession | 默认开启 | 同一个 SqlSession 内 |
| 二级缓存 | namespace | 需手动配置 | 同一个 namespace 下所有 SqlSession |
查询流程(开启二级缓存时)
用户查询请求
↓
┌─────────────────┐
│ 二级缓存查询 │ ← namespace 级别,跨 Session 共享
└────────┬────────┘
↓ 未命中
┌─────────────────┐
│ 一级缓存查询 │ ← SqlSession 级别
└────────┬────────┘
↓ 未命中
┌─────────────────┐
│ 查询数据库 │
└────────┬────────┘
↓
写入一级缓存 → 写入二级缓存(commit 后)→ 返回结果二、一级缓存详解
2.1 基本概念
一级缓存是 SqlSession 级别 的缓存,每个 SqlSession 对象内部都会持有一个本地缓存(LocalCache)。
核心组件关系:
SqlSession(用户接口)
└── Executor(执行器)
└── LocalCache(本地缓存,实际是 PerpetualCache)
└── HashMap(存储缓存数据)2.2 工作原理图示
┌──────────────────────────────────────────────────────────┐
│ SqlSession │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Executor │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ LocalCache │ │ │
│ │ │ │ │ │
│ │ │ CacheKey → 查询结果 │ │ │
│ │ │ │ │ │
│ │ │ key1 → Student(id=1, name="张三") │ │ │
│ │ │ key2 → List<Student>... │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
查询流程:
1. 生成 CacheKey(由 SQL + 参数等组成)
2. 在 LocalCache 中查找
3. 找到 → 直接返回
4. 未找到 → 查数据库 → 存入 LocalCache → 返回2.3 CacheKey 的组成
CacheKey 决定了"什么情况下认为是相同的查询",其构成如下:
CacheKey cacheKey = new CacheKey();
cacheKey.update(ms.getId()); // Statement Id(Mapper方法的全路径名)
cacheKey.update(rowBounds.getOffset()); // 分页偏移量
cacheKey.update(rowBounds.getLimit()); // 分页限制
cacheKey.update(boundSql.getSql()); // SQL 语句本身
cacheKey.update(parameter); // SQL 参数值举个例子:
Statement Id: com.example.mapper.StudentMapper.getStudentById
Offset: 0
Limit: 2147483647(默认无限制)
SQL: SELECT * FROM student WHERE id = ?
参数: 1
这些组合起来生成唯一的 CacheKey2.4 一级缓存的两种配置模式
<!-- mybatis-config.xml -->
<settings>
<!-- SESSION(默认):整个 SqlSession 期间缓存有效 -->
<setting name="localCacheScope" value="SESSION"/>
<!-- STATEMENT:每次查询后立即清空缓存 -->
<!-- <setting name="localCacheScope" value="STATEMENT"/> -->
</settings>| 配置值 | 缓存行为 | 适用场景 |
|---|---|---|
SESSION(默认) | 同一 SqlSession 内多次相同查询只查一次数据库 | 单次业务操作内的重复查询 |
STATEMENT | 每次查询后清空缓存,相当于"禁用共享" | 需要每次都查最新数据的场景 |
2.5 一级缓存失效时机
以下情况会导致一级缓存被清空:
// BaseExecutor.update() 方法
public int update(MappedStatement ms, Object parameter) {
clearLocalCache(); // ← 执行任何更新操作前,先清空缓存
return doUpdate(ms, parameter);
}失效场景总结:
| 操作 | 缓存状态 |
|---|---|
sqlSession.close() | 缓存销毁(Session 结束) |
sqlSession.clearCache() | 手动清空缓存 |
sqlSession.commit() | 清空缓存 |
执行 insert/update/delete | 清空缓存 |
localCacheScope=STATEMENT | 每次查询后自动清空 |
2.6 实验演示
实验 1:同一 SqlSession 多次相同查询
SqlSession session = factory.openSession();
StudentMapper mapper = session.getMapper(StudentMapper.class);
// 第一次查询
Student s1 = mapper.getStudentById(1); // 查询数据库,写入缓存
System.out.println(s1);
// 第二次查询(完全相同的 SQL 和参数)
Student s2 = mapper.getStudentById(1); // 直接从缓存返回
System.out.println(s2);
// 第三次查询
Student s3 = mapper.getStudentById(1); // 直接从缓存返回
System.out.println(s3);
session.close();输出日志:
==> Preparing: SELECT * FROM student WHERE id = ?
==> Parameters: 1(Integer)
<== Total: 1
Student(id=1, name=张三)
Student(id=1, name=张三) ← 无 SQL 输出,命中缓存
Student(id=1, name=张三) ← 无 SQL 输出,命中缓存实验 2:执行更新后缓存失效
SqlSession session = factory.openSession();
StudentMapper mapper = session.getMapper(StudentMapper.class);
Student s1 = mapper.getStudentById(1); // 查数据库,写入缓存
System.out.println(s1);
// 执行更新操作
mapper.updateStudentName("李四", 1); // 清空缓存!
session.commit();
// 再次查询
Student s2 = mapper.getStudentById(1); // 缓存已清空,重新查数据库
System.out.println(s2);
session.close();输出日志:
==> Preparing: SELECT * FROM student WHERE id = ? ← 第一次查询
==> Parameters: 1(Integer)
<== Total: 1
==> Preparing: UPDATE student SET name = ? WHERE id = ? ← 更新操作
==> Preparing: SELECT * FROM student WHERE id = ? ← 再次查询数据库(缓存已失效)
==> Parameters: 1(Integer)
<== Total: 1实验 3:不同 SqlSession 之间缓存不共享
SqlSession session1 = factory.openSession();
SqlSession session2 = factory.openSession();
StudentMapper mapper1 = session1.getMapper(StudentMapper.class);
StudentMapper mapper2 = session2.getMapper(StudentMapper.class);
// session1 查询
Student s1 = mapper1.getStudentById(1); // session1 缓存写入
System.out.println(s1); // 输出:张三
// session2 更新数据
mapper2.updateStudentName("李四", 1);
session2.commit(); // session2 的二级缓存刷新(如果开启)
// session1 再次查询
Student s2 = mapper1.getStudentById(1); // 从 session1 自己的缓存返回
System.out.println(s2); // 输出:张三 ← 脏数据!数据库已是李四
session1.close();
session2.close();一级缓存的问题
多个 SqlSession 之间缓存不共享,在一个 Session 更新数据后,另一个 Session 可能读到旧数据(脏数据)。
三、二级缓存详解
3.1 基本概念
二级缓存是 namespace 级别 的缓存,同一个 Mapper 的所有 SqlSession 共享同一块缓存区域。
与一级缓存的对比:
一级缓存:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ SqlSession1 │ │ SqlSession2 │ │ SqlSession3 │
│ Cache1 │ │ Cache2 │ │ Cache3 │
└─────────────┘ └─────────────┘ └─────────────┘
各自独立,不共享
二级缓存:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ SqlSession1 │ │ SqlSession2 │ │ SqlSession3 │
└─────────────┘ └─────────────┘ └─────────────┘
↓ ↓ ↓
┌───────────────────────────────────────────────┐
│ namespace 级别的共享缓存 │
│ (StudentMapper 的 Cache) │
└───────────────────────────────────────────────┘3.2 开启二级缓存
步骤 1:全局配置
<!-- mybatis-config.xml -->
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>步骤 2:Mapper 配置
<!-- StudentMapper.xml -->
<mapper namespace="com.example.mapper.StudentMapper">
<!-- 简单声明 -->
<cache/>
<!-- 或者详细配置 -->
<cache
type="PERPETUAL" <!-- 缓存实现类 -->
eviction="LRU" <!-- 淘汰策略 -->
flushInterval="60000" <!-- 刷新间隔(毫秒) -->
size="512" <!-- 最大缓存对象数 -->
readOnly="true" <!-- 是否只读 -->
blocking="false" <!-- 未命中时是否阻塞 -->
/>
</mapper>cache 标签属性详解:
| 属性 | 说明 | 可选值 |
|---|---|---|
type | 缓存实现类 | 默认 PERPETUAL(内部 HashMap) |
eviction | 缓存淘汰策略 | LRU(默认)、FIFO、SOFT、WEAK |
flushInterval | 自动刷新间隔 | 默认不自动刷新 |
size | 最大缓存数量 | 默认 1024 |
readOnly | 是否只读 | true:返回引用;false:返回序列化副本 |
blocking | 是否阻塞等待 | true:缓存未命中时阻塞直到有数据 |
3.3 缓存装饰器链
MyBatis 使用装饰器模式为缓存添加各种能力:
最终的缓存对象结构:
┌─────────────────────────────────────────────────────────────┐
│ SynchronizedCache │ ← 同步锁
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ LoggingCache │ │ ← 记录命中率
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ SerializedCache │ │ │ ← 序列化存储
│ │ │ ┌─────────────────────────────────────────────────┐ │ │ │
│ │ │ │ LruCache │ │ │ │ ← LRU 淘汰
│ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ PerpetualCache │ │ │ │ │ ← 基础实现
│ │ │ │ │ │ │ │ │ │
│ │ │ │ │ HashMap<Object, Object> │ │ │ │ │
│ │ │ │ └─────────────────────────────────────────────┘ │ │ │ │
│ │ │ └─────────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ TransactionalCache │ │ │ ← 事务管理
│ │ │ │ │ │
│ │ │ entriesToAddOnCommit: 待提交的缓存数据 │ │ │
│ │ │ clearOnCommit: 是否在提交时清空 │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘各装饰器作用:
| 装饰器类 | 作用 |
|---|---|
SynchronizedCache | 为所有方法加 synchronized,保证线程安全 |
LoggingCache | 记录缓存命中率,DEBUG 模式下输出日志 |
SerializedCache | 序列化缓存值,返回副本而非引用,避免修改缓存内容 |
LruCache | 实现 LRU 淘汰算法,超过 size 时移除最少使用的 |
PerpetualCache | 最底层实现,使用 HashMap 存储数据 |
TransactionalCache | 管理事务,只有 commit 后才真正写入缓存 |
3.4 二级缓存与事务的关系
重要
二级缓存只有在 SqlSession.commit() 或 SqlSession.close() 后才会生效!
原因:TransactionalCache 会暂存缓存数据,等到事务提交时才写入真正的缓存。
查询流程(开启二级缓存):
SqlSession.query()
↓
CachingExecutor.query()
↓
┌─────────────────────────────────────────┐
│ tcm.getObject(cache, key) │ ← 从 TransactionalCacheManager 获取
│ │
│ TransactionalCache │
│ ↓ │
│ delegate.getObject(key) │ ← 从装饰链获取
│ ↓ │
│ PerpetualCache (HashMap) │
└─────────────────────────────────────────┘
↓ 未命中
┌─────────────────────────────────────────┐
│ delegate.query() → 查数据库 │
│ ↓ │
│ tcm.putObject(cache, key, result) │ ← 放入待提交区
│ ↓ │
│ entriesToAddOnCommit.put(key, result) │ ← 只是暂存,未真正写入!
└─────────────────────────────────────────┘
SqlSession.commit()
↓
┌─────────────────────────────────────────┐
│ TransactionalCache.commit() │
│ ↓ │
│ flushPendingEntries() │ ← 将待提交数据写入真正的缓存
│ ↓ │
│ delegate.putObject(key, value) │
└─────────────────────────────────────────┘3.5 实验演示
实验 1:不提交事务,二级缓存不生效
SqlSession session1 = factory.openSession();
SqlSession session2 = factory.openSession();
StudentMapper mapper1 = session1.getMapper(StudentMapper.class);
StudentMapper mapper2 = session2.getMapper(StudentMapper.class);
// session1 查询
Student s1 = mapper1.getStudentById(1); // 查数据库
System.out.println(s1);
// session1 不提交,直接关闭
session1.close(); // 未 commit,缓存数据未写入二级缓存
// session2 查询
Student s2 = mapper2.getStudentById(1); // 又查数据库!
System.out.println(s2);
session2.close();实验 2:提交事务后,二级缓存生效
SqlSession session1 = factory.openSession();
SqlSession session2 = factory.openSession();
StudentMapper mapper1 = session1.getMapper(StudentMapper.class);
StudentMapper mapper2 = session2.getMapper(StudentMapper.class);
// session1 查询并提交
Student s1 = mapper1.getStudentById(1); // 查数据库
session1.commit(); // 提交后,缓存写入二级缓存!
// session2 查询
Student s2 = mapper2.getStudentById(1); // 从二级缓存返回
System.out.println("命中缓存:" + s2);
session2.close();输出日志:
Cache Hit Ratio [com.example.mapper.StudentMapper]: 0.5
← 命中率 0.5 表示第二次查询命中了缓存实验 3:更新操作刷新二级缓存
SqlSession session1 = factory.openSession();
SqlSession session2 = factory.openSession();
SqlSession session3 = factory.openSession();
StudentMapper mapper1 = session1.getMapper(StudentMapper.class);
StudentMapper mapper2 = session2.getMapper(StudentMapper.class);
StudentMapper mapper3 = session3.getMapper(StudentMapper.class);
// session1 查询并提交
Student s1 = mapper1.getStudentById(1);
session1.commit(); // 写入二级缓存
// session2 查询(命中缓存)
Student s2 = mapper2.getStudentById(1); // 从二级缓存返回
// session3 更新数据
mapper3.updateStudentName("王五", 1);
session3.commit(); // 刷新 StudentMapper 的二级缓存!
// session2 再次查询(缓存已刷新)
Student s3 = mapper2.getStudentById(1); // 重新查数据库
System.out.println(s3); // 输出:王五四、二级缓存的问题与局限
4.1 多表查询的脏数据问题
严重缺陷
MyBatis 二级缓存基于 namespace,多表查询极易产生脏数据!
场景说明:
表关系:
student(学生表) classroom(班级学生关联表)
id, name student_id, class_id
↓
class(班级表)
id, class_name<!-- StudentMapper.xml -->
<select id="getStudentWithClassInfo" resultType="StudentWithClass">
SELECT s.*, c.class_name
FROM student s
LEFT JOIN classroom cr ON s.id = cr.student_id
LEFT JOIN class c ON cr.class_id = c.id
WHERE s.id = #{id}
</select>
<!-- ClassMapper.xml -->
<update id="updateClassName">
UPDATE class SET class_name = #{className} WHERE id = #{id}
</update>问题演示:
SqlSession session1 = factory.openSession();
SqlSession session2 = factory.openSession();
SqlSession session3 = factory.openSession();
StudentMapper mapper1 = session1.getMapper(StudentMapper.class);
StudentMapper mapper2 = session2.getMapper(StudentMapper.class);
ClassMapper mapper3 = session3.getMapper(ClassMapper.class);
// session1 查询学生+班级信息(多表查询)
StudentWithClass s1 = mapper1.getStudentWithClassInfo(1);
System.out.println(s1.getClassName()); // 输出:一班
session1.commit(); // 写入 StudentMapper 的二级缓存
// session3 更新班级名(ClassMapper 的操作)
mapper3.updateClassName("二班", 1);
session3.commit(); // 只刷新 ClassMapper 的缓存!
// session2 再次查询学生+班级信息
StudentWithClass s2 = mapper2.getStudentWithClassInfo(1);
System.out.println(s2.getClassName()); // 输出:一班 ← 脏数据!
// 实际数据库已是二班问题根源:
StudentMapper 的缓存:
┌─────────────────────────────────────────┐
│ key: getStudentWithClassInfo(1) │
│ value: {name: 张三, className: 一班} │ ← 缓存的是"一班"
└─────────────────────────────────────────┘
ClassMapper 更新班级名 → ClassMapper 的缓存被刷新
↓
但 StudentMapper 的缓存不知道!
↓
StudentMapper 再次查询 → 返回旧的"一班"(脏数据)4.2 解决方案:cache-ref
使用 cache-ref 让多个 Mapper 共用同一块缓存:
<!-- ClassMapper.xml -->
<cache-ref namespace="com.example.mapper.StudentMapper"/>这样 ClassMapper 的操作也会刷新 StudentMapper 的缓存。
但副作用:缓存粒度变粗,ClassMapper 的任何操作都会影响 StudentMapper 的缓存。
4.3 分布式环境的问题
MyBatis 默认的缓存实现是基于本地内存的:
服务器 A:
┌─────────────────┐
│ MyBatis Cache │ ← 本地 HashMap
└─────────────────┘
服务器 B:
┌─────────────────┐
│ MyBatis Cache │ ← 本地 HashMap(与 A 不共享!)
└─────────────────┘问题:
- 服务器 A 更新数据后,服务器 B 的缓存仍是旧数据
- 需要自定义 Cache 实现(如 Redis)才能解决
五、最佳实践建议
5.1 生产环境建议
推荐做法
生产环境中关闭 MyBatis 缓存,让其作为纯粹的 ORM 框架使用。
分布式缓存需求直接使用 Redis、Memcached 等专业方案。
5.2 适用场景总结
| 场景 | 推荐配置 | 说明 |
|---|---|---|
| 分布式环境 | 关闭缓存 | 使用 Redis 等分布式缓存 |
| 多表查询频繁 | 关闭二级缓存 | 避免脏数据 |
| 单表简单 CRUD | 可开启二级缓存 | 注意 commit 才生效 |
| 需要实时数据 | 一级缓存用 STATEMENT | 每次查数据库 |
5.3 配置建议
<!-- mybatis-config.xml -->
<settings>
<!-- 关闭二级缓存 -->
<setting name="cacheEnabled" value="false"/>
<!-- 一级缓存用 STATEMENT 级别(避免脏数据) -->
<setting name="localCacheScope" value="STATEMENT"/>
</settings>六、核心知识点速记
MyBatis 缓存总结:
┌─────────────────────────────────────────────────────────────┐
│ 只有两级缓存 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 一级缓存(SqlSession 级别) │
│ ├── 默认开启 │
│ ├── 配置:SESSION(共享) / STATEMENT(不共享) │
│ ├── 失效:update、commit、close、clearCache │
│ ├── 实现:PerpetualCache(HashMap) │
│ └── 问题:多 Session 不共享,可能脏数据 │
│ │
│ 二级缓存(namespace 级别) │
│ ├── 需手动开启(cacheEnabled + cache标签) │
│ ├── 生效条件:commit 或 close 后 │
│ ├── 实现:装饰器链(同步、日志、序列化、LRU、基础) │
│ ├── 问题:多表查询脏数据、分布式不共享 │
│ └── 解决:cache-ref 共享缓存 / 自定义 Redis 实现 │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ CacheKey = StatementId + Offset + Limit + SQL + Params │
│ │
│ 查询顺序:二级缓存 → 一级缓存 → 数据库 │
│ │
└─────────────────────────────────────────────────────────────┘常见面试问题
MyBatis 有三级缓存吗?
- 没有,只有两级。一级缓存的 STATEMENT scope 只是一种配置模式。
一级缓存什么时候失效?
- 执行 update/insert/delete、commit、close、clearCache、配置 STATEMENT scope。
二级缓存什么时候生效?
- SqlSession commit 或 close 后才写入缓存。
为什么多表查询会有脏数据?
- 二级缓存基于 namespace,A Mapper 的缓存无法感知 B Mapper 的更新。
生产环境怎么用缓存?
- 关闭 MyBatis 缓存,使用 Redis 等分布式缓存方案。