2617 字
约 8 分钟
16
S08-X04 · 一级与二级缓存:命中条件与失效陷阱

"我在 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. 自测

  1. 一级缓存命中的三个前提是什么?为什么"同一个方法调两次"不一定命中?
  2. 一条 update 会清空一级缓存里的哪些条目?为什么不是只清相关表?
  3. 二级缓存的结果在什么时候真正写入?commit 与 rollback 分别发生什么?
  4. readOnly="true" 与 readOnly="false" 在对象返回和序列化要求上有什么差别?
  5. 为什么 join 查询的二级缓存会在关联表更新后仍然返回旧值?<cache-ref> 能解决吗?
  6. 多实例部署下二级缓存会带来什么后果?替代方案是什么?
S08-X04 · 一级与二级缓存:命中条件与失效陷阱
https://www.clxhxhhr.top/posts/5813/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。