缓存读写策略
MyBatis 二级缓存的读写策略决定了缓存数据的访问方式与隔离性。合理配置 readOnly 属性,理解序列化机制,能有效避免并发环境下的脏读、缓存穿透等问题。
readOnly 与读写模式
<cache readOnly="..."/> 配置决定了缓存对象是以引用方式返回还是以拷贝方式返回。
readOnly=true(只读模式)
XML
<cache readOnly="true"/>
只读模式下,MyBatis 直接从缓存中返回对象的引用,不进行序列化拷贝:
Java
SqlSession session1 = factory.openSession();
UserMapper mapper1 = session1.getMapper(UserMapper.class);
User user1 = mapper1.selectById(1);
session1.commit();
session1.close();
SqlSession session2 = factory.openSession();
UserMapper mapper2 = session2.getMapper(UserMapper.class);
User user2 = mapper2.selectById(1); // 命中缓存
// 只读模式下返回同一对象引用
System.out.println(user1 == user2); // true
| 特性 | 说明 |
|---|---|
| 性能 | 高(无序列化开销) |
| 返回值 | 缓存对象的引用 |
| 安全性 | 低(多 Session 可修改同一对象) |
| 线程安全 | 否 |
readOnly=false(默认,读写模式)
XML
<cache readOnly="false"/>
读写模式下,MyBatis 使用序列化机制创建缓存对象的深拷贝,每个 Session 获得独立的对象副本:
Java
SqlSession session1 = factory.openSession();
UserMapper mapper1 = session1.getMapper(UserMapper.class);
User user1 = mapper1.selectById(1);
session1.commit();
session1.close();
SqlSession session2 = factory.openSession();
UserMapper mapper2 = session2.getMapper(UserMapper.class);
User user2 = mapper2.selectById(1); // 命中缓存,获得拷贝
// 读写模式下返回不同的对象副本
System.out.println(user1 == user2); // false
System.out.println(user1.equals(user2)); // true(内容相同)
| 特性 | 说明 |
|---|---|
| 性能 | 低(有序列化与反序列化开销) |
| 返回值 | 缓存对象的深拷贝 |
| 安全性 | 高(各 Session 相互隔离) |
| 线程安全 | 是 |
序列化机制
当 readOnly=false 时,MyBatis 使用序列化来隔离不同 Session 对缓存对象的访问:
Java
// 内部实现逻辑
public Object getObject(Object key) {
Object value = cache.get(key);
if (!readOnly && value != null) {
// 序列化拷贝:深度复制对象
value = serializeCopy(value);
}
return value;
}
private Object serializeCopy(Object obj) {
// 1. 对象序列化
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(obj);
// 2. 反序列化创建新对象
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais);
return ois.readObject();
}
序列化要求
| 要求 | 说明 |
|---|---|
| 实现 Serializable | 缓存对象必须实现 Serializable 接口 |
| 所有属性可序列化 | 对象的每个成员变量都必须可序列化 |
| 定义 serialVersionUID | 推荐定义序列化版本 ID,避免版本升级问题 |
Java
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
private List<String> roles; // 内部集合也必须可序列化
// ...
}
并发问题与脏读
readOnly=true 时的脏读风险
只读模式下,多个 Session 共享同一对象引用,可能导致脏读:
Java
// Session 1 读取缓存对象
SqlSession session1 = factory.openSession();
UserMapper mapper1 = session1.getMapper(UserMapper.class);
User user1 = mapper1.selectById(1); // 从缓存获得引用
session1.close();
// Session 2 读取同一缓存对象
SqlSession session2 = factory.openSession();
UserMapper mapper2 = session2.getMapper(UserMapper.class);
User user2 = mapper2.selectById(1); // 命中缓存,获得同一引用
// Session 2 修改对象属性
user2.setUsername("modified_by_session2");
// 如果 Session 1 再次访问,会看到被修改的值(脏读)
SqlSession session3 = factory.openSession();
UserMapper mapper3 = session3.getMapper(UserMapper.class);
User user3 = mapper3.selectById(1);
System.out.println(user3.getUsername()); // 输出 "modified_by_session2"
readOnly=true 下,缓存对象的修改会影响所有后续读取,这是典型的脏读问题。
readOnly=false 时的隔离性
Java
SqlSession session1 = factory.openSession();
UserMapper mapper1 = session1.getMapper(UserMapper.class);
User user1 = mapper1.selectById(1);
session1.close();
SqlSession session2 = factory.openSession();
UserMapper mapper2 = session2.getMapper(UserMapper.class);
User user2 = mapper2.selectById(1); // 获得拷贝
// Session 2 修改对象
user2.setUsername("modified");
// Session 3 读取,不受 Session 2 影响
SqlSession session3 = factory.openSession();
UserMapper mapper3 = session3.getMapper(UserMapper.class);
User user3 = mapper3.selectById(1);
System.out.println(user3.getUsername()); // 输出原始值,不受修改影响
缓存穿透
什么是缓存穿透
缓存穿透是指查询一个不存在的数据,由于缓存中无法命中,请求会穿透到数据库。如果大量恶意请求都查询不存在的 key,会对数据库造成压力。
Java
// 恶意查询不存在的的数据
for (int i = 0; i < 10000; i++) {
User user = mapper.selectById(-i); // 不存在的数据
// 每次查询都会访问数据库
}
防御策略
1. 缓存空结果
将空结果也缓存起来,避免重复查询:
Java
public User selectByIdWithCache(Long id) {
// 伪代码:先查缓存
User user = cache.get(id);
if (user != null) {
return user; // 命中缓存(包括空结果)
}
// 缓存未命中,查询数据库
user = mapper.selectById(id);
// 将结果(即使是 null)写入缓存
if (user != null) {
cache.put(id, user);
} else {
cache.put(id, NULL_OBJECT); // 缓存空对象
}
return user;
}
2. 使用布隆过滤器
在查询前使用布隆过滤器判断数据是否存在:
Java
// 使用布隆过滤器
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
expectedInsertions,
falsePositiveRate
);
// 查询前检查
if (!bloomFilter.mightContain(id)) {
return null; // 数据一定不存在,直接返回
}
// 可能命中,再查数据库
User user = mapper.selectById(id);
| 方案 | 优点 | 缺点 |
|---|---|---|
| 缓存空结果 | 简单,有效 | 增加缓存内存占用 |
| 布隆过滤器 | 空间效率高 | 有误判率 |
| 参数校验 | 过滤非法请求 | 只能处理明显非法值 |
性能对比
| 场景 | readOnly=true | readOnly=false |
|---|---|---|
| 单线程读取 | 快(无序列化开销) | 慢(有序列化开销) |
| 多线程读取 | 快但可能脏读 | 慢但安全 |
| 对象修改传播 | 会传播到所有 Session | 不会传播 |
| 大对象缓存 | 更高效 | 序列化开销大 |
| 并发安全性 | 差 | 好 |
推荐配置
根据业务场景选择合适的读写策略:
| 场景 | 推荐配置 | 说明 |
|---|---|---|
| 数据只读,不修改 | readOnly="true" | 性能最佳 |
| 多 Session 并发访问 | readOnly="false" | 防止脏读 |
| 缓存大对象 | readOnly="true" + 业务层保证不修改 | 平衡性能与安全 |
| 对一致性要求高 | readOnly="false" | 确保隔离 |
| 低并发环境 | readOnly="true" | 性能优先 |
XML
<!-- 高性能只读缓存 -->
<cache eviction="LRU" size="1024" readOnly="true"/>
<!-- 安全读写缓存 -->
<cache eviction="LRU" size="512" readOnly="false"/>
<!-- 定时刷新的只读缓存 -->
<cache eviction="FIFO" size="256" flushInterval="60000" readOnly="true"/>
注意事项
- readOnly=true 时禁止修改:业务代码绝对不能修改从缓存中取出的对象
- 对象引用传播:只读模式下,一个 Session 修改缓存对象会影响其他 Session
- 序列化性能:大对象在 readOnly=false 下序列化开销很大,可能导致延迟
- 缓存穿透防护:高频查询应考虑缓存空结果或布隆过滤器
- 线程安全:MyBatis 二级缓存默认不是线程安全的,需要外部缓存保证并发安全
要点总结
readOnly=true返回对象引用,性能高但可能脏读;readOnly=false返回深拷贝,安全但有序列化开销- 只读模式下,多个 Session 修改同一缓存对象会导致脏读传播
- 读写模式下通过序列化创建对象副本,保证 Session 间隔离
- 缓存对象必须实现
Serializable接口,所有成员变量也必须可序列化 - 缓存穿透可通过缓存空结果或布隆过滤器防御
- 数据只读、低并发场景推荐 readOnly=true;多 Session 并发、一致性要求高场景推荐 readOnly=false
存放路径:D:\git2\jwdev\articles\MYBATIS\进阶\缓存机制\缓存读写策略.md
📝 发现内容有误?点击此处直接编辑