改了数据库,查出来还是旧的:一次 MyBatis 缓存踩坑的复盘

长事务里两次查同一条数据,第二次读到的还是旧值;后台改了配置接口却始终返回旧的,重启才好。根子是 MyBatis 一级缓存与二级缓存在帮倒忙。几天梳理:一级缓存作用域、长事务致脏、二级缓存跨表脏数据、集群不一致、为何生产建议关二级缓存改用 Redis。

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 天

避坑清单

  1. MyBatis 一级缓存默认开启且关不掉,作用域是一个 SqlSession,约等于一个事务
  2. 同一事务内执行完全相同的查询会命中一级缓存,不发 SQL,读不到外部的最新改动
  3. 一级缓存的坑几乎都出在长事务,尤其是事务中途夹了外部调用等耗时操作
  4. 同会话内任何 insert/update/delete 都会清空整个一级缓存,不管改的是哪张表
  5. 对账、轮询等"查-等-再查"逻辑,每次查询要用独立事务/会话,不要包成长事务
  6. 二级缓存是 namespace 级、跨 SqlSession,默认关闭,要 mapper 加 cache 标签才启用
  7. 二级缓存只清自己 namespace,跨表 join 查询会因别的 mapper 改表而产生脏数据
  8. 二级缓存是单机本地内存,集群部署下各实例必然不一致,改了数据要重启才生效
  9. 生产环境建议关闭 MyBatis 二级缓存,需要缓存就用 Redis,由业务显式管理失效
  10. 排查"改了不生效"先开 SQL 日志,看查询有没有真的发 SQL,没发就是命中了缓存

总结

这次 MyBatis 缓存的踩坑,让我对"缓存"这两个字多了一层警惕。我们这次同时被一级缓存和二级缓存咬了一口,这两个缓存虽然名字只差一个字,作用域和坑点却完全不同,但它们造成麻烦的根本原因是同一个——它们都是"隐形"的,是框架在你不知情的情况下,默默替你做的决定。先说一级缓存。它的作用域是一个 SqlSession,而在 Spring 的世界里,一个事务通常就对应一个 SqlSession,所以你完全可以把一级缓存理解成"一个事务内的查询缓存"。它的设计本意是好的:在同一次会话里,如果你用完全一样的条件查了两次同样的东西,何必发两次 SQL 去烦数据库?第二次直接把第一次的结果还给你就是了。这个优化在绝大多数情况下都是对的,因为它隐含了一个通常成立的假设——在一个事务这么短的时间窗口内,数据不太会被外部改变。可我们的对账任务,恰恰是那个例外:它在一个事务里先查了一次订单,然后停下来去调一个耗时的外部接口,等接口回调之后再查第二次。问题就出在"停下来"这段时间——它把事务的窗口从"很短"硬生生拉长成了"很长",长到足以让另一个线程从容地把数据库里的数据改掉。而一级缓存并不知道外面发生了什么,它忠实地遵守它的规则:同样的查询,返回缓存里的旧结果。于是第二次查询连 SQL 都没发,读到的自然是改之前的值。想清楚这一点之后我意识到,对付一级缓存的坑,最好的办法根本不是去研究怎么清缓存、怎么绕过它,而是回头消灭那个"长事务"——把查询和外部调用彻底分开,让每一次查询都在自己独立的、短小的事务里完成,一级缓存的复用收益还在,而致脏的窗口被关上了。再说二级缓存,它的危险性比一级缓存还要高一个数量级。它的作用域是 Mapper 的 namespace,而且能跨 SqlSession 共享,这意味着它的生命周期长得多、影响面大得多。它有两个几乎无解的硬伤:第一,它的失效是"各扫门前雪"的——一个 mapper 执行了写操作,只会清掉它自己 namespace 的缓存,可现实中一个查询经常 join 了好几张表,改了其中一张表的那个 mapper,根本不知道、也不会去通知那个缓存了 join 结果的 mapper,于是脏数据就这样产生了;第二,它默认是存在单个应用实例的本地内存里的,一旦你的服务是集群部署——而现在几乎所有像样的服务都是集群部署——那么 A 实例改了数据清了自己的本地缓存,B 实例的本地缓存对此一无所知,会一直捧着旧数据返回给用户,这正是我们测试环境那个配置接口"必须重启才生效"的真相,因为重启清空了本地内存。这两个硬伤让我得出一个明确的结论:对绝大多数生产服务来说,MyBatis 的二级缓存应该直接关掉。这不是说缓存不重要,恰恰相反,缓存非常重要——但缓存这件事,应该被显式地、清清楚楚地放在业务代码里,用 Redis 这样独立的、共享的缓存层来做,由业务代码自己明明白白地决定:什么时候读缓存、什么时候回写、过期时间设多久、数据更新时该删哪个 key。这次复盘给我留下的最深的一条原则就是:缓存的第一要义是"显式"。一个好的缓存,应该是你读代码时一眼就能看见的,能清楚知道它在哪里命中、在哪里失效。而 MyBatis 的两级缓存,最大的问题不在于它们的机制有多复杂,而在于它们太安静、太隐形了——它们藏在框架的默认行为里,藏在一个不起眼的 标签里,在你完全没意识到的时候就开始影响你的数据。当一个"数据不一致"的 bug 摆在面前,你的排查思路里甚至根本不会浮现出"缓存"这个嫌疑人。能让你看见的缓存,才是能让你掌控的缓存;看不见的缓存,迟早会在某个你意想不到的地方,变成一个看不见的 bug。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

消息消费失败后悄悄没了:一次死信队列治理的复盘

2026-5-20 14:01:44

技术教程

一波流量把服务冲垮:一次接口限流改造的复盘

2026-5-20 16:33:55

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索