MyBatis 一级/二级缓存实战:坑在哪、什么场景必须关掉
引言
周五晚上 21:30,客服群里涌入一批截图:
"我明明把收货地址改了,下单页面还是旧地址!"
"商品价格后台已经调成 79 了,用户下单还是按 99 扣的款!"
排查了两个多小时,DB 里的数据是对的,Redis 里的数据是对的,接口日志也没报错——但部分用户看到的就是旧数据。最后定位到的"凶手"让所有人沉默:订单服务的 OrderMapper.xml 里,三年前有人加了一行 <cache/>,二级缓存一直开着。
事故链条清晰得可笑:
- 订单查询 SQL 关联了
user_address和product两张表,结果缓存在OrderMapper的命名空间下; - 地址服务、商品服务各自更新自己的表,走的是
AddressMapper、ProductMapper——它们的更新只清自己命名空间的缓存,根本不知道 OrderMapper 里还躺着关联查询的旧结果; - 用户改完地址,订单页查到的还是缓存里的旧快照,直到缓存过期(默认永不过期)或订单表自己有更新才"自愈"。
更扎心的是复盘时发现:这个缓存的命中率只有 11%,却制造了一起脏读事故。 性能收益约等于零,正确性负债拉满。
这不是个例。MyBatis 的两级缓存是 ORM 框架里设计最"时代局限"的功能之一——它诞生于单体应用 + 单库单表的年代,放到今天的多表关联、微服务、多实例部署里,几乎每个默认行为都是坑。先放结论总表:
| 维度 | 一级缓存(SqlSession 级) | 二级缓存(Mapper 级) |
|---|---|---|
| 默认状态 | 默认开启,关不干净 | 默认关闭,需手动开 |
| 作用范围 | 同一个 SqlSession | 同一个命名空间,跨 SqlSession |
| 数据结构 | PerpetualCache(一个 HashMap) | PerpetualCache + 装饰器链 |
| 主要坑 | 事务内重复查询拿到旧快照 | 多表关联脏读、多实例不一致 |
| 收益 | 几乎为零(见 2.5) | 单表高频读场景勉强可用 |
| 我们的建议 | 调成 STATEMENT 级 | 写多读少、有跨表关联 → 果断关掉,改用 Redis |
一句话破题:MyBatis 缓存的本质是"用正确性换性能"的设计,而它换来的性能微乎其微(一级缓存在 Spring 管理下几乎不生效),换走的正确性却足以制造脏读事故——现代应用的缓存应该放在 Redis 这一层,用显式的过期和失效策略管理,而不是藏在 ORM 框架的命名空间里。
一、先建立正确的心智模型:缓存 key 长什么样
不理解 MyBatis 缓存的 key 是怎么算的,后面所有的坑都解释不通。
1.1 一次查询的缓存判定
MyBatis 把一条查询请求抽象成 CacheKey,由五部分拼成:
CacheKey =
Statement ID(全限定名,如 com.x.OrderMapper.selectById)
+ 分页参数(RowBounds 的 offset/limit)
+ 完整 SQL 文本(<if> 动态拼完之后的那条)
+ 参数值列表
+ 环境 ID(Environment,多数据源时区分)
五个要素任何一个不同,就是另一个缓存条目。 这意味着:
selectById(1)和selectById(2)是两个条目(废话,但决定了缓存粒度极碎)- 同一条 SQL,
<if>条件不同拼出的 SQL 不同 → 缓存不共享 - 分页参数不同 → 缓存不共享
所以 MyBatis 缓存的粒度是"一模一样的查询调用",不是"这张表的数据"。记住这一点,第二章的"一级缓存几乎没用"和第三章的"命中率 11%"都从这里来。
1.2 两级缓存的位置
一次查询的查找顺序:
Executor(执行器)
│
├──▶ 二级缓存(CachingExecutor,包装在最外层)
│ 作用域:Mapper 命名空间
│ 命中 → 直接返回(跨 SqlSession 共享)
│ 未命中 ↓
│
└──▶ 一级缓存(BaseExecutor.localCache)
作用域:当前 SqlSession
命中 → 返回
未命中 ↓
真正执行 SQL → 结果同时写回一级、二级缓存
注意这个顺序:二级在一级外面。一个查询先查跨会话共享的二级缓存,再查会话私有的一级缓存,最后才打 DB。这个顺序本身没问题,问题出在"谁来保证缓存里的数据和 DB 一致"——答案是:基本没人保证。
二、一级缓存:默认开启,但几乎没发挥作用
2.1 实现:一个朴素到简陋的 HashMap
一级缓存的实现在 BaseExecutor 里,核心就是一个 PerpetualCache——名字唬人,本质是对 HashMap 包了一层:
// org.apache.ibatis.cache.impl.PerpetualCache(删减版)
public class PerpetualCache implements Cache {
private final Map<CacheKey, Object> cache = new HashMap<>(); // 就这
public void putObject(CacheKey key, Object value) {
cache.put(key, value);
}
public Object getObject(CacheKey key) {
return cache.get(key);
}
}
没有 LRU、没有大小上限、没有过期时间、没有并发控制(靠外层 SqlSession 单线程使用天然规避)。这决定了它只能是一个"会话内的临时口袋",扛不起任何正经缓存的职责。
2.2 生效边界:Spring 管理下,它的作用域是"事务"
原生 MyBatis 里,一级缓存跟着 SqlSession 走。但接入 Spring(mybatis-spring)之后,规则变了:
| 场景 | SqlSession 生命周期 | 一级缓存实际生效范围 |
|---|---|---|
方法在 @Transactional 事务内 | 整个事务共用一个 SqlSession | 整个事务 |
| 方法不在事务内 | 每次 Mapper 调用新建一个 SqlSession | 单次查询——等于没有缓存 |
第二条是绝大多数团队不知道的事实:非事务方法里,同一个 Service 方法连续调两次 selectById(1),照样打两次 DB,因为每次调用都是新的 SqlSession,新的一级缓存。
2.3 坑:事务内拿到旧快照
一级缓存唯一真正"生效"的场景(事务内),恰好是它唯一真正咬人的场景:
@Transactional
public void transfer(Long orderId) {
Order order = orderMapper.selectById(orderId); // ① 查:status = PENDING
// ... 中间调用了另一个服务,该服务把订单状态改成了 PAID(独立事务,已提交)
Order again = orderMapper.selectById(orderId); // ② 再查:还是 PENDING!
// ② 命中一级缓存,拿到的是 ① 的旧快照,DB 里的新值被挡住了
if ("PENDING".equals(again.getStatus())) {
// 基于过期状态做的业务判断 → 错
}
}
同一个事务里,第一次查询的结果被缓存,期间外部对同一行的任何修改都不可见。
要公平地说一句:这在"事务隔离 + 可重复读"语义下不一定是 bug——MySQL RR 隔离级别下,事务内本来就该看到一致的快照。真正的坑是语义错位:
- 开发者以为第二次查询能"看到最新数据"(缓存语义)
- 数据库层面 RR 保证看到一致快照(事务语义)
- 一级缓存把这两者搅在一起:你想要的和你拿到的,取决于你开没开事务、查询了几次、中间有没有别的更新——不可预测就是最大的坑
尤其危险的是事务内"先查再改"的场景:基于 ① 的旧值做判断去 UPDATE,和基于最新值判断,结果可能完全不同。这类 bug 的排查难度和《Transactional 失效的 8 个场景》里讲的自调用失效是一个级别——代码看起来完全没问题。
2.4 关闭与收敛
一级缓存没法完全关掉,只能从 SESSION 降级到 STATEMENT:
# application.yml:一级缓存降级为语句级——每条 SQL 执行完就清空
mybatis:
configuration:
local-cache-scope: STATEMENT # 默认 SESSION
<!-- 也可以在单条语句上精细控制 -->
<select id="selectById" flushCache="true" useCache="false">
SELECT * FROM orders WHERE id = #{id}
</select>
| 配置 | 作用域 | 效果 |
|---|---|---|
localCacheScope=SESSION(默认) | 事务内共享 | 有旧快照风险 |
localCacheScope=STATEMENT | 单条语句 | 推荐,事务内重复查询也打 DB,语义可预测 |
flushCache="true"(语句级) | 执行后清空一级缓存 | 个别查询需要最新数据时用 |
useCache="false"(语句级) | 该查询不写一级缓存 | 同上 |
STATEMENT 级的代价几乎为零——反正非事务场景本来就等于没有;换来的是事务内语义的可预测性。这是本文第一个可以直接抄的配置。
2.5 结论
一级缓存是一个"名义上存在"的缓存:非事务场景不生效(每次新 SqlSession),事务场景生效但带来快照错位风险。它的存在对性能的贡献趋近于零,对正确性的威胁真实存在。结论:全局调成 STATEMENT,忘掉它的存在。
三、二级缓存:真正的脏读制造机
3.1 开启方式与真实结构
二级缓存默认关闭,开启只要一行:
<!-- OrderMapper.xml -->
<mapper namespace="com.example.OrderMapper">
<cache/> <!-- 就这一行,事故的起点 -->
这一行背后是一条装饰器链:
CachingExecutor
└── TransactionalCacheManager ← 事务未提交时先存暂存区
└── LruCache ← LRU 淘汰(默认 1024 条)
└── SerializedCache ← 默认开启:存取都做序列化深拷贝!
└── PerpetualCache(HashMap)
两个默认行为必须知道:
- SerializedCache 默认在链上——缓存命中时返回的不是原对象,而是反序列化出来的深拷贝。每次命中都付一次序列化 + 反序列化的 CPU 税(3.4 节有实测)。
- TransactionalCacheManager 的存在——查询结果在事务提交后才真正写入共享缓存,避免脏数据被其他会话读到。这是二级缓存身上唯一一个"认真考虑过正确性"的设计。
3.2 核心坑:命名空间边界 ≠ 数据边界
二级缓存的清空规则简单粗暴:该命名空间下任何 INSERT/UPDATE/DELETE 执行后,清空这个命名空间的全部缓存。
问题在于:缓存按命名空间隔离,数据表可不按命名空间隔离。
OrderMapper.xml(namespace = OrderMapper)
├── selectOrderDetail:SELECT ... FROM orders o
│ JOIN user_address a ON ...
│ JOIN product p ON ... ← 结果依赖三张表
└── <cache/> 缓存了这个三表联查的结果
AddressMapper.xml(namespace = AddressMapper)
└── updateAddress:UPDATE user_address SET ... ← 只清 AddressMapper 的缓存
结果:user_address 变了,OrderMapper 里的联查结果纹丝不动 → 脏读
这就是引言事故的全部根因。 缓存的正确性要求"数据变了缓存就失效",而 MyBatis 只能感知"这个命名空间下的 SQL 执行了",感知不到"这张表被别的 Mapper、别的服务、甚至别的系统改了"。在单体单 Mapper 的年代这个假设勉强成立,在今天:
- 多张表 JOIN 的查询 → 缓存正确性取决于所有相关表的写入都经过同一个命名空间——几乎不可能保证
- 微服务拆分后,同一张表被多个服务写 → 本服务的缓存对外服务的写入完全无感知
- 多实例部署 → 每个实例一份本地二级缓存,A 实例更新了,B 实例的缓存还在
三条路全是死路。
3.3 第二个坑:多实例部署下的"薛定谔的数据"
二级缓存默认实现是JVM 进程内的 HashMap,不是共享缓存。两个实例的服务:
实例 A:执行 update → 清掉 A 自己的二级缓存
实例 B:不知道发生了什么,缓存照旧
用户请求被 LB 分到 A → 新数据
刷新一下被分到 B → 旧数据
"刷新一下数据就变"这个诡异现象,是二级缓存生产事故的第二大经典症状(第一是联查脏读)。官方文档自己也都承认:默认实现不适合分布式环境。
有人会用 <cache type="org.mybatis.caches.redis.RedisCache"/> 把二级缓存存到 Redis——解决了一致性,没解决失效逻辑:联查结果存在 Redis 里,别的命名空间更新时照样不会清它。脏读原封不动,只是从单机脏读变成了集群一致地脏读。
3.4 第三个隐藏成本:序列化税
SerializedCache 在链上意味着每次缓存读写都要过一遍 Java 序列化。用 JMH 测一个量级(1KB 的订单 DTO):
| 操作 | 耗时(量级) | 说明 |
|---|---|---|
| 直接返回对象引用 | ~0.001ms | 假如不序列化 |
| 序列化 + 反序列化(每次命中) | ~0.05ms | 每次命中都付 |
| 一次简单主键查询(走 DB,有索引 + 连接池) | ~0.3-1ms | 参照系 |
也就是说二级缓存命中大约比走 DB 快一个数量级——但别忘了对照组:Redis GET 同量级对象(含网络)也在 0.3-1ms。再除以 1.1 的命中率……收益覆盖率算下来,二级缓存在多数业务里是负资产。
3.5 它到底什么时候能用
公平起见,二级缓存不是全无价值。同时满足以下四个条件的场景,它可以活:
- 单表单 Mapper:查询不 JOIN 别的表,这张表的所有写操作都在这一个命名空间里
- 读极多写极少:字典表、行政区域、类目树这种一天改不了一次的
- 单实例部署,或能接受实例间短暂不一致
- 对象可正确序列化
典型的合格候选人:省市县字典表、系统参数表。典型的不合格候选人:一切有联查的业务查询——而这恰恰是大家最想加缓存的地方,讽刺拉满。
3.6 结论
二级缓存的问题不是"实现有 bug",而是架构假设过时:它假设"命名空间边界 = 数据边界 = 应用边界",这个等式在现代系统里三段都不成立。写多读少、有跨表关联的场景,果断关掉,改用 Redis。
四、一条查询的一生:两级缓存联动全景
把前两章的机制放回同一条查询的完整生命周期,看正确性是在哪一步丢的:
【查询】
请求 → CachingExecutor
├── 二级缓存命中? → 返回(反序列化深拷贝) ← 脏读风险点 A:联查旧快照
└── 未命中 → BaseExecutor
├── 一级缓存命中? → 返回 ← 快照错位点 B:事务内旧值
└── 未命中 → 查 DB
├── 写一级缓存(当前 SqlSession)
└── 写二级暂存区(事务提交后才生效)
【更新】
同一命名空间 UPDATE/INSERT/DELETE
├── 清空本 SqlSession 一级缓存
└── 事务提交后:清空本命名空间二级缓存 ← 注意:只清"本命名空间"
别的 Mapper 里的联查结果不动
← 脏读风险点 A 的根源
正确性丢失对照表——这是回答"缓存为什么会脏"的完整清单:
| 丢失点 | 机制 | 触发条件 | 后果 |
|---|---|---|---|
| A. 联查旧快照 | 二级缓存按命名空间隔离清空 | 联查涉及的表被别的 Mapper 更新 | 脏读 |
| A'. 跨实例不一致 | 二级缓存是进程内 HashMap | 多实例部署,LB 分发 | 数据忽新忽旧 |
| A''. 跨服务不一致 | 缓存感知不到其他服务的写入 | 微服务共库/同事务外更新 | 脏读 |
| B. 事务内快照错位 | 一级缓存事务内共享 | 事务内重复查询,期间外部有更新 | 基于旧值做决策 |
| C. 序列化异常 | SerializedCache 深拷贝 | DTO 未实现 Serializable / 有不可序列化字段 | 启动或运行期异常 |
五、实测:二级缓存的收益到底有多大
5.1 测试场景
线上一个典型订单详情查询(三表联查,平均 1.2KB 结果集),分别测四种姿势的单线程耗时:
| 方案 | 平均耗时 | 说明 |
|---|---|---|
| 直连 DB(索引覆盖,连接池复用) | 0.8ms | 基线 |
| 二级缓存命中(含序列化) | 0.06ms | 快 13 倍,但每次命中付序列化税 |
| Redis 缓存命中(lettuce,同机房) | 0.5ms | 比 DB 略快,比二级缓存慢 |
| 多级缓存(Caffeine + Redis 兜底) | 0.02ms / 0.5ms | 一级本地命中最快 |
5.2 关键结论:单看延迟会误判
单看延迟,二级缓存完胜。但引入命中率之后,故事完全变了。缓存方案的期望延迟:
期望延迟 = 命中率 × 命中延迟 + (1 − 命中率) × 回源延迟
二级缓存(命中率 11%):0.11 × 0.06 + 0.89 × 0.8 ≈ 0.72ms
Redis(命中率 85%) :0.85 × 0.5 + 0.15 × 0.8 ≈ 0.55ms
为什么 Redis 命中率能做到 85% 而二级缓存只有 11%?因为:
- 二级缓存的 key 是"完整 SQL + 参数 + 分页",粒度极碎,同一个订单换个查询写法就不命中
- 二级缓存"任何写操作清空整个命名空间",一张表的写流量稍大,缓存就被反复清空
- Redis 方案的 key 是业务主键(
order:{id}),失效靠"更新时主动删除这个 key",精准且粒度细
缓存的收益 = 命中率 × 单次收益,不是单次收益。 二级缓存输在命中率这个乘数上,而且输得没有悬念。
5.3 结论
二级缓存唯一的赢面是"命中的那一跳足够快",但命中率被 key 粒度和粗暴的清空策略双重压制。同样的缓存预算,给 Redis 的回报高出一个量级——还不算 Redis 方案天然解决了跨实例、跨服务一致性。
六、怎么关,以及关掉之后怎么办
6.1 三级关闭配置(从粗到细)
# ① 全局关闭二级缓存(总闸,推荐)
mybatis:
configuration:
cache-enabled: false # 关掉后所有 <cache/> 全部失效
local-cache-scope: STATEMENT # 顺手把一级缓存降级(第二章)
<!-- ② 命名空间级:删掉或注释掉这行 -->
<!-- <cache/> -->
<!-- ③ 语句级:某条查询单独不缓存 -->
<select id="selectOrderDetail" useCache="false" flushCache="true">
SELECT ...
</select>
三个层级的语义:
| 层级 | 配置 | 影响面 |
|---|---|---|
| 全局 | cache-enabled: false | 所有命名空间,存量 `` 一行不用删 |
| 命名空间 | 删除 `` | 单个 Mapper |
| 语句 | useCache="false" | 单条查询不进二级缓存 |
存量项目治理建议:先全局关(一行配置,风险最小),观察一周无性能回退,再逐步清理各 Mapper 里的 <cache/> 声明。 反过来先删 XML 再动全局配置,等于把风险拆成了几十次。
6.2 替代方案:Redis 显式缓存
关掉之后的标准替代是"业务主键为 key + 更新时主动失效"的 Cache-Aside 模式,Spring 下用注解就够了:
@Cacheable(value = "order", key = "#id") // 读:命中直接返回
public Order getOrder(Long id) {
return orderMapper.selectById(id);
}
@CacheEvict(value = "order", key = "#order.id") // 写:主动删除这个 key
public void updateOrder(Order order) {
orderMapper.updateById(order);
// 联查涉及的维度更新时,也要精确驱逐对应的缓存 key
}
与 MyBatis 二级缓存的本质差异:
| 维度 | 二级缓存 | Redis Cache-Aside |
|---|---|---|
| key 粒度 | 完整 SQL + 参数(碎) | 业务主键(准) |
| 失效方式 | 命名空间有任何写 → 全清(粗) | 精确删除受影响 key(细) |
| 跨实例 | ❌ 进程内 | ✅ 天然共享 |
| 跨服务 | ❌ | ✅ |
| 过期策略 | 默认无(依赖装饰器) | TTL + 主动失效 |
| 命中率 | 低(11% 实测) | 高(85% 实测) |
联查场景的处理:不为联查结果建缓存,而是给每张维表单独建缓存,在应用层拼装(查订单 → 缓存取地址 → 缓存取商品)。哪个维度更新就精确驱逐哪个 key,正确性和命中率两头都占。热点维度还可以在前面叠一层 Caffeine 做多级缓存,套路见《本地缓存三巨头》第九、十一章。
6.3 灰度姿势
关缓存是"去掉一个优化",理论上只会变慢不会出错——但如果某个隐藏的热点查询一直靠它续命,关掉瞬间 DB 会挨一刀。安全姿势:
- 关之前先加监控(第七章),跑一周看每个命名空间的真实命中率
- 命中率 >30% 的命名空间,先迁 Redis 再关,别裸关
- 全局限流降级预案:备好 DB 连接池和慢查询告警的观察面板
- 低峰期操作,观察 24 小时慢查询数与 DB CPU
七、命中率监控:没有数据的缓存决策都是赌博
7.1 MyBatis 原生:没有指标,自己包一层
MyBatis 的 Cache 是接口,统计命中率最干净的办法是写一个装饰器实现:
public class MeteredCache implements Cache {
private final Cache delegate;
private final MeterRegistry registry;
private final String namespace;
public MeteredCache(Cache delegate, MeterRegistry registry) {
this.delegate = delegate;
this.registry = registry;
this.namespace = delegate.getId();
}
@Override
public Object getObject(Object key) {
Object result = delegate.getObject(key);
// 命中/未命中分别计数,Grafana 里一除就是命中率
registry.counter("mybatis.cache",
"namespace", namespace,
"result", result != null ? "hit" : "miss")
.increment();
return result;
}
// getId/putObject/removeObject/clear/getSize 全部 delegate 转发,略
@Override public String getId() { return delegate.getId(); }
@Override public void putObject(Object k, Object v) { delegate.putObject(k, v); }
@Override public Object removeObject(Object k) { return delegate.removeObject(k); }
@Override public void clear() { delegate.clear(); }
@Override public int getSize() { return delegate.getSize(); }
}
<!-- Mapper 里换上包装实现 -->
<cache type="com.example.mybatis.MeteredCache"/>
7.2 该看什么指标
| 指标 | 计算 | 决策用途 |
|---|---|---|
| 命名空间命中率 | hit / (hit + miss) | >30% 才值得保留,低于此数是纯负债 |
| 命名空间清空频率 | clear 次数/分钟 | 频繁被清 = 写流量大 = 二级缓存不适合 |
| 缓存条目数 | size 水位 | 接近 1024 上限 = LRU 在淘汰,命中率进一步打折 |
| 回源 DB QPS | miss 数 | 关掉之后的 DB 压力预估 |
7.3 迁到 Redis 之后的监控
Redis 侧是成熟体系,不用自己造轮子:
# 命中率
redis-cli INFO stats | grep keyspace
# keyspace_hits:12345678
# keyspace_misses:1234567 → 命中率 = hits/(hits+misses)
# Spring Cache 侧接 Micrometer(原生支持)
management.metrics.enable.cache=true # 暴露 cache.gets{result=hit/miss}
大 Key、热 Key 的坑位提前占好——缓存订单这类大对象时注意单 key 体积,教训见《Redis 大 Key 阻塞排查:一个 200MB 的 Hash 拖垮了整个集群》。
八、常见问题
8.1 一级缓存能彻底关掉吗?
不能。最小作用域是 STATEMENT(每条语句执行完即清)。这个设计改不了,也没必要改——STATEMENT 级已经等价于"没有缓存",语义干净。
8.2 为什么事务内第二次查询结果和数据库不一样?
两种机制都可能:① MyBatis 一级缓存返回了本事务第一次查询的快照;② MySQL RR 隔离级别下事务本来就看不到其他事务已提交的修改。前者可以用 local-cache-scope: STATEMENT 消掉,后者是数据库语义,想用最新值得改隔离级别或显式 SELECT ... FOR UPDATE(走当前读,且会拿锁)。
8.3 <cache-ref/> 能解决联查脏读吗?
不能,只能缓解。cache-ref 让多个命名空间共享同一块缓存,A Mapper 更新时 B Mapper 的缓存也会被清——但要求所有写路径都收进这几个命名空间里。一旦有第三个 Mapper、另一个服务、或者 DBA 直接改库,假设立刻破裂。它把"不可能保证"降级成了"很难保证",治标不治本。
8.4 为什么二级缓存命中了,数据还是被改了?
因为默认 SerializedCache 返回的是深拷贝,改拷贝不影响缓存——这是正常的。但如果你为了性能把缓存配成 readOnly="true"(去掉序列化),返回的就是同一个对象引用,业务代码改一个字段,全应用看到的缓存数据都被污染。readOnly 是拿正确性换性能的又一笔交易,知道代价再签字。
8.5 Spring Boot 项目里 <cache/> 不生效?
检查两处:① mybatis.configuration.cache-enabled 是否被设为 false(很多团队的全局配置里早就关了);② Mapper XML 是否被正确扫描(mybatis.mapper-locations)。二级缓存开关是"全局总闸 AND 命名空间声明",两个都得开。
8.6 老项目几十个 Mapper 都有 <cache/>,怎么治理最稳?
按 6.3 的节奏:先全局 cache-enabled: false(一行配置立刻全部失效,XML 不用动)→ 加监控跑一周 → 命中率高的命名空间迁 Redis → 逐步清理 XML 声明。千万别反过来先删几十个 XML——那是几十个独立的变更窗口,风险翻了几十倍。
8.7 MyBatis-Plus 的缓存和这套一样吗?
一样。MyBatis-Plus 没有重写缓存层,一级/二级缓存机制完全继承 MyBatis 内核,本文的所有结论与配置原样适用。
8.8 关掉缓存后 DB 扛不住怎么办?
那就说明有真实的热点读需求——这恰好是 Redis 的主场,按 6.2 的 Cache-Aside 模式补上。如果 Redis 都扛不住(极高频热点),在前面叠 Caffeine 做本地一级 + Redis 二级的多级缓存,命中率与延迟两头都能要,完整方案见《本地缓存三巨头》11.3 节。
九、总结
决策树
这个查询要不要加缓存?
├── 联查了多张表?
│ └── 是 → 禁用二级缓存(脏读)
│ ├── 读多写少 → Redis:各维度分开缓存,应用层拼装
│ └── 读写都多 → 不缓存,优化 SQL/索引(见《数据库慢查询定位》)
├── 单表查询?
│ ├── 写多读少 → 不缓存或 Redis 短 TTL
│ ├── 读多写少且单实例 → 二级缓存可用(字典表这类)
│ └── 读多写少且多实例 → Redis(别用进程内缓存)
└── 一级缓存怎么处理?
└── 全局 local-cache-scope: STATEMENT,然后忘掉它
速查表
| 项目 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用域 | SqlSession(Spring 下 = 事务) | Mapper 命名空间 |
| 默认状态 | 开(SESSION 级) | 关 |
| 核心坑 | 事务内旧快照 | 联查脏读、多实例不一致 |
| 推荐配置 | local-cache-scope: STATEMENT | cache-enabled: false + Redis |
| 语句级控制 | flushCache="true" | useCache="false" |
| 监控 | 无需(等于没有) | 装饰器包 MeteredCache / Redis INFO |
一句话
MyBatis 两级缓存的正确姿势是"一级降级到 STATEMENT、二级全局关掉、缓存需求迁移到 Redis"——因为缓存的价值 = 命中率 × 单次收益 × 正确性,二级缓存在联查场景下命中率和正确性双输,而 Redis 用业务主键做 key、更新时精确失效,三个乘数全赢。
关键数据
- 二级缓存 key 由 5 要素组成(statement + 分页 + SQL + 参数 + 环境),粒度是"一模一样的查询调用"
- 默认 SerializedCache:每次命中付一次序列化 + 反序列化(1KB 对象约 0.05ms)
- 实测命中率:二级缓存 11% vs Redis Cache-Aside 85%(同一查询场景)
- 期望延迟:二级缓存 0.72ms vs Redis 0.55ms——命中率是决定性的乘数
- 非事务方法里一级缓存等效于不存在(每次调用新建 SqlSession)
给团队的建议
| 项目状态 | 建议 |
|---|---|
| 新项目 | 全局 cache-enabled: false + local-cache-scope: STATEMENT 写进脚手架 |
| 存量项目有 `` | 先全局关 → 监控一周 → 高命中率迁 Redis → 清理 XML |
| 正在经历"数据忽新忽旧" | 先查二级缓存 + 多实例部署,别先怀疑 DB 主从 |
| 字典/参数类数据 | 可保留二级缓存,或迁 Redis 长 TTL,二选一别混用 |
| 面试/分享 | 按"key 的构成 → 命名空间边界 ≠ 数据边界"讲,别背"一级会话二级 Mapper" |
互动话题:你们项目里还开着 MyBatis 二级缓存吗?有没有踩过联查脏读或者"刷新一下数据就变了"的坑?评论区说说你当时的排查过程,点赞最高的案例我拆成下一篇的完整复盘。
参考资料
- MyBatis 官方文档:缓存配置(XML Cache)
- MyBatis 官方文档:二级缓存说明与 cache-ref
- mybatis-spring 官方文档:SqlSession 与事务的生命周期
- MyBatis 源码:BaseExecutor / CachingExecutor / TransactionalCacheManager
- 《MyBatis 源码拆解:一条 SQL 从 Java 代码到数据库经历了什么》
- 《本地缓存三巨头:Caffeine vs Guava Cache vs Ehcache——到底谁才是王者》
- 《Redis 大 Key 阻塞排查:一个 200MB 的 Hash 拖垮了整个集群》
- 《Transactional 失效的 8 个场景——90% 的 Java 开发者都踩过至少一个》
- 《数据库慢查询定位:从慢日志到 EXPLAIN ANALYZE——PostgreSQL 篇》
标题:MyBatis 一级/二级缓存实战:坑在哪、什么场景必须关掉
作者:jiangyi
地址:http://jiangyi.space/articles/2026/10/27/1792930350759.html
公众号:服务端技术精选
- 引言
- 一、先建立正确的心智模型:缓存 key 长什么样
- 1.1 一次查询的缓存判定
- 1.2 两级缓存的位置
- 二、一级缓存:默认开启,但几乎没发挥作用
- 2.1 实现:一个朴素到简陋的 HashMap
- 2.2 生效边界:Spring 管理下,它的作用域是"事务"
- 2.3 坑:事务内拿到旧快照
- 2.4 关闭与收敛
- 2.5 结论
- 三、二级缓存:真正的脏读制造机
- 3.1 开启方式与真实结构
- 3.2 核心坑:命名空间边界 ≠ 数据边界
- 3.3 第二个坑:多实例部署下的"薛定谔的数据"
- 3.4 第三个隐藏成本:序列化税
- 3.5 它到底什么时候能用
- 3.6 结论
- 四、一条查询的一生:两级缓存联动全景
- 五、实测:二级缓存的收益到底有多大
- 5.1 测试场景
- 5.2 关键结论:单看延迟会误判
- 5.3 结论
- 六、怎么关,以及关掉之后怎么办
- 6.1 三级关闭配置(从粗到细)
- 6.2 替代方案:Redis 显式缓存
- 6.3 灰度姿势
- 七、命中率监控:没有数据的缓存决策都是赌博
- 7.1 MyBatis 原生:没有指标,自己包一层
- 7.2 该看什么指标
- 7.3 迁到 Redis 之后的监控
- 八、常见问题
- 8.1 一级缓存能彻底关掉吗?
- 8.2 为什么事务内第二次查询结果和数据库不一样?
-
8.3
能解决联查脏读吗? - 8.4 为什么二级缓存命中了,数据还是被改了?
-
8.5 Spring Boot 项目里
不生效? -
8.6 老项目几十个 Mapper 都有
,怎么治理最稳? - 8.7 MyBatis-Plus 的缓存和这套一样吗?
- 8.8 关掉缓存后 DB 扛不住怎么办?
- 九、总结
- 决策树
- 速查表
- 一句话
- 关键数据
- 给团队的建议
- 参考资料
评论