MyBatis 缓存机制详解
定位:MyBatis 系列第 4 篇——一级缓存与二级缓存的完整机制、脏读分析与生产实践结论 适用版本:MyBatis 3.5.x(JDK 8+)
目录
一、缓存全景
MyBatis 内建两级缓存,查询时依次经过:
SELECT 查询路径:
二级缓存(namespace 级,跨 SqlSession)
│ 未命中
▼
一级缓存(SqlSession 级)
│ 未命中
▼
数据库
│ 结果回填
▼
一级缓存立即写入;二级缓存在事务提交后写入
| 作用域 | SqlSession 生命周期 | namespace(Mapper)级 |
| 默认状态 | 开启,无需配置 | 关闭,需显式开启 |
| 承载者 | BaseExecutor.localCache | MappedStatement 绑定的 Cache + CachingExecutor |
| 跨会话 | 否 | 是 |
| 关闭方式 | 只能缩小作用域(localCacheScope) | 不配置 <cache/> 即关闭 |
缓存键与值:键是 CacheKey(见 2.2),值是查询结果对象列表;底层都是 PerpetualCache(HashMap 包装),二级缓存额外叠加淘汰策略等装饰器(见第五节)。
二、一级缓存(Local Cache)
2.1 基本特征
- 实现:BaseExecutor 内的 localCache 字段(PerpetualCache),查询入口 BaseExecutor.query 先查缓存。
- 默认开启,没有配置项可以整体关闭——只能通过 localCacheScope=STATEMENT 把作用域缩小到语句级。
- 同一次查询(相同缓存键)在同一会话内重复执行,第二次直接返回缓存结果,不再访问数据库。
2.2 CacheKey 构成
CacheKey = statementId(namespace.方法名)
+ RowBounds(offset / limit)
+ BoundSql 的 SQL 文本
+ 各参数值(按 parameterMappings 顺序)
+ environment id
任一要素不同即视为不同查询。这解释了:同一方法不同参数不会互相污染缓存;而动态 SQL 拼出的不同 SQL 文本也会产生不同缓存键。
2.3 失效时机
以下任一情况发生后,会话的一级缓存被清空:
| insert / update / delete 执行后 | 同会话任意写语句(无论是否影响缓存中的数据) |
| commit / rollback | 事务边界 |
| close | 会话关闭 |
| flushCache="true" | 语句级强制刷新(查询语句也可设置) |
| session.clearCache() | 手动清空(不清理二级缓存) |
注意:写操作清空的是整个会话缓存,不是精准失效某条缓存——MyBatis 不做依赖分析(不知道哪条查询依赖了被更新的表)。
2.4 localCacheScope
<settings>
<setting name="localCacheScope" value="SESSION"/> <!– 默认:会话级 –>
<!– <setting name="localCacheScope" value="STATEMENT"/> –>
</settings>
STATEMENT 模式下每条语句执行完即清空一级缓存,等价于放弃会话内重复查询优化,用于规避特定脏读场景(见第三节)。
2.5 Spring 整合下的实际表现(重要)
SqlSessionTemplate 在无事务场景下,每次 Mapper 方法调用都创建独立 SqlSession、执行完即关闭——因此一级缓存在两次独立调用之间基本不会命中。只有在同一 @Transactional 方法内,事务同步机制让多次调用共享同一 SqlSession,一级缓存才真正生效。
推论:很多团队「一级缓存没起作用」的困惑,根源就是无事务调用模式;这不是 Bug,是 SqlSessionTemplate 的生命周期设计。
三、一级缓存脏读分析
3.1 场景还原(教学示例)
时间轴:
t1 会话A:SELECT * FROM user WHERE id = 1 → name='张三',写入 A 的一级缓存
t2 会话B:UPDATE user SET name='李四' WHERE id = 1;提交
t3 会话A:SELECT * FROM user WHERE id = 1 → 命中一级缓存,返回'张三'(脏读)
t4 会话A:任意写操作/commit/close → 缓存清空,之后查询才能读到'李四'
3.2 窗口与本质
- 窗口期:A 首次查询之后,到 A 会话内发生任何写操作/关闭之前。
- 本质:缓存读取绕过了数据库,数据库隔离级别(即使 SERIALIZABLE)也管不到应用进程内的 HashMap。
- 影响放大:长会话(如批处理任务中一个 SqlSession 处理整个批次)窗口更大;分布式多节点下各节点的会话缓存互不感知。
3.3 缓解手段
| localCacheScope=STATEMENT | 放弃会话内重复查询优化 |
| 缩短 SqlSession 生命周期 | Spring 无事务模式天然如此 |
| 关键业务读后校验 / flushCache="true" | 语句级精准控制,增加 DB 压力 |
四、二级缓存(Global Cache)
4.1 开启方式
<!– Mapper XML:namespace 级开启 –>
<mapper namespace="com.example.mapper.UserMapper">
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="false"/>
…
</mapper>
// 注解方式
@CacheNamespace(eviction = LruCache.class, flushInterval = 60000, size = 512)
public interface UserMapper { ... }
前提:全局 cacheEnabled=true(默认即为 true,仅控制是否允许);readOnly=false(默认)时实体类必须实现 Serializable——缓存存取的是序列化副本。
4.2 cache 属性详解
| eviction | LRU | 淘汰策略:LRU(最近最少使用)/ FIFO / SOFT(软引用)/ WEAK(弱引用) |
| flushInterval | 不设置 | 定时全量清空间隔(毫秒);不设置则只靠写操作触发刷新 |
| size | 512 | 最多缓存多少个「语句结果」条目 |
| readOnly | false | false:返回序列化副本(安全、有开销);true:返回共享引用(快,但调用方修改会污染缓存) |
4.3 写入与刷新机制
写入时机——TransactionalCache:二级缓存被 TransactionalCache 装饰,查询结果先放入事务临时区,事务提交(或会话关闭)后才真正写入缓存——避免未提交数据被其他会话读到。
刷新时机:该 namespace 内任意 C/U/D 语句执行后清空整个 namespace 缓存(写语句 flushCache 默认为 true)。语句级控制:
<!– 该查询不使用二级缓存 –>
<select id="selectRealtime" useCache="false" …>
<!– 该查询执行前强制刷新二级缓存 –>
<select id="selectFresh" flushCache="true" …>
4.4 两类经典脏读
脏读一:跨 namespace
场景(教学示例):
OrderMapper.selectOrderWithUser:JOIN user 表,结果进入 OrderMapper namespace 缓存
UserMapper.updateName:更新 user 表 → 只刷新 UserMapper namespace 缓存
→ OrderMapper 的 JOIN 缓存仍旧有效,返回过期的用户名
MyBatis 不追踪 SQL 依赖了哪些表,缓存失效以 namespace 为单位,跨 namespace 的表依赖必然出现失效盲区。
脏读二:多节点
集群部署:
节点A、节点B 各自持有本地二级缓存(JVM 内存 HashMap)
节点A 执行更新 → 只刷新节点A 自己的缓存
→ 流量打到节点B 时持续读到旧值,且无失效广播
4.5 结论
二级缓存的设计目标是「单 namespace 只读为主的数据加速」,其失效粒度(namespace)与部署形态(单机 JVM)都难以匹配现代微服务场景。生产环境一般不开启二级缓存,缓存需求交给应用层外部缓存(见第六节)。
五、缓存执行顺序与装饰结构
5.1 查询完整路径
// CachingExecutor.query(简化)
Cache cache = ms.getCache(); // 该语句所属 namespace 的二级缓存
if (cache != null && ms.isUseCache()) {
List<E> result = (List<E>) tcm.getObject(cache, key); // 查二级缓存
if (result == null) {
result = delegate.query(ms, param, rowBounds, handler, key, boundSql);
// ↑ 委托被装饰 Executor(BaseExecutor 内部再查一级缓存)
tcm.putObject(cache, key, result); // 暂存,提交后生效
}
return result;
}
return delegate.query(...);
// BaseExecutor.query(简化):一级缓存
List<E> result = (List<E>) localCache.getObject(key);
if (result == null) {
result = queryFromDatabase(...); // 真正执行 SQL
localCache.putObject(key, result);
}
return result;
5.2 Cache 装饰链
MyBatis 用装饰器模式组合缓存能力,<cache/> 的典型装配结果:
TransactionalCache(提交时才写入)
└── SynchronizedCache(并发安全)
└── LoggingCache(命中率日志)
└── SerializedCache(readOnly=false 时的序列化副本)
└── LruCache(淘汰策略)
└── PerpetualCache(HashMap 基础实现)
各装饰器职责:
| PerpetualCache | HashMap 存取,一切缓存的地基 |
| LruCache / FifoCache / SoftCache / WeakCache | 淘汰策略 |
| SerializedCache | 存取时序列化/反序列化,保证返回副本 |
| LoggingCache | 记录命中率 |
| SynchronizedCache | synchronized 包装 |
| TransactionalCache | 事务提交前暂存,提交后提交到下层 |
六、生产实践与外部缓存整合
6.1 实践结论
| 一级缓存 | 保留默认;长会话 + 高一致性要求的批处理场景评估 localCacheScope=STATEMENT |
| 二级缓存 | 生产不开启(不写 <cache/>);仅接受 namespace 粒度失效的单机只读场景可例外 |
6.2 自定义 Cache 对接 Redis?
技术上可以实现 org.apache.ibatis.cache.Cache 接口把存储指向 Redis:
public class RedisCache implements Cache {
@Override public String getId() { return namespaceId; }
@Override public void putObject(Object key, Object value) { /* redis set */ }
@Override public Object getObject(Object key) { /* redis get */ }
@Override public Object removeObject(Object key) { /* redis del */ }
@Override public void clear() { /* 按 namespace 前缀删除 */ }
// getSize 等省略
}
但不推荐,原因:
6.3 推荐模式:应用层缓存
读路径:应用代码 → Spring Cache(Caffeine/Redis)→ 未命中 → MyBatis 查询 → 回填缓存
写路径:更新数据库 → 删除对应缓存 key(Cache-Aside)
要点:
- 单机高频读用 Caffeine(进程内、纳秒级);跨节点共享用 Redis;
- 失效策略首选「先更新 DB,再删缓存」,而非更新缓存(避免并发写覆盖与无效计算);
- 缓存 key 按业务语义设计(如 user:profile:{id}),粒度可控;
- 与 MyBatis 解耦:缓存逻辑在 Service 层,切换 ORM 框架不受影响。
6.4 认知分层
ORM 内建缓存解决的是「会话内 / 命名空间内」的重复读优化,不解决分布式一致性。分布式场景的一致性要靠外部缓存 + 明确的失效协议(删缓存、延迟双删、binlog 订阅等)来保证——这是缓存选型的基本分界线。
七、总结
八、常见高频面试题
1. MyBatis 一级缓存和二级缓存的区别?
要点:一级缓存 SqlSession 级、默认开启、PerpetualCache(HashMap)实现、写操作/提交/关闭即清空;二级缓存 namespace 级、需 <cache/> 显式开启、跨 SqlSession 生效、TransactionalCache 保证提交后写入、C/U/D 后按 namespace 全量清空。查询顺序:二级 → 一级 → DB。
2. 一级缓存的缓存键包含哪些内容?为什么这样设计?
要点:statementId + RowBounds + SQL 文本 + 参数值 + environment id。设计意图:同方法不同参数隔离、动态 SQL 不同结构隔离、分页不同区间隔离;任一要素不同即不同缓存条目。
3. 一级缓存有什么脏读风险?如何规避?
要点:会话 A 查询后,数据被他事务/会话更新并提交,A 再次查询命中缓存返回旧值——缓存绕过数据库,隔离级别防不住。规避:localCacheScope=STATEMENT、缩短会话生命周期(Spring 无事务模式天然如此)、关键语句 flushCache=“true”。
4. 为什么生产环境不建议开启 MyBatis 二级缓存?
要点:① 失效粒度是 namespace,多表 JOIN 的查询缓存不会因关联表被他 namespace 更新而失效(跨 namespace 脏读);② 缓存是 JVM 本地的,多节点无失效广播;③ 默认序列化副本有开销。替代方案:应用层 Caffeine/Redis + Cache-Aside。
5. 二级缓存的写入时机?为什么要这样设计?
要点:事务提交(或会话关闭)后由 TransactionalCache 提交写入。目的:防止未提交数据进入缓存被其他会话读到(脏数据入缓存)。
6. Spring 整合后一级缓存还有效吗?
要点:取决于事务。无事务时 SqlSessionTemplate 每次调用创建独立 SqlSession,会话间不共享一级缓存,基本不命中;同一 @Transactional 内共享 SqlSession,一级缓存生效,且事务内写操作会清空缓存。
7. readOnly 属性的作用?true 和 false 的区别?
要点:false(默认)存取序列化副本,各调用方拿到独立对象,安全但有性能开销,实体需 Serializable;true 直接返回缓存中对象引用,性能好,但调用方修改返回值会污染缓存,只适合严格只读的数据。
8. MyBatis 缓存与 Redis 缓存如何取舍?
要点:MyBatis 内建缓存是会话/namespace 粒度的进程内优化,解决重复读,不解决分布式一致性;Redis 是分布式共享缓存,可按业务 key 精准失效、支持集群。生产做法:关闭二级缓存,用 Spring Cache 抽象统一接入 Caffeine(单机热点)或 Redis(共享),写操作采用先更新 DB 再删缓存。

网硕互联帮助中心



评论前必须登录!
注册