☰
MyBatis 缓存机制详解
2026/9/25 18:09:45 网站建设 项目流程

MyBatis 缓存机制详解

定位:MyBatis 系列第 4 篇——一级缓存与二级缓存的完整机制、脏读分析与生产实践结论
适用版本:MyBatis 3.5.x(JDK 8+)


目录

  1. 缓存全景
  2. 一级缓存(Local Cache)
  3. 一级缓存脏读分析
  4. 二级缓存(Global Cache)
  5. 缓存执行顺序与装饰结构
  6. 生产实践与外部缓存整合
  7. 总结
  8. 常见高频面试题

一、缓存全景

MyBatis 内建两级缓存,查询时依次经过:

SELECT 查询路径: 二级缓存(namespace 级,跨 SqlSession) │ 未命中 ▼ 一级缓存(SqlSession 级) │ 未命中 ▼ 数据库 │ 结果回填 ▼ 一级缓存立即写入;二级缓存在事务提交后写入
维度一级缓存二级缓存
作用域SqlSession 生命周期namespace(Mapper)级
默认状态开启,无需配置关闭,需显式开启
承载者BaseExecutor.localCacheMappedStatement 绑定的 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><settingname="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 级开启 --><mappernamespace="com.example.mapper.UserMapper"><cacheeviction="LRU"flushInterval="60000"size="512"readOnly="false"/>...</mapper>
// 注解方式@CacheNamespace(eviction=LruCache.class,flushInterval=60000,size=512)publicinterfaceUserMapper{...}

前提:全局cacheEnabled=true(默认即为 true,仅控制是否允许);readOnly=false(默认)时实体类必须实现 Serializable——缓存存取的是序列化副本。

4.2 cache 属性详解

属性默认值说明
evictionLRU淘汰策略:LRU(最近最少使用)/ FIFO / SOFT(软引用)/ WEAK(弱引用)
flushInterval不设置定时全量清空间隔(毫秒);不设置则只靠写操作触发刷新
size512最多缓存多少个「语句结果」条目
readOnlyfalsefalse:返回序列化副本(安全、有开销);true:返回共享引用(快,但调用方修改会污染缓存)

4.3 写入与刷新机制

写入时机——TransactionalCache:二级缓存被TransactionalCache装饰,查询结果先放入事务临时区,事务提交(或会话关闭)后才真正写入缓存——避免未提交数据被其他会话读到。

刷新时机:该 namespace 内任意 C/U/D 语句执行后清空整个 namespace 缓存(写语句flushCache默认为 true)。语句级控制:

<!-- 该查询不使用二级缓存 --><selectid="selectRealtime"useCache="false"...><!-- 该查询执行前强制刷新二级缓存 --><selectid="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(简化)Cachecache=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);// 暂存,提交后生效}returnresult;}returndelegate.query(...);
// BaseExecutor.query(简化):一级缓存List<E>result=(List<E>)localCache.getObject(key);if(result==null){result=queryFromDatabase(...);// 真正执行 SQLlocalCache.putObject(key,result);}returnresult;

5.2 Cache 装饰链

MyBatis 用装饰器模式组合缓存能力,<cache/>的典型装配结果:

TransactionalCache(提交时才写入) └── SynchronizedCache(并发安全) └── LoggingCache(命中率日志) └── SerializedCache(readOnly=false 时的序列化副本) └── LruCache(淘汰策略) └── PerpetualCache(HashMap 基础实现)

各装饰器职责:

装饰器职责
PerpetualCacheHashMap 存取,一切缓存的地基
LruCache / FifoCache / SoftCache / WeakCache淘汰策略
SerializedCache存取时序列化/反序列化,保证返回副本
LoggingCache记录命中率
SynchronizedCachesynchronized 包装
TransactionalCache事务提交前暂存,提交后提交到下层

六、生产实践与外部缓存整合

6.1 实践结论

缓存建议
一级缓存保留默认;长会话 + 高一致性要求的批处理场景评估localCacheScope=STATEMENT
二级缓存生产不开启(不写<cache/>);仅接受 namespace 粒度失效的单机只读场景可例外

6.2 自定义 Cache 对接 Redis?

技术上可以实现org.apache.ibatis.cache.Cache接口把存储指向 Redis:

publicclassRedisCacheimplementsCache{@OverridepublicStringgetId(){returnnamespaceId;}@OverridepublicvoidputObject(Objectkey,Objectvalue){/* redis set */}@OverridepublicObjectgetObject(Objectkey){/* redis get */}@OverridepublicObjectremoveObject(Objectkey){/* redis del */}@Overridepublicvoidclear(){/* 按 namespace 前缀删除 */}// getSize 等省略}

但不推荐,原因:

  1. 失效粒度仍是 namespace,跨表 JOIN 的脏读问题原样存在;
  2. TransactionalCache 提交后写入仍存在「DB 已提交、缓存未写入」的一致性窗口;
  3. 缓存键由 MyBatis 生成(CacheKey),无法按业务语义管理与精准失效。

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. 两级结构:查询顺序为二级缓存(namespace 级)→ 一级缓存(SqlSession 级)→ 数据库;一级默认开启、二级默认关闭。
  2. 一级缓存:PerpetualCache 实现,CacheKey 由 statementId + 分页 + SQL + 参数 + environment 构成;写操作/提交/关闭时整体清空;localCacheScope=STATEMENT可缩小作用域。
  3. 一级缓存的真相:Spring 无事务模式下每次调用独立 SqlSession,一级缓存基本不命中;事务内才真正生效。
  4. 一级缓存脏读:同会话先查后被他事务更新,缓存绕过数据库导致读到旧值;数据库隔离级别无法防御,只能靠缩小作用域或缩短会话生命周期。
  5. 二级缓存机制:TransactionalCache 保证提交后才写入;失效以 namespace 为单位(C/U/D 触发全量清空)。
  6. 二级缓存两大脏读:跨 namespace 的 JOIN 依赖失效盲区;多节点缓存独立无失效广播。因此生产环境不建议开启。
  7. 装饰器架构:Cache 接口经 PerpetualCache → 淘汰策略 → 序列化 → 日志 → 同步 → 事务逐层装饰,是 MyBatis 扩展性设计的范例。
  8. 生产方案:应用层 Spring Cache + Caffeine/Redis,Cache-Aside 失效策略,按业务 key 管理——把分布式一致性问题放在正确的层次解决。

八、常见高频面试题

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 再删缓存。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询