MyBatis 一级/二级缓存实战:坑在哪、什么场景必须关掉

引言

周五晚上 21:30,客服群里涌入一批截图:

"我明明把收货地址改了,下单页面还是旧地址!"
"商品价格后台已经调成 79 了,用户下单还是按 99 扣的款!"

排查了两个多小时,DB 里的数据是对的,Redis 里的数据是对的,接口日志也没报错——但部分用户看到的就是旧数据。最后定位到的"凶手"让所有人沉默:订单服务的 OrderMapper.xml 里,三年前有人加了一行 <cache/>,二级缓存一直开着。

事故链条清晰得可笑:

  1. 订单查询 SQL 关联了 user_address 和 product 两张表,结果缓存在 OrderMapper 的命名空间下;
  2. 地址服务、商品服务各自更新自己的表,走的是 AddressMapper、ProductMapper——它们的更新只清自己命名空间的缓存,根本不知道 OrderMapper 里还躺着关联查询的旧结果;
  3. 用户改完地址,订单页查到的还是缓存里的旧快照,直到缓存过期(默认永不过期)或订单表自己有更新才"自愈"。

更扎心的是复盘时发现:这个缓存的命中率只有 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)

两个默认行为必须知道:

  1. SerializedCache 默认在链上——缓存命中时返回的不是原对象,而是反序列化出来的深拷贝。每次命中都付一次序列化 + 反序列化的 CPU 税(3.4 节有实测)。
  2. 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 它到底什么时候能用

公平起见,二级缓存不是全无价值。同时满足以下四个条件的场景,它可以活:

  1. 单表单 Mapper:查询不 JOIN 别的表,这张表的所有写操作都在这一个命名空间里
  2. 读极多写极少:字典表、行政区域、类目树这种一天改不了一次的
  3. 单实例部署,或能接受实例间短暂不一致
  4. 对象可正确序列化

典型的合格候选人:省市县字典表、系统参数表。典型的不合格候选人:一切有联查的业务查询——而这恰恰是大家最想加缓存的地方,讽刺拉满。

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 会挨一刀。安全姿势:

  1. 关之前先加监控(第七章),跑一周看每个命名空间的真实命中率
  2. 命中率 >30% 的命名空间,先迁 Redis 再关,别裸关
  3. 全局限流降级预案:备好 DB 连接池和慢查询告警的观察面板
  4. 低峰期操作,观察 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 QPSmiss 数关掉之后的 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: STATEMENTcache-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 一级/二级缓存实战:坑在哪、什么场景必须关掉
作者:jiangyi
地址:http://jiangyi.space/articles/2026/10/27/1792930350759.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消