跳到正文

MyBatis 缓存机制 ​

一句话:一级缓存是 SqlSession 的本地 Map,二级缓存是 namespace 级、可跨 session 的缓存;前者换来同一事务内少查几次,后者换来跨请求复用热点数据,代价是失效和一致性都要自己设计。

适用版本:MyBatis 3.5.x;接入 Spring 时以 mybatis-spring 3.0.x / Spring Boot 3.x 为例。

为什么需要 ​

同一事务里反复查同一条数据很常见:校验、更新前查询、组装 DTO。每次都回表会把数据库连接和网络往返放大。

MyBatis 提供两级缓存:

  • 一级缓存:默认开启,作用域是 SqlSession,不需要额外配置。
  • 二级缓存:默认没有生效,需要在 namespace 上声明 <cache/>,可跨 SqlSession 共享。

两者解决的不是同一个问题:一级缓存是“同一会话内减少重复查询”,二级缓存是“多个会话共享热点数据”。这也是为什么一级缓存默认开、二级缓存默认需要显式开启。

核心机制 ​

一级缓存跟 SqlSession 走,不是跟请求走 ​

一级缓存的失效点是「任何写操作与事务边界」,不是时间;同一个 SqlSession 里的两次相同 select 只打一次库,中间只要发生过增删改或提交,缓存就被清空。

缓存 Key 由 statement id、SQL 文本、参数值、RowBounds、Environment id 等组成;参数不同就是不同的 Key。localCacheScope=SESSION(默认)表示整个 session 内有效,设为 STATEMENT 则在每次查询后清空。

和 Spring 事务的关系:

  • 原生 MyBatis 每次操作通常 openSession() / close(),一级缓存只活在一次操作里,几乎观察不到。
  • Spring 整合后,SqlSessionTemplate 在同一个事务内复用同一个 SqlSession;所以 @Transactional 方法内连续两次相同查询,第二次命中一级缓存。
  • 没有事务时,每次 Mapper 调用都取一个新的 SqlSession,一级缓存不会跨调用生效。不要把“一级缓存”误当成“请求级缓存”。

脏读场景:事务 A 第一次查询缓存了旧值;事务 B 修改并提交;事务 A 再查,仍命中旧值(在 READ_COMMITTED 下也不会看到 B 的提交)。这不是数据库隔离级别的问题,而是应用层缓存。关键“先查后改”查询可以加 flushCache="true",或把 localCacheScope 设为 STATEMENT,或缩短事务。

二级缓存:namespace 级,提交后才写入 ​

开启方式:

xml
<!-- mybatis-config.xml:全局开关,默认就是 true -->
<settings>
    <setting name="cacheEnabled" value="true"/>
</settings>

<!-- UserMapper.xml -->
<mapper namespace="com.example.UserMapper">
    <!-- 默认:LRU、1024 个对象、不自动刷新、readOnly=false -->
    <cache eviction="LRU" flushInterval="60000" size="1024" readOnly="false"/>
</mapper>
参数含义默认值(3.5.x)
evictionLRU / FIFO / SOFT / WEAKLRU
flushInterval自动清空间隔(ms)不设置
size最多缓存对象数1024
readOnlytrue 返回同一实例;false 返回副本false
blocking未命中时是否阻塞同 key 查询false

关键机制:

  • cacheEnabled=true 是全局开关(默认 true),但没有 <cache/> 就没有 namespace 二级缓存;说“二级缓存默认关闭”指的是后者。
  • 查询顺序是 二级缓存 → 一级缓存 → 数据库;二级缓存命中时不会再查一级缓存。
  • 写入不是立即发生:TransactionalCache 先把结果放进 entriesToAddOnCommit,事务 commit() 时才刷入底层缓存,rollback() 时丢弃。这样避免其他 session 读到未提交数据。
  • 只有本 namespace 的增删改会清空本 namespace 缓存;跨 namespace 写同一张表不会互相清空,这是二级缓存最常见的脏数据来源。

cache-ref 与接 Redis ​

多个 Mapper 操作同一组表时,用 cache-ref 共享同一个 namespace 的缓存:

xml
<!-- OrderMapper.xml:复用 UserMapper 的缓存配置和失效域 -->
<mapper namespace="com.example.OrderMapper">
    <cache-ref namespace="com.example.UserMapper"/>
</mapper>

接 Redis 时实现 org.apache.ibatis.cache.Cache,在 <cache type="..."> 指定:

java
public class RedisCache implements Cache {
    private static RedisTemplate<String, Object> redis;
    public static void setRedisTemplate(RedisTemplate<String, Object> t) { redis = t; }
    private final String id;                       // MyBatis 传入 namespace
    public RedisCache(String id) { this.id = id; }
    public String getId() { return id; }
    public void putObject(Object key, Object value) {
        redis.opsForValue().set(id + ":" + key, value, Duration.ofMinutes(10));
    }
    public Object getObject(Object key) { return redis.opsForValue().get(id + ":" + key); }
    public Object removeObject(Object key) { redis.delete(id + ":" + key); return null; }
    public void clear() { /* 用 SCAN 删除 id:*,不要用 KEYS */ }
    public int getSize() { return 0; }             // 近似值即可,MyBatis 不依赖精确值
}
xml
<mapper namespace="com.example.UserMapper">
    <cache type="com.example.RedisCache"/>
</mapper>

注意:

  • MyBatis 只会用 namespace id 调用构造器,RedisTemplate 要通过静态持有、ApplicationContextAware 或自定义 Cache 工厂注入,不能指望构造器参数。
  • Key 要稳定且可区分 namespace;直接把 CacheKey.toString() 拼进 Redis key 可用,但要注意长度和碰撞,生产建议自定义编码。
  • 设置 TTL 只是兜底,跨服务写同一张表仍要靠业务失效或短 TTL,不能假设缓存自动一致。

实战场景 ​

一、跨 namespace 写同一张表:改了但读不到 ​

现象:用户表开了二级缓存。客服系统(AdminUserMapper)改了手机号后,App 侧(AppUserMapper)查询仍返回旧号码,最长 60 秒后自己好了(配了 flushInterval);更麻烦的是多实例部署时,只有处理写请求的那台机器的缓存被清掉,另外三台一直是旧值。

排查:二级缓存的失效域是 namespace,不是表。

定位层取证据判定
谁写了这张表全仓搜该表名出现在哪些 namespace 的 XML/注解 SQL 里多于一个 → 跨 namespace 写,失效不联动
失效范围读本 namespace 的 <cache> 配置写只清自己这份缓存
缓存实例在哪原生 PerpetualCache 是 JVM 内一份,还是接了 Redis每个 JVM 一份 → 多节点必然不一致
兜底机制flushInterval 是否被当成一致性手段到期才清,意味着“过期前一直错”
提交时机写事务是否 commitTransactionalCache 在回滚时丢弃,缓存保持旧值

本例两个问题叠加:AdminUserMapper 与 AppUserMapper 各自有 <cache/>;同时缓存是 JVM 内的原生实现。表现就是“同一集群里不同节点给出不同答案”。

对症做法:

xml
<!-- 1. 同一组表共享一份缓存与失效域(解决单 JVM 内的跨 namespace) -->
<mapper namespace="com.example.AppUserMapper">
    <cache-ref namespace="com.example.AdminUserMapper"/>
</mapper>
java
// 2. 跨节点必须外部化:接 Redis 的 Cache 实现,并在写后显式失效
@CacheEvict(cacheManager = "mybatisRedisCache", value = "com.example.AdminUserMapper")
public void updatePhone(long uid, String phone) { adminUserMapper.updatePhone(uid, phone); }

// 3. 统一写入口:只允许一个 Service 写这张表,其余走读模型
//    这是唯一能让“失效域 = 写入域”成立的做法

cache-ref 只共享缓存与失效范围,不会自动感知另一个 namespace 的写入;flushInterval 是兜底,不是一致性方案。真需要跨服务一致时,把缓存当成可选加速,正确性交给数据库读或带版本号的读。

复盘要点:这张表有多少个写入口,应该在设计阶段就写下来。判据可以固化成检查:select count(distinct namespace) from mapper_index where table = 'users' 这类脚本能扫出跨 namespace 写的表;命中即要求开 cache-ref 或取消二级缓存。多节点部署的原生二级缓存,本质上不该用于会写的表。

二、readOnly=true 让一次修改污染整个缓存 ​

现象:字典接口加了 readOnly=true 提速后,运营反馈“某个城市名变成大写”,且所有用户都看到;重启服务后恢复,一段时间后再次出现。

排查:这类“全局看到同一处修改”的证据指向共享实例。

定位层取证据判定
返回的是不是同一对象连续两次查询打印 System.identityHashCode(dto)相同 → readOnly=true,返回同一实例
谁改了它业务代码里是否有 dto.setName(...)、list.sort(...)、Optional.orElse 后原地改有 → 直接改到缓存对象上
序列化开销改成 readOnly=false 后 P99 是否上升上升说明副本成本真实存在
异常类型readOnly=false 且 POJO 未实现 Serializable 时报错部署期改类结构也会引发反序列化失败

readOnly 的语义就是“返回同一实例”还是“返回副本”。选 true 等于把缓存对象当成共享可变状态,和单例 Bean 存字段是同一类错误;默认值 false 是有理由的。

对症做法:

java
// 1. 保持 readOnly=false,用不可变对象把副本成本压到最低
public record DictItem(Long id, String code, String name) {}   // 无 setter,改不了

// 2. 返回集合时给不可变视图,避免调用方 sort/remove 污染缓存
List<DictItem> items = List.copyOf(dictMapper.listByType(type));

// 3. 确需 readOnly=true 时,只允许“读了不写”的调用形态,并加断言
@Test void cacheInstancesAreNeverMutated() {
    var a = service.getCity(1L);
    var b = service.getCity(1L);
    assertThat(b.getName()).isEqualTo("杭州");    // 上一步若改过 a,这里必然失败
}

性能收益要按实测决定:size=1024、LRU 下副本序列化对字典类小对象通常可忽略;只有在压测里证明序列化是瓶颈,才值得换 readOnly=true + 全链路不可变。

复盘要点:缓存对象必须是不可变值,这是可以静态保证的约束——字典/配置类 DTO 一律 record,禁用 setter。相比提醒开发者“别改返回值”,把 DTO 改成不可变类型让这类 bug 无法写出。POJO 走 readOnly=false 时记得实现 Serializable,否则上线直接报错。

三、开了二级缓存反而更慢 ​

现象:订单列表接入二级缓存后,QPS 从 900 掉到 600,Redis 出网流量翻倍,数据库 QPS 只降了 8%(经验值,需校准)。看起来“缓存生效了”,但整体变差。

排查:先算命中率,再看写入是否把缓存清光。

定位层取证据判定
命中率Cache.stats 埋点或 Redis 侧 keyspace 与调用量对比长期低于 30%,收益基本被开销吃掉
失效频率该表的写次数 vs 缓存存活时间订单表每次写都清整个 namespace
键稳定性CacheKey 是否含分页 RowBounds、时间戳参数条件每次都变 → 键不复用,命中率天然为 0
序列化成本CPU profile 里 ObjectOutputStream 占比明显占比 → 副本与远程序列化成主要开销
并发穿透blocking 默认 false未命中时同 key 并发全部落到数据库

根因是把缓存加在了写多读少、且带分页参数的查询上:每次写入清空整个 namespace,同 key 命中率极低,而每次未命中都要序列化并回源。

对症做法:

xml
<!-- 1. 只给低频写、高频读的表开二级缓存(字典、区域、类目) -->
<cache eviction="LRU" size="2048" readOnly="false" blocking="true"/>
<!-- blocking=true:同 key 未命中时串行等待,避免并发穿透 -->
java
// 2. 写多读少的表不在 MyBatis 层缓存,改在业务层缓存可复用的聚合结果
@Cacheable(cacheNames = "orderSummary", key = "#orderId", unless = "#result == null")
public OrderSummary summary(long orderId) { ... }   // 写路径显式 @CacheEvict

// 3. 列表类查询优先解决 SQL:先加索引、去 N+1,再谈缓存

顺序很重要:缓存不能替代索引和 SQL 优化。命中率长期低于 30% 时,先怀疑缓存对象选错了,而不是把 TTL 调长。

复盘要点:这次改动的错误是把“有缓存”当成目标。上线判据应该量化到三条:命中率、回源 QPS 变化、端到端 P99;三者不同时改善就回滚。给每个开了二级缓存的 namespace 记一条“读写比”,读写比低于阈值(例如 5:1,经验值,需校准)就不允许开——这条规则可以在 CI 里用 XML 扫描加表统计实现。

最小上手 ​

验证一级缓存与事务的关系(假设已有 UserMapper.selectById 返回带 getName() 的对象,Spring Boot 3.x + H2):

java
@SpringBootTest
class CacheTest {
    @Autowired UserMapper mapper;
    @Autowired JdbcTemplate jdbc;
    @Test
    @Transactional
    void firstLevelCacheLivesInTransaction() {
        jdbc.update("create table if not exists users(id bigint primary key, name varchar(20))");
        jdbc.update("insert into users(id,name) values (1,'Ada')");
        mapper.selectById(1L);                 // 第一次:查库
        jdbc.update("update users set name='Bob' where id=1");
        var second = mapper.selectById(1L);    // 命中一级缓存,仍是 Ada
        assertEquals("Ada", second.getName());
    }
}

把 @Transactional 去掉再跑,第二次会查库并得到 Bob,说明没有事务时两次调用用的是不同 SqlSession。日志里数 ==> Preparing 的次数即可验证。

生产动作 ​

  • localCacheScope=SESSION(默认):同一事务内可能读到旧值;关键“先查后改”查询加 flushCache="true",或改 STATEMENT。
  • cacheEnabled=true(默认 true):它只是二级缓存的全局开关;是否启用还取决于 namespace 上的 <cache/>/<cache-ref/>。
  • readOnly=false(默认):返回序列化副本,POJO 必须实现 Serializable;readOnly=true 返回同一实例,调用方修改会污染缓存。
  • 排障路径:现象“更新后仍读旧值” → 查是否同一事务命中一级缓存 → 查写入是否在另一个 namespace(二级缓存不互相失效)→ 查 Redis 二级缓存是否跨节点未清理。
  • 排障路径:现象“二级缓存命中率很低” → 查表是否写多读少、flushInterval 是否过短、每次写是否清空整个 namespace → 命中率长期低于 30% 时,缓存可能只增加序列化开销。
  • 排障路径:现象“反序列化失败/ClassCastException” → 查 POJO 是否实现 Serializable、序列化器是否一致、部署期间类结构是否变更。

边界 ​

  • 一级缓存不能完全关闭:只能改作用域;它几乎无副作用的前提是 session 短、事务小。
  • 二级缓存不适合频繁写的表:每次增删改清空整个 namespace,写多读少时命中率低,还多一次序列化。
  • 二级缓存不是分布式缓存:原生实现是每个 JVM 一份;多节点要么接 Redis,要么不用。
  • 跨 namespace 写同一张表:cache-ref 只能共享缓存,不能自动感知另一个 namespace 的写入;需要统一写入口或短 TTL。
  • 缓存不能替代索引和 SQL 优化:缓存命中率再高,也掩盖不了慢查询和 N+1;先修 SQL,再谈缓存。

讲清楚 ​

MyBatis 的两级缓存解决的是两个不同范围的问题。一级缓存是 SqlSession 里的一个 HashMap,同一 session 内相同 statement + 参数直接命中;它跟事务走,Spring 下通常等于一个事务的范围,不是整个请求。二级缓存是 namespace 级,需要 <cache/> 显式开启,跨 session 共享;写入要等事务提交才生效,靠 TransactionalCache 暂存。

最容易踩的坑是把一级缓存当成“请求缓存”:没有事务时每次 Mapper 调用都是新 session,缓存根本不跨调用。另一个坑是跨 namespace 写同一张表,二级缓存不会互相失效;cache-ref 只能共享缓存,不能共享失效逻辑。

接 Redis 时,Cache 实现只拿到 namespace id,RedisTemplate 要另外注入;key 要带 namespace,清理用 SCAN,TTL 只是兜底。缓存是优化,不是一致性方案。

把一门技术讲透到既懂也会用