很多同学熟悉 MyBatis 一级缓存、二级缓存基础用法,但多表联查、跨Mapper更新场景下的缓存脏读漏洞,90%的人都会踩坑。

本文结合完整实战案例,用大白话拆解:脏读如何产生、一级缓存+二级缓存双重隐患、Namespace隔离缺陷,最后给出官方解决方案与企业级最佳实践,可直接面试背诵、项目避坑。


一、业务场景前置说明

1. 业务数据表设计

本次案例涉及两张关联表,模拟日常一对多/关联业务场景:

表名 业务作用 初始测试数据
dept 部门基础信息表 部门名:ali、地址:hz
deptNum 部门人数统计表 部门名:ali、员工人数:1050

2. Mapper 与 Namespace 隔离规则

MyBatis 二级缓存核心特性:以 Mapper 命名空间 Namespace 为单位独立隔离,不同 Mapper 缓存相互独立、互不干扰。

- DeptDao.xml:负责多表关联查询,同时查询部门信息+部门人数

- DeptNumDao.xml:负责单表更新,仅修改部门人数字段

核心关键:
联查SQL 在 DeptDao 命名空间;
更新SQL 在 DeptNumDao 命名空间;
两个Mapper 属于不同Namespace。

关联查询 SQL(DeptDao)

select d.deptno,d.dname,d.loc,dn.num 
from dept d, deptNum dn
where dn.name = d.dname and d.dname = #{name}

人数更新 SQL(DeptNumDao)

update deptNum set num = #{num} where name = #{dname}

二、官方必考:MyBatis 完整缓存查询顺序(标准答案)

这是 MyBatis 官方规定的固定查询优先级,死记硬背:

1. 先查询二级缓存(跨SqlSession、Mapper全局缓存)

2. 二级缓存未命中 → 再查询一级缓存(当前SqlSession本地缓存)

3. 一级缓存也未命中 → 最终查询数据库

关键规则:
一级缓存的数据,必须执行 commit / close / flush 操作,才会同步到二级缓存中!
不执行这三个操作,二级缓存永远是空的!

三、完整业务执行流程

步骤1:第一次查询,数据仅存入一级缓存

// 开启第一个SqlSession
SqlSession sqlSession = factory.openSession();
DeptDao deptDao = sqlSession.getMapper(DeptDao.class);
// 关联查询部门+人数数据
DeptVo deptVo = deptDao.selectByDeptVo("ali");
System.out.println("deptVo = " + deptVo);

执行流程:

1. 首次查询,二级缓存为空 → 未命中

2. 查询一级缓存 → 为空 → 未命中

3. 访问数据库,查询结果:ali部门,人数 1050

4. 数据仅存入当前SqlSession一级缓存

5. 致命重点

该SqlSession 全程没有关闭、没有commit提交、没有flush刷新

一级缓存数据永远无法同步到二级缓存

✅ 一级缓存:存有旧数据

❌ 二级缓存:完全为空

步骤2:跨Mapper更新数据库数据

// 开启全新第二个SqlSession
SqlSession sqlSession2 = factory.openSession();
DeptNumDao deptDao2 = sqlSession2.getMapper(DeptNumDao.class);
// 修改ali部门人数为 1000
deptDao2.updateDeptVoNum(new DeptVo("ali",1000));
// 提交事务,数据库数据已真实修改
sqlSession2.commit();
// 关闭当前会话
sqlSession2.close();

执行流程:

1. 执行更新语句,数据库 deptNum 人数永久修改为 1000;

2. 更新操作归属 DeptNumDao 命名空间;

3. MyBatis 只会清空当前Namespace二级缓存

4. 完全不会清理 DeptDao 的缓存数据。

步骤3:二次查询,产生脏读

// 复用【第一个未关闭的SqlSession】再次查询
deptVo = deptDao.selectByDeptVo("ali");
System.out.println("deptVo = " + deptVo);

现象:

数据库人数已经是 1000,但代码查询结果依旧是 1050

明明数据已经更新,却查不到最新结果,这就是典型的缓存脏读


四、深度剖析:脏读产生两大核心原因

原因1:二级缓存为空 + 命中一级缓存(最直接、最核心原因)

结合 官方缓存查询顺序 完整闭环解释:

1. 二次查询先走 二级缓存 → 因为没commit/close,二级缓存一直是空的 → 未命中

2. 按照顺序,继续走 一级缓存 → 缓存里有旧数据(1050)→ 命中

3. 直接返回一级缓存的旧数据,不会查询数据库

4. 日志中 Cache Hit Ratio: 0.0 证明:二级缓存命中率0,本身就无数据

简单总结:
没提交、没关闭 → 数据进不了二级缓存 → 二级缓存空 → 查询走一级缓存 → 脏数据诞生!

原因2:二级缓存 Namespace 隔离缺陷

就算手动关闭第一个SqlSession,把一级缓存刷入二级缓存,脏读依旧存在:

1. 联查结果缓存存放在 DeptDao 二级缓存;

2. 更新操作只清空 DeptNumDao 二级缓存;

3. 跨Namespace 的更新,无法触发其他Mapper缓存清空;

4. 多表联查场景下,不同表操作分散在不同Mapper,缓存完全无法联动。

致命问题:
多表关联业务,一旦增删改和查询不在同一个 Namespace,MyBatis原生二级缓存必然脏读。


五、官方解决方案:cache-ref 共享缓存

1. 标签作用

MyBatis 提供 <cache-ref> 标签,用于多个Mapper共享同一个二级缓存

打破 Namespace 独立隔离限制,让多个命名空间共用一块缓存区域。

2. 使用方式

在需要联动的 Mapper 中,引入对方的缓存命名空间:

<!-- 在 DeptDao.xml 中配置 -->
<cache-ref namespace="com.mybatis.dao.DeptNumDao"/>

含义:

DeptDao 放弃独立二级缓存,直接共用 DeptNumDao 的缓存。

3、解决原理

1. 当 DeptNumDao 执行更新操作时,会清空公共缓存;

2. 关联查询的 DeptDao 共用此缓存,缓存同步失效;

3. 下次查询缓存不存在,自动访问数据库,读取最新数据;

4. 彻底解决跨 Mapper 更新导致的缓存不同步问题。


六、方案优缺点 & 企业级选型

1. cache-ref 方案优缺点

✅ 优点:

- MyBatis 原生自带,无需引入第三方组件;

- 配置简单,少量代码即可解决跨命名空间脏读;

❌ 缺点:

- 只适用于简单关联业务;

- 项目模块复杂、多表错综复杂时,缓存耦合严重,难以维护;

- 依旧无法解决分布式环境下多服务缓存同步问题。

2. 企业级主流最佳实践

绝大多数互联网/企业项目,直接禁用 MyBatis自带二级缓存

1. 原生二级缓存基于本地内存,分布式部署下多服务缓存不互通;

2. Namespace 隔离机制导致联查场景大量脏读;

3. 事务、缓存刷新机制复杂,线上问题难排查;

统一替换方案:
使用 Redis / Redisson 实现分布式业务缓存

- 手动控制缓存存入、过期、主动删除;

- 跨服务、跨模块数据统一缓存管理;

- 灵活性更高、数据一致性更强、线上问题更好排查。


七、核心总结(面试满分版)

1. MyBatis 缓存查询顺序(官方标准答案)
二级缓存 → 一级缓存 → 数据库

2. 缓存同步规则
一级缓存数据,只有执行 commit / close / flush 才会写入二级缓存;

3. 本次脏读根源
SqlSession 未提交/关闭 → 数据只在一级缓存 → 二级缓存为空 → 查询命中一级缓存旧数据;

4. 二级缓存缺陷
基于Namespace隔离,跨Mapper更新无法联动清空缓存;

5. 临时解决方案
使用 <cache-ref> 共享缓存,打破命名空间隔离;

6. 生产环境规范
禁用 MyBatis 原生二级缓存,统一使用 Redis 分布式缓存。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐