MyBatis 缓存机制:一级缓存与二级缓存原理详解
MyBatis 一级缓存与二级缓存原理详解
本文梳理 MyBatis 一级缓存、二级缓存的原理、区别与失效场景,并分析缓存选型策略。
目录
一、为什么需要缓存
每次查询都访问数据库会带来网络开销和 IO 开销。对于相同的查询条件,短时间内多次请求的结果往往是一样的,完全可以把第一次的结果缓存起来,后续直接返回,不再查库。
MyBatis 提供两级缓存:
请求进来
↓
查二级缓存(跨 Session 共享)→ 命中 → 直接返回
↓ 未命中
查一级缓存(当前 Session 内)→ 命中 → 直接返回
↓ 未命中
查数据库 → 返回结果 → 存入一级缓存
↓
Session 关闭 → 一级缓存数据转移到二级缓存
二、一级缓存
原理
一级缓存是 SqlSession 内部的一个 HashMap,key 由以下几部分组成:
namespace + statementId + 查询参数 + 分页参数
同一个 Session 内,相同参数的查询会命中缓存,直接返回,不再访问数据库。详细机制可参考 MyBatis 官方文档 — Local Cache。
SqlSession session = factory.openSession();
StudentMapper mapper = session.getMapper(StudentMapper.class);
Student s1 = mapper.findById(1); // 查数据库,存入缓存
Student s2 = mapper.findById(1); // 命中缓存,直接返回
System.out.println(s1 == s2); // true,是同一个对象
失效场景
| 场景 | 原因 |
|---|---|
| 执行了 INSERT / UPDATE / DELETE | MyBatis 主动清空缓存,防止脏读 |
调用 session.clearCache() |
手动清空 |
SqlSession 关闭 |
缓存随 Session 销毁 |
| 查询参数不同 | key 不同,不会命中 |
作用域
一级缓存是 SqlSession 级别的,生命周期与 SqlSession 一致。原因是:
Mapper 代理对象内部持有 SqlSession 的引用,缓存存在 SqlSession 里,Session 关了缓存就没了。
实际价值
在 Web 项目中,每个 HTTP 请求都会新建一个 SqlSession,请求结束 Session 关闭。因此一级缓存只在同一次请求内的多次相同查询中生效,实际收益有限。
三、二级缓存
原理
二级缓存是 namespace 级别(即 Mapper 接口级别)的缓存,挂载在 SqlSessionFactory 下,生命周期与应用一致。
SqlSessionFactory(全局单例)
└── StudentMapper 二级缓存 { id=1→张三, id=2→李四, ... }
├── SqlSession1 关闭后,数据转移到这里
├── SqlSession2 查询时,先来这里找
└── SqlSession3 查询时,先来这里找
多个 SqlSession 共享同一块二级缓存,这就是它能跨请求复用的根本原因。
为什么说是 factory 级别
一级缓存:缓存存在 SqlSession 里 → SqlSession 关了就没了 → SqlSession 级别
二级缓存:缓存挂在 factory 下 → factory 是单例活整个应用 → factory 级别
开启步骤
第一步: sqlMapConfig.xml 开启全局开关(默认已开启)
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
第二步: 在 mapper.xml 中开启该 namespace 的缓存
<mapper namespace="com.demo.mapper.StudentMapper">
<cache/> <!-- 加这一行才生效 -->
...
</mapper>
注解开发则用 @CacheNamespace:
@CacheNamespace
public interface StudentMapper { ... }
第三步: 实体类实现序列化接口
public class Student implements Serializable {
// ...
}
为什么必须序列化
二级缓存可以将数据持久化到磁盘(不只是内存),序列化是将 Java 对象转换为字节流的必要条件。关于 Serializable 接口的详细说明可参考 Java 官方文档 — Serializable。不实现该接口会在运行时抛出异常。
为什么 user1 == user2 返回 false
User user1 = mapper1.findById(1);
session1.close(); // 关闭时,user1 序列化后存入二级缓存
User user2 = mapper2.findById(1); // 从二级缓存取出,反序列化生成新对象
session2.close();
System.out.println(user1 == user2); // false,两个不同的对象地址
System.out.println(user1.equals(user2)); // true,内容相同(需重写 equals)
原因: 二级缓存存储的是序列化后的字节数据,每次取出都会反序列化成一个新对象,内存地址不同,== 自然返回 false。
四、失效场景对比
| 失效原因 | 一级缓存 | 二级缓存 |
|---|---|---|
| 执行 CUD 操作 | ✅ 失效 | ✅ 失效(该 namespace) |
| Session 关闭 | ✅ 失效 | ❌ 不失效,数据转移过去 |
| 外部直接修改数据库 | ✅ 下次 Session 自然感知 | ❌ 不感知,可能脏读 |
| 集群其他实例修改 | - | ❌ 不感知,可能脏读 |
| 缓存到期(flushInterval) | 无此机制 | ✅ 强制过期 |
五、为什么要设置 flushInterval(刷新间隔)
MyBatis 的 CUD 操作只能清除本应用内的缓存,以下场景会产生脏读:
场景1:外部系统直接操作数据库
MyBatis 应用 其他系统/DBA
──────────── ──────────────
查用户,存缓存
直接 UPDATE user SET age=99
再次查用户
→ 缓存命中,返回旧数据 ← 脏读!
场景2:集群部署
实例1 缓存{ id=1 → 张三(age=18) }
实例2 缓存{ id=1 → 张三(age=18) }
实例1 执行 update age=99 → 清空实例1的缓存
实例2 完全不知道 → 缓存还是旧数据 ← 脏读!
flushInterval 的作用是设置一个强制过期时间,不管数据从哪里被修改,到期后缓存自动清空,下次查询强制回库:
<cache flushInterval="60000"/> <!-- 每 60 秒强制刷新 -->
六、一级缓存 vs 二级缓存 总览
| 对比项 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用域 | SqlSession 级别 | namespace(Mapper)级别 |
| 存储位置 | SqlSession 内的 HashMap | SqlSessionFactory 下 |
| 默认状态 | 默认开启,无法关闭 | 需手动开启 |
| 生命周期 | Session 关闭即销毁 | 应用运行期间持续存在 |
| 跨 Session | ❌ 不支持 | ✅ 支持 |
| 序列化要求 | 不需要 | 需要实现 Serializable |
| 脏读风险 | 低(范围小) | 较高(需配合 flushInterval) |
七、实际项目中的缓存策略
MyBatis 的二级缓存在以下场景存在局限:
- 集群部署:各实例缓存相互独立,数据不同步
- 外部修改:无法感知非 MyBatis 渠道的数据变更
- 高并发:缓存粒度粗,命中率有限
因此实际项目中,二级缓存往往被 Redis 替代。Redis 作为独立缓存中间件,天然支持集群共享,且过期策略更灵活,是生产环境的主流选择。可参考:
总结
| 知识点 | 结论 |
|---|---|
| 一级缓存级别 | SqlSession 级别,Mapper 代理对象持有 Session 引用 |
| 二级缓存级别 | factory 级别,factory 单例活整个应用 |
user1 == user2 为 false |
反序列化生成新对象,地址不同 |
| flushInterval 的意义 | 防范外部修改和集群场景的脏读,兜底保险 |
| 生产环境 | 二级缓存局限多,通常用 Redis 替代 |
更多推荐
所有评论(0)