"我在 mapper 里加了 <cache/>,为什么另一个服务还是读到旧数据?"——因为二级缓存住在当前 JVM 的堆里,namespace 是它的边界,而数据库不是。
MyBatis 有两层缓存,行为完全不同:一级缓存是 SqlSession 的私产,二级缓存是 namespace 的公共品。两者都不是"数据库缓存",都只在单进程内成立。把边界记住,剩下的问题都能推出来。
1. 一级缓存:作用域只有一句话
一级缓存挂在 SqlSession 上,本质是一个 PerpetualCache(内部 HashMap),key 由 MappedStatement id、SQL 文本、参数、分页 offset/limit 拼成。
命中的三个前提同时成立才行:同一个 SqlSession、同一个 MappedStatement、参数相同。三者任一变化就是新 key。
try (SqlSession session = sqlSessionFactory.openSession()) {
UserMapper mapper = session.getMapper(UserMapper.class);
User a = mapper.selectById(1L); // 查库
User b = mapper.selectById(1L); // 命中一级缓存,不发 SQL
System.out.println(a == b); // true:同一条对象引用
}
在 Spring 里这条规则由事务决定:没有事务时,SqlSessionTemplate 每次 mapper 调用都取一个新的 SqlSession,方法返回即关闭,一级缓存活不过一次调用;有 @Transactional 时,整个事务共用一个 SqlSession,一级缓存才真正生效。
| 维度 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用域 | SqlSession |
Mapper 的 namespace |
| 跨会话 | 否 | 是(同 JVM 内) |
| 默认状态 | 开启,无法整体关闭 | 关闭,需显式声明 |
| 关闭方式 | 只能 localCacheScope=STATEMENT 缩到语句级 |
不写 <cache/> 即无 |
| 存储位置 | JVM 堆,会话销毁即消失 | JVM 堆,进程退出即消失 |
| 跨进程/跨实例 | 不支持 | 不支持 |
2. 一级缓存失效的五个条件
| 触发条件 | 机制 | 现场表现 |
|---|---|---|
换了一个 SqlSession |
缓存对象随会话创建 | 同方法调两次、两次都发 SQL |
执行 commit() / rollback() |
事务结束清空会话缓存 | 提交后再查又走库 |
任意 insert/update/delete |
写语句 flushCache 默认 true,清空整个一级缓存 |
改 A 表导致 B 表查询也失效 |
显式 flushCache="true" |
查询前先清 | 手动控制 |
localCacheScope=STATEMENT |
每次查询结束即清 | 缓存退化为"单语句内有效" |
第三条最容易被误读:MyBatis 不是按表清缓存,而是把整个 SqlSession 的本地缓存一把清掉——它无从知道一条 update 影响了哪些表。
第五条的配置:
mybatis:
configuration:
local-cache-scope: statement # 默认 SESSION
cache-enabled: true
localCacheScope=STATEMENT 是"我想彻底不要缓存"的标准做法——一级缓存没有开关,只能靠缩小作用域让它无效。
3. 二级缓存:namespace 级
开启需要两步:全局 cacheEnabled=true(默认就是 true),加上在 mapper 里声明 <cache/> 或注解 @CacheNamespace。
<mapper namespace="com.demo.mapper.UserMapper">
<cache eviction="LRU" flushInterval="600000" size="512" readOnly="false"/>
<select id="selectById" resultType="com.demo.entity.User" useCache="true">
select id, name, status, version from t_user where id = #{id}
</select>
</mapper>
@CacheNamespace(eviction = LruCache.class, flushInterval = 600000, size = 512, readWrite = true)
public interface UserMapper { }
关键细节:二级缓存只在事务提交后写入。TransactionalCache 暂存本事务内查到的结果,commit 时才 flush 进真正的 Cache,rollback 则丢弃。"更新完立刻查还是旧值"多半是事务边界没对齐。
多个 mapper 想共享缓存区,必须显式写 <cache-ref namespace="com.demo.mapper.UserMapper"/>:ref 之后两者的写操作才互相清空,不写就是两份互不相识的缓存。
4. <cache> 的四个属性
| 属性 | 默认 | 取值 | 影响 |
|---|---|---|---|
eviction |
LRU |
LRU / FIFO / SOFT / WEAK |
淘汰策略;SOFT/WEAK 交给 GC,命中率不可控 |
flushInterval |
不设置 | 毫秒 | 定时清空;不设置则只靠写操作触发 |
size |
1024 |
整数 | 最多缓存多少个结果对象,超了按策略淘汰 |
readOnly |
false |
布尔 | true 返回同一实例(快,但调用方改动会污染缓存);false 走序列化副本 |
readOnly="false" 要求结果对象实现 Serializable,否则取缓存抛 NotSerializableException。readOnly="true" 更危险:所有调用方拿到同一个对象,任何一处 setName() 都直接改写缓存内容,下次命中返回脏对象——表现为"偶发字段值不对",极难复现。
size 按"对象平均大小 × 条数"估算:设成 10 万等于在堆里放一个不受监控的 Map,GC 压力上来时最先被怀疑的却是业务代码。
5. useCache 与 flushCache
| 语句 | useCache |
flushCache |
说明 |
|---|---|---|---|
<select> 默认 |
true |
false |
读走缓存、不清缓存 |
<insert/update/delete> 默认 |
— | true |
写操作清空本 namespace 二级缓存 |
| 实时性查询 | false |
false |
强制走库,如对账、风控 |
| 批量导入 | — | false |
避免每批都清缓存,最后统一清 |
<select id="selectForCheck" resultType="com.demo.entity.User" useCache="false" flushCache="false">
select id, balance from t_account where id = #{id}
</select>
<insert id="batchInsert" flushCache="false">
insert into t_user (name) values <foreach collection="list" item="u" separator=",">(#{u.name})</foreach>
</insert>
flushCache="false" 是批量写入的有意取舍:导入 5000 条时每条都清缓存等于把命中率打到零。代价是导入结束后要手动处理一次,否则读到导入前的快照。
6. 最坑的一条:跨 namespace 不清缓存
二级缓存的失效规则只有一条:本 namespace 的写操作,清空本 namespace 的缓存。
| 动作 | 会清 UserMapper 缓存吗 |
说明 |
|---|---|---|
UserMapper.insert/update/delete |
会 | 同 namespace |
OrderMapper.insert(写 order 表) |
不会 | 不同 namespace,各管各的 |
OrderMapper 里 join 了 t_user 的查询 |
不会 | 该查询结果缓存在 OrderMapper 下 |
写了 <cache-ref> 指向 UserMapper |
会 | 共享缓存区才联动 |
| 另一个 JVM 写库 | 不会 | 缓存不在数据库里 |
第三行是雷区:OrderMapper 的 select o.*, u.name from t_order o join t_user u 结果缓存在 OrderMapper;改了昵称时 UserMapper.update 只清自己的缓存,这条 join 查询继续返回旧昵称。缓存不知道自己依赖了谁,这就是二级缓存不适合多表关联的根本原因。
7. 与 Spring Cache / Redis 的分工
两套缓存叠在一起时,混乱来自"两份副本、两套失效"。
| 方案 | 作用域 | 失效触发 | 适合 |
|---|---|---|---|
| MyBatis 一级缓存 | 单会话/单事务 | 提交、写语句 | 事务内的重复读,白拿 |
| MyBatis 二级缓存 | 单 JVM 的 namespace | 本 namespace 写 | 单实例、读多写少、单表查询 |
| Spring Cache + Redis | 跨实例 | @CacheEvict 显式声明 |
多实例、需要跨进程一致 |
| 业务层手写 Redis | 跨实例 | 自己控制 | 需要精细的 key 与过期策略 |
典型冲突:方法标了 @Cacheable("user"),MyBatis 又开了二级缓存。首次查库两份都写入;更新时只 @CacheEvict 清了 Redis,二级缓存仍在,于是本进程读到旧值、其他进程读到新值——"一部分节点对、一部分错"最消耗排查时间。
结论很简单:要么用 MyBatis 二级缓存,要么用 Spring Cache,不要同时开。需要跨实例一致就选后者。
8. 什么场景该关掉二级缓存
| 场景 | 建议 | 原因 |
|---|---|---|
| 多实例部署(Spring Boot 常规形态) | 关 | 每实例各自缓存,写操作只清本机 |
| 多表关联查询为主 | 关 | 关联表写入不会清本 namespace |
| 强一致要求(余额、库存、权限) | 关 | 缓存与库之间没有失效通道 |
| 单实例、单表、读远多于写 | 可开 | 收益最明显 |
多实例下二级缓存不是"优化没生效",而是正确性问题:A 写库并清自己的缓存,B 继续命中旧值,直到重启或过期。只要服务有两个以上副本,答案就是关掉,把缓存交给 Redis。
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
加了 <cache/> 但从不命中 |
未提交事务就查;或 useCache="false" |
确认事务边界;检查语句属性 |
取缓存抛 NotSerializableException |
readOnly="false" 且实体未实现 Serializable |
实体加 implements Serializable |
| 缓存里对象字段被莫名改掉 | readOnly="true" 返回同一实例 |
改 readOnly="false" 或返回不可变对象 |
| 更新后同进程读到旧值 | 写操作未清该 namespace(join 查询在别的 mapper) | 关闭二级缓存,或让 join 查询 useCache="false" |
| 多实例数据不一致 | 二级缓存是进程内缓存 | 关闭,改用 Redis |
| 同一事务内两次查询发了两次 SQL | 会话被换掉(无事务的 SqlSessionTemplate) |
加 @Transactional 让会话复用 |
10. 边界
| 需求 | 更合适的手段 |
|---|---|
| 跨实例共享缓存 | Spring Cache + Redis,或直接 Redis |
| 缓存需要 TTL | Redis 的 EXPIRE;<cache> 只有 flushInterval 整体清 |
| 需要按 key 精确失效 | Redis DEL;namespace 级缓存只能整体清 |
| 只想减少事务内重复查询 | 一级缓存已足够,不用开二级 |
| 需要缓存命中率指标 | 自定义 Cache 实现 + Micrometer 埋点 |
一句话:一级缓存是"事务内的顺手便宜",二级缓存是"单机单表的赌注"。跨实例的缓存永远该在 MyBatis 之外解决。
11. 自测
- 一级缓存命中的三个前提是什么?为什么"同一个方法调两次"不一定命中?
- 一条
update会清空一级缓存里的哪些条目?为什么不是只清相关表? - 二级缓存的结果在什么时候真正写入?
commit与rollback分别发生什么? readOnly="true"与readOnly="false"在对象返回和序列化要求上有什么差别?- 为什么 join 查询的二级缓存会在关联表更新后仍然返回旧值?
<cache-ref>能解决吗? - 多实例部署下二级缓存会带来什么后果?替代方案是什么?