主题
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) |
|---|---|---|
eviction | LRU / FIFO / SOFT / WEAK | LRU |
flushInterval | 自动清空间隔(ms) | 不设置 |
size | 最多缓存对象数 | 1024 |
readOnly | true 返回同一实例;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 是否被当成一致性手段 | 到期才清,意味着“过期前一直错” |
| 提交时机 | 写事务是否 commit | TransactionalCache 在回滚时丢弃,缓存保持旧值 |
本例两个问题叠加: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 只是兜底。缓存是优化,不是一致性方案。