2024 年我们一个老服务在排查一个"数据不一致"的诡异问题时,踩进了 MyBatis 缓存的坑。现象是这样的:一个定时任务在一个长事务里,先查了一遍订单状态,然后调用外部接口,接口回调后又查了一次同一个订单——可第二次查出来的状态,和数据库里真实的值对不上,数据库明明已经被另一个线程改过了,它查出来还是旧的。更离谱的是测试环境一个接口,我们刚通过后台改了配置,接口返回的却始终是改之前的值,重启才好。查了很久才定位到,这两个问题分别是 MyBatis 的一级缓存和二级缓存在"帮倒忙"。投了几天把 MyBatis 的两级缓存机制彻底搞清,本文复盘这次实战。
问题背景
业务:订单服务,MyBatis + MySQL,有定时对账任务
事故现象:
- 长事务里两次查同一条数据,第二次读到的还是第一次的旧值
- 测试环境某接口,后台改了配置,接口仍返回旧配置,重启才生效
- 现象只在特定代码路径出现,难以稳定复现
现场排查:
# 1. 看长事务里的对账代码
@Transactional
public void reconcile(Long orderId) {
Order o1 = orderMapper.selectById(orderId); // 第一次查
callExternalApi(o1); // 耗时操作,期间别的线程改了 DB
Order o2 = orderMapper.selectById(orderId); // 第二次查
// o2 居然 == o1 的内容,DB 已经变了却没读到
}
# 2. 开 MyBatis 日志看 SQL
DEBUG ==> Preparing: select * from orders where id=?
DEBUG ==> Parameters: 1001(Long)
# 第二次 selectById 根本【没有打印 SQL】!
# 说明它没查数据库,直接从缓存返回了
# 3. 看那个配置接口的 mapper
# 二级缓存:跨 SqlSession,改了配置但缓存没刷新
根因:
1. 同一个 SqlSession 内重复查询,命中【一级缓存】,不查 DB
2. 一级缓存默认开启,事务内第二次查永远拿不到最新数据
3. ConfigMapper 开了【二级缓存】,缓存粒度和失效没控制好
4. 团队对 MyBatis 两级缓存的作用域和失效规则完全没概念
修复 1:一级缓存——SqlSession 级别的缓存
=== 什么是一级缓存 ===
MyBatis 默认就【开启】一级缓存,而且关不掉(只能换作用域)。
它的作用域是【一个 SqlSession】:
在同一个 SqlSession 里,执行了一条查询后,
结果会被缓存在这个 SqlSession 的本地缓存里。
之后只要是【完全相同的查询】(同样的 statement、
同样的参数、同样的分页),就直接返回缓存里的结果,
【不再发 SQL 给数据库】。
=== 它和 Spring 事务的关系 ===
在 Spring 里,一个事务通常对应一个 SqlSession。
所以"一级缓存的作用域",实际上约等于"一个事务"。
这就解释了我们的对账 bug:
reconcile 方法上有 @Transactional,整个方法是一个事务、
一个 SqlSession,两次 selectById(1001) 是完全相同的查询,
第二次直接命中一级缓存,根本没去查已经被改过的数据库。
=== 一级缓存什么时候失效(被清空)===
1. 同一个 SqlSession 里执行了 insert/update/delete ——
任何写操作都会【清空整个一级缓存】(不管改的是不是那张表)
2. SqlSession 关闭 / 提交 / 回滚
3. 手动调用 sqlSession.clearCache()
4. 查询时显式声明 flushCache=true
=== 关键认知 ===
一级缓存的本意是:在一次会话里,避免对同一查询的重复 SQL。
它隐含一个假设:【一个事务内,数据不会被外部改变】。
而我们的对账任务恰恰打破了这个假设 ——
它在事务中间停下来调外部接口,给了别人改数据的时间窗口。
修复 2:被一级缓存坑到时,怎么拿到最新数据
// === 办法 1:别在长事务里"查-等-再查" ===
// 根上的问题是:对账任务不该把"调外部接口"这种
// 耗时、不确定的操作,夹在一个数据库事务中间。
// 重构:把事务拆开,查询和外部调用分离。
public void reconcile(Long orderId) {
Order o1 = queryOrder(orderId); // 独立事务/会话
callExternalApi(o1); // 事务外,不占连接
Order o2 = queryOrder(orderId); // 又一个独立事务/会话
// 两次查询是不同的 SqlSession,各自独立,o2 是最新的
}
// 这同时还解决了"长事务长占数据库连接"的问题。
// === 办法 2:把一级缓存作用域改成 STATEMENT ===
// MyBatis 配置:localCacheScope 设为 STATEMENT
//
// 效果:缓存只在【一次查询语句】内有效,
// 语句一结束就清掉 -> 同一会话内的两次查询互不影响。
// 代价:完全放弃了一级缓存的复用收益。
// === 办法 3:第二次查询前手动清缓存 ===
// 在需要"强制读最新"的地方,显式清掉一级缓存:
sqlSession.clearCache();
Order o2 = orderMapper.selectById(orderId); // 这次会真的查 DB
// === 办法 4:在 mapper 查询上声明 flushCache ===
//
修复 3:二级缓存——Mapper 级别的缓存
=== 什么是二级缓存 ===
二级缓存的作用域比一级缓存大得多:它是【Mapper namespace 级别】,
可以【跨 SqlSession】共享。
也就是说,A 事务查出来的数据进了二级缓存,
B 事务查同样的东西,能直接命中 A 留下的缓存。
=== 二级缓存默认是关的,要手动开 ===
1. 全局开关(默认就是 true,一般不用动):
2. 在具体的 mapper.xml 里加 标签,
这个 namespace 才真正启用二级缓存。
3. 实体类通常要可序列化(implements Serializable)。
=== 二级缓存的失效规则(关键)===
- 某个 namespace 的 mapper 执行了 insert/update/delete,
会清空【这个 namespace 自己】的二级缓存。
- 注意:只清自己 namespace 的!
=== 二级缓存最大的坑:跨表 / 跨 namespace 的脏数据 ===
这是它最危险的地方。设想:
- OrderMapper 的查询 join 了 user 表,结果进了 OrderMapper 缓存
- UserMapper 执行 update 改了 user 表
- UserMapper 只会清【UserMapper 自己】的二级缓存,
它【不知道也不会去清】OrderMapper 的缓存
- 结果:OrderMapper 缓存里那条 join 了 user 的数据,变成了脏数据
我们那个配置接口的问题就类似:配置在别处被改,
ConfigMapper 的二级缓存却没人通知它失效。
=== 还有一个坑:分布式环境下二级缓存不一致 ===
二级缓存默认存在【单个应用实例的本地内存】里。
集群部署时,A 实例改了数据、清了自己的二级缓存,
B 实例的二级缓存根本不知情 -> B 实例一直返回旧数据。
重启才"好",正是因为重启清空了本地内存缓存。
修复 4:生产环境到底要不要用二级缓存
// === 结论:多数生产场景,建议【关闭】MyBatis 二级缓存 ===
// 不是它没用,而是它的坑(跨表脏数据、分布式不一致)
// 对大多数业务来说,代价远大于收益。
// === 为什么不推荐用它 ===
// 1. 跨 namespace 更新无法感知,极易产生脏数据
// 2. 单机本地缓存,集群下天然不一致
// 3. 失效控制弱,无法按业务精细控制过期、刷新
// 4. 一旦出现"数据不对",排查成本很高(缓存是隐形的)
// === 正确做法:需要缓存,就用专门的缓存层(Redis)===
// 把缓存这件事,从 ORM 框架里【显式地】拿出来,
// 由业务代码自己掌控:
@Service
public class ConfigService {
public Config getConfig(String key) {
// 1. 先查 Redis
Config c = redisCache.get("config:" + key);
if (c != null) return c;
// 2. 没有再查 DB
c = configMapper.selectByKey(key);
// 3. 回写 Redis,过期时间由业务明确控制
redisCache.set("config:" + key, c, Duration.ofMinutes(10));
return c;
}
// 配置更新时,业务代码【显式】删除缓存
public void updateConfig(String key, String value) {
configMapper.update(key, value);
redisCache.delete("config:" + key); // 主动失效,集群共享
}
}
// === 这样做的好处 ===
// - 缓存是显式的:看代码就知道哪里用了缓存、何时失效
// - 集群一致:Redis 是共享的,一处删除,处处生效
// - 可控:过期时间、缓存粒度、更新策略全在业务手里
// 对比之下,MyBatis 二级缓存是"隐形"的,这正是它的危险所在。
// === 什么情况下二级缓存还可以用 ===
// 单机部署、数据极少变更、且不涉及跨表查询的字典类数据 ——
// 这种边角场景可以用,但收益也很有限。
修复 5:如果坚持用二级缓存,要注意什么
修复 6:MyBatis 缓存使用规范
=== 团队沉淀的 MyBatis 缓存规范 ===
【一级缓存】
1. 一级缓存默认开启、关不掉,要理解它 = 一个事务内的缓存
2. 严禁写"长事务",尤其严禁在事务中间夹外部接口调用、
sleep、大循环等耗时操作 —— 这是一级缓存致脏的温床
3. 对账、轮询这类"查-等-再查"的逻辑,
每次查询用独立的事务/会话,不要包在一个大事务里
4. 确需在同一会话内读到最新值,用 clearCache() 或 flushCache
【二级缓存】
5. 默认【不开启】二级缓存,需要缓存走 Redis,由业务显式管理
6. 禁止在涉及 join 多表、或数据频繁变更的 mapper 上开二级缓存
7. 集群部署的服务,一律不用 MyBatis 二级缓存(本地缓存必不一致)
8. 仅"单机 + 极少变更 + 无跨表"的字典数据,才考虑二级缓存
【通用】
9. 排查"数据不一致 / 改了不生效"问题时,
先开 MyBatis 的 SQL 日志,看第二次查询【有没有真的发 SQL】 ——
没发 SQL,基本就是命中了缓存
10. 缓存的第一原则是"显式" —— 让人一眼看出哪里有缓存、
何时失效。MyBatis 缓存的危险,正在于它太"隐形"。
优化效果
指标 治理前 治理后
=============================================================
长事务内重复查询 命中一级缓存读旧值 拆短事务,各查独立会话
对账任务结构 查-调外部接口-再查 查询与外部调用分离
二级缓存 ConfigMapper 开着 全部关闭,改用 Redis
配置更新生效 要重启实例才生效 主动删 Redis,集群即时生效
跨表查询脏数据 二级缓存无法感知 不用二级缓存,无此问题
集群缓存一致性 各实例本地缓存不一致 Redis 共享,处处一致
缓存可见性 隐形,排查困难 显式在业务代码,一眼可见
数据不一致投诉 偶发 清零
治理过程:
- 定位一级/二级缓存致脏根因:1 天
- 对账等长事务拆分改造:1 天
- 关闭二级缓存 + 配置类缓存迁移到 Redis:1 天
- 全量排查 mapper 的 标签:0.5 天
- 缓存使用规范沉淀 + 评审:0.5 天
避坑清单
- MyBatis 一级缓存默认开启且关不掉,作用域是一个 SqlSession,约等于一个事务
- 同一事务内执行完全相同的查询会命中一级缓存,不发 SQL,读不到外部的最新改动
- 一级缓存的坑几乎都出在长事务,尤其是事务中途夹了外部调用等耗时操作
- 同会话内任何 insert/update/delete 都会清空整个一级缓存,不管改的是哪张表
- 对账、轮询等"查-等-再查"逻辑,每次查询要用独立事务/会话,不要包成长事务
- 二级缓存是 namespace 级、跨 SqlSession,默认关闭,要 mapper 加 cache 标签才启用
- 二级缓存只清自己 namespace,跨表 join 查询会因别的 mapper 改表而产生脏数据
- 二级缓存是单机本地内存,集群部署下各实例必然不一致,改了数据要重启才生效
- 生产环境建议关闭 MyBatis 二级缓存,需要缓存就用 Redis,由业务显式管理失效
- 排查"改了不生效"先开 SQL 日志,看查询有没有真的发 SQL,没发就是命中了缓存
总结
这次 MyBatis 缓存的踩坑,让我对"缓存"这两个字多了一层警惕。我们这次同时被一级缓存和二级缓存咬了一口,这两个缓存虽然名字只差一个字,作用域和坑点却完全不同,但它们造成麻烦的根本原因是同一个——它们都是"隐形"的,是框架在你不知情的情况下,默默替你做的决定。先说一级缓存。它的作用域是一个 SqlSession,而在 Spring 的世界里,一个事务通常就对应一个 SqlSession,所以你完全可以把一级缓存理解成"一个事务内的查询缓存"。它的设计本意是好的:在同一次会话里,如果你用完全一样的条件查了两次同样的东西,何必发两次 SQL 去烦数据库?第二次直接把第一次的结果还给你就是了。这个优化在绝大多数情况下都是对的,因为它隐含了一个通常成立的假设——在一个事务这么短的时间窗口内,数据不太会被外部改变。可我们的对账任务,恰恰是那个例外:它在一个事务里先查了一次订单,然后停下来去调一个耗时的外部接口,等接口回调之后再查第二次。问题就出在"停下来"这段时间——它把事务的窗口从"很短"硬生生拉长成了"很长",长到足以让另一个线程从容地把数据库里的数据改掉。而一级缓存并不知道外面发生了什么,它忠实地遵守它的规则:同样的查询,返回缓存里的旧结果。于是第二次查询连 SQL 都没发,读到的自然是改之前的值。想清楚这一点之后我意识到,对付一级缓存的坑,最好的办法根本不是去研究怎么清缓存、怎么绕过它,而是回头消灭那个"长事务"——把查询和外部调用彻底分开,让每一次查询都在自己独立的、短小的事务里完成,一级缓存的复用收益还在,而致脏的窗口被关上了。再说二级缓存,它的危险性比一级缓存还要高一个数量级。它的作用域是 Mapper 的 namespace,而且能跨 SqlSession 共享,这意味着它的生命周期长得多、影响面大得多。它有两个几乎无解的硬伤:第一,它的失效是"各扫门前雪"的——一个 mapper 执行了写操作,只会清掉它自己 namespace 的缓存,可现实中一个查询经常 join 了好几张表,改了其中一张表的那个 mapper,根本不知道、也不会去通知那个缓存了 join 结果的 mapper,于是脏数据就这样产生了;第二,它默认是存在单个应用实例的本地内存里的,一旦你的服务是集群部署——而现在几乎所有像样的服务都是集群部署——那么 A 实例改了数据清了自己的本地缓存,B 实例的本地缓存对此一无所知,会一直捧着旧数据返回给用户,这正是我们测试环境那个配置接口"必须重启才生效"的真相,因为重启清空了本地内存。这两个硬伤让我得出一个明确的结论:对绝大多数生产服务来说,MyBatis 的二级缓存应该直接关掉。这不是说缓存不重要,恰恰相反,缓存非常重要——但缓存这件事,应该被显式地、清清楚楚地放在业务代码里,用 Redis 这样独立的、共享的缓存层来做,由业务代码自己明明白白地决定:什么时候读缓存、什么时候回写、过期时间设多久、数据更新时该删哪个 key。这次复盘给我留下的最深的一条原则就是:缓存的第一要义是"显式"。一个好的缓存,应该是你读代码时一眼就能看见的,能清楚知道它在哪里命中、在哪里失效。而 MyBatis 的两级缓存,最大的问题不在于它们的机制有多复杂,而在于它们太安静、太隐形了——它们藏在框架的默认行为里,藏在一个不起眼的
—— 别看了 · 2026