说说 Mybatis 的缓存机制?
结论先行:MyBatis 有两级缓存。一级缓存在 SqlSession 内,默认开启、无法关闭,本质是一个 PerpetualCache 里的 HashMap;二级缓存在 namespace 级,需要显式配置 <cache/> 才生效,跨 SqlSession 共享。两者都只缓存查询结果,任何增删改都会让相关缓存整体失效,所以「缓存命中率」远不如 Redis,误用反而会读到脏数据。
1. 两级缓存的位置
┌──────────────────── SqlSessionFactory ────────────────────┐
│ │
│ namespace = com.example.mapper.UserMapper │
│ ┌──────────────── 二级缓存(跨 SqlSession)───────────┐ │
│ │ 需要 <cache/> 开启,可配 LRU/FIFO/软引用 │ │
│ └───────────────────────┬─────────────────────────────┘ │
│ │ 未命中 │
│ SqlSession A ▼ │
│ ┌──────────── 一级缓存(Session 级)────────────┐ │
│ │ PerpetualCache { CacheKey -> List<Object> } │ │
│ └───────────────────────┬───────────────────────┘ │
└───────────────────────────┼────────────────────────────────┘
▼ 未命中才查库
一级缓存的生命周期跟随 SqlSession:Spring 集成后,一个没有事务的查询结束后 SqlSession 就关闭,一级缓存随之消失;只有在同一个事务(同一个 SqlSession)内连续查两次,才能看到命中。
2. 一级缓存的命中条件
CacheKey 由 MappedStatement 的 id、分页参数 RowBounds 的 offset/limit、以及最终传给 JDBC 的 SQL 与参数值共同决定。任何一个不同,都不会命中。
| 场景 | 是否命中一级缓存 | 原因 |
|---|---|---|
| 同一 Session 内两次相同查询,中间无写操作 | 命中 | CacheKey 完全一致 |
同一 Session 内中间执行了 update | 不命中 | 写操作调用 clearLocalCache() |
| 两次查询参数不同 | 不命中 | 参数参与 CacheKey 计算 |
手动 sqlSession.clearCache() 后 | 不命中 | 缓存被清空 |
| 两次查询在不同事务中 | 不命中 | 属于不同 SqlSession |
查询前手动 flushCache="true" | 不命中 | 执行前清空本地缓存 |
3. 二级缓存的开关与淘汰策略
<!-- 在 Mapper XML 顶部声明,作用域是这个 namespace -->
<cache eviction="LRU"
flushInterval="60000"
size="1024"
readOnly="false"
blocking="false"/>
| 属性 | 含义 | 可选值 |
|---|---|---|
eviction | 淘汰算法 | LRU(默认)、FIFO、SOFT、WEAK |
flushInterval | 定时清空间隔(毫秒) | 正整数,不配则只在写操作时清 |
size | 最多缓存对象数 | 默认 1024 |
readOnly | 是否只读 | true 直接返回缓存引用(快,但会被调用方改坏);false 序列化后返回副本 |
blocking | 缓存未命中时是否加锁 | 默认 false,true 可防止缓存击穿 |
readOnly="true" 的坑很隐蔽:返回的是同一个对象引用,Service 层改了下游会直接读脏值。需要可修改就用 false,但要求实体实现 Serializable。
4. 为什么二级缓存经常「看起来不生效」
二级缓存挂在 namespace 上,而写操作只会清空当前 namespace 的缓存。多表联查时,A 表的数据被 B 表的 Mapper 改了,A 的缓存不会失效。
// UserMapper.xml 里做了关联查询,缓存了 user + dept
// DeptMapper.updateName() 改了部门名,UserMapper 的缓存依然是旧值
解决办法只有两个:<cache-ref namespace="..."/> 让两个 Mapper 共用一个缓存,或干脆不用二级缓存、交给 Redis。生产上更常见的做法是:关闭二级缓存(mybatis.configuration.cache-enabled: false),把跨请求缓存交给业务层的 Redis,避免缓存粒度与事务边界错位。
5. 配置示例
mybatis:
configuration:
cache-enabled: true # 全局开关,false 时 <cache/> 也不生效
local-cache-scope: session # 一级缓存范围:session(默认)/ statement
default-executor-type: simple
local-cache-scope: statement 常被用来「关掉一级缓存」:每次查询结束就清空本地缓存,适合对实时性要求高、又不想改代码的场景。代价是同一事务内重复查询也要回库。
6. 常见追问
追问一:一级缓存能关掉吗?
没有直接的开关,只有 localCacheScope=STATEMENT 这一种「查询完立即清空」的近似关闭方式。所以严格来说默认开启、不可移除。
追问二:为什么 Spring 下感觉一级缓存没起作用?
因为 Spring 每次查询都会取新的 SqlSession,除非你处在同一个 @Transactional 方法里。这也是一级缓存「看起来不存在」的最常见原因。
追问三:二级缓存和 Redis 缓存冲突吗? 冲突。两者都缓存查询结果,失效时机不同,容易出现「Redis 已更新、MyBatis 二级缓存还是旧值」的双缓存不一致。选一个即可。
追问四:flushCache 和 useCache 有什么区别?
useCache="false" 表示这次查询不读也不写缓存;flushCache="true" 表示执行前清空缓存。写语句默认 flushCache="true"。
追问五:二级缓存能解决 N+1 吗?
不能。N+1 是关联查询次数问题,缓存只能缓解重复查询,第一次仍然要发 N 次 SQL。解决 N+1 要靠 fetchType、<collection> 的嵌套查询合并或手写 join。
小结
一级缓存是「同一个 SqlSession 内的查询备忘录」,二级缓存是「同一个 namespace 内的跨会话缓存」。它们省的是重复 SQL,不是分布式缓存;真正需要跨服务、跨请求的缓存,应该交给 Redis,并用业务键控制失效,而不是指望 MyBatis 的缓存边界。