MyBatis(二):缓存机制、执行器与批量操作
MyBatis(二):缓存机制、执行器与批量操作
导语:缓存是 MyBatis 面试区分度最高的一章——一级缓存的失效场景、
CacheKey的组成、二级缓存为什么"不提交读不到"、装饰器链的层层包装,答不出细节就只能停在"有两级缓存"。本篇再往下扎一层:Executor 三种类型的取舍、BatchExecutor的两个硬伤、批量插入的性能真相(rewriteBatchedStatements),最后把 Executor → CachingExecutor → LocalCache 的调用链串起来,共 12 题。
一、一级缓存(SqlSession 级)
1. MyBatis 有几级缓存?默认开启哪些?一级缓存能关闭吗?
答:
| 级别 | 作用域 | 实现位置 | 默认状态 |
|---|---|---|---|
| 一级缓存 | SqlSession(会话级) | BaseExecutor.localCache(PerpetualCache,底层 HashMap) | 默认开启 |
| 二级缓存 | namespace(Mapper 级) | CachingExecutor + Cache 接口实现(可外接 Redis/Ehcache) | 默认关闭,需显式 <cache/> |
一级缓存"能不能关闭"——这是高频追问,标准答案:
- MyBatis 没有"关闭一级缓存"的开关(它内建在
BaseExecutor里,恒存在); - 但有两种等效手段:
localCacheScope=STATEMENT(推荐):每次查询结束就clearLocalCache(),使缓存只在单条语句内有效 → 等效于关闭;- 在语句上设
flushCache="true":每次执行前清空本地缓存 → 也能达到"每次查库"的效果(但语义上是"强制刷新")。
<settings>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>一个必须先讲清的前提(否则很多现象解释不通):
一级缓存的生命周期 = SqlSession 的生命周期。而在 Spring 集成下:
- 不在事务中的 Mapper 调用:每次调用都会新建并关闭一个 SqlSession → 一级缓存"用不上"(这也是很多人"感觉不到一级缓存"的原因);
- 在同一个
@Transactional方法内:多次 Mapper 调用共用同一个 SqlSession → 一级缓存生效,同一事务内两次相同查询只发一次 SQL。
一句话:一级缓存是"SqlSession 内的一致性视图"——它保证了同一会话内"看到的数据是稳定的",代价是可能读到旧值(见第 3 题)。
2. 一级缓存的工作机制是什么?什么情况会失效?
答:
工作机制(BaseExecutor.query()):
1. 用 (statementId + RowBounds + SQL + 参数值) 生成 CacheKey
2. localCache.getObject(key) 命中 → 直接返回(不再查库)
3. 未命中 → queryFromDatabase():查库、ResultSetHandler 映射、
结果 putObject(key, list) 回填本地缓存 → 返回
4. 若 localCacheScope == STATEMENT → 本次查询结束后 clearLocalCache()CacheKey 的组成(能报出这五项就是细节控):
| 组成 | 说明 |
|---|---|
statementId | 即 namespace + 方法名,不同 Mapper 的方法天然不共享 |
RowBounds 的 offset/limit | 分页参数不同 → key 不同(内存分页会参与 key) |
| SQL 文本 | BoundSql.getSql(),动态 SQL 拼出的最终 SQL |
| 参数值 | 每个 ParameterMapping 的值 + 其 TypeHandler 的类名 |
| Environment id | 运行环境标识(多数据源场景用于隔离) |
CacheKey内部用update()逐个累积哈希,equals()时逐项精确比较,因此"SQL 一样但参数不同"不会误命中。
一级缓存的失效(清空)场景(7 条,面试常考"为什么两次查询发了两次 SQL"):
- 换了一个
SqlSession(最常见,尤其在 Spring 无事务时); - 执行了任意的
insert/update/delete——BaseExecutor.update()结尾会无条件clearLocalCache()(注意:连别的 namespace 的写操作也会清掉本会话的一级缓存,因为一级缓存不区分 namespace); - 手动
sqlSession.clearCache(); sqlSession.close();- 事务
rollback()(会清本地缓存); - 语句上
flushCache="true"(查询前强制清空); localCacheScope=STATEMENT(每次查询后自动清空),以及 SQL / 方法 / 分页参数不同导致CacheKey不同(严格说这不是"失效",是"没命中")。
对比记忆:二级缓存"精准清"(只清本 namespace),一级缓存"粗放清"(任何写操作都清)。
3. 一级缓存为什么可能读到旧数据?怎么规避?
答: 一级缓存只保证"本会话内看到的结果稳定",它感知不到其他会话/连接对数据的修改,因此在这些场景会读到旧值:
| 场景 | 现象 | 说明 |
|---|---|---|
| 同一事务内,数据被外部修改 | 本会话重复查询仍返回旧值 | 另一条连接/其他服务/存储过程/触发器改了库;本会话缓存不会失效 |
| 长事务 + 读多写少 | 缓存窗口被拉长 | 事务越长,"看到旧数据"的时间越久 |
| 读写分离 | 主库写、从库读不同步 | 这与缓存无关,但常被误认为"缓存脏读",要能区分 |
规避手段(按代价从小到大):
localCacheScope=STATEMENT:需要"每次都拿最新"的查询走这条路(Spring Boot 配置项mybatis.configuration.local-cache-scope: statement);- 关键查询加
flushCache="true":只对个别语句关闭缓存; - 用新会话执行:把"必须最新"的查询放到
REQUIRES_NEW事务(或独立SqlSession)里; - 业务上不要依赖"同一事务内重复查询取最新值"——这本就不是 MyBatis 的承诺。
答题要点:要主动说清"这不是 bug 而是设计:一级缓存换取了减少重复 SQL 的收益;需要强一致就读主库/关缓存,而不是骂框架"。
4. 一级缓存和二级缓存的区别?
答:
| 维度 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用域 | SqlSession(会话级) | namespace(Mapper 级) |
| 是否默认开启 | 开启(无法直接关闭) | 关闭(需 <cache/>) |
| 是否跨会话共享 | 否 | 是(同 namespace 的多个 SqlSession 共享) |
| 实现位置 | BaseExecutor.localCache(PerpetualCache) | CachingExecutor + Cache(可外接 Redis/Ehcache) |
| 清空时机 | 本会话任何写操作 / clearCache / close / rollback | 本 namespace 的写操作(在事务提交时生效) |
| 缓存范围 | 不区分 namespace | 按 namespace 隔离 |
| 对象要求 | 无(存的是对象引用) | readOnly=false 时对象需 Serializable |
| 能否禁用 | 只能靠 localCacheScope=STATEMENT 等效关闭 | cacheEnabled=false 或删除 <cache/> |
| 设计定位 | 性能优化(减少重复 SQL)+ 会话内一致性 | 跨会话的查询缓存(收益受限于失效粒度) |
一句话:一级缓存是"会话内共享",二级缓存是"Mapper 内跨会话共享";二级缓存之所以被称为"鸡肋",是因为它的失效粒度太粗(见第 8 题)。
二、二级缓存(namespace 级)
5. 二级缓存的工作机制是什么?命中顺序是怎样的?
答:
开启后,SqlSessionFactory 创建 Executor 时会用 CachingExecutor 装饰真正的执行器:
openSession()
→ newExecutor(ExecutorType)
→ baseExecutor = SimpleExecutor / ReuseExecutor / BatchExecutor
→ if (cacheEnabled) executor = new CachingExecutor(baseExecutor) // 装饰器模式查询命中顺序:二级缓存 → 一级缓存 → 数据库
CachingExecutor.query()
1. 查 TransactionalCache(二级缓存,按 namespace)
├─ 命中 → 直接返回,不再往下走(连一级缓存都不查)
└─ 未命中 ↓
2. 委托 BaseExecutor.query()
2.1 用 CacheKey 查 localCache(一级缓存)→ 命中返回
2.2 未命中 → 查库 → 回填一级缓存 → 返回
3. 结果 put 到 TransactionalCache(提交时才真正写入二级缓存)四个必须掌握的细节:
- 二级缓存按 namespace 隔离:不同 namespace 的
CacheKey(含statementId)天然不同,所以即使有<cache-ref>共享同一Cache实例,key 也不会撞; useCache/flushCache控制粒度:<select useCache="false">不查也不写二级缓存;<select flushCache="true">查询前清空;insert/update/delete的flushCache默认为true,所以写操作必然清缓存;- 存储过程不能用二级缓存:MyBatis 对
CALLABLE语句会抛异常(Caching cannot be used with stored procedures that have output parameters),因为输出参数结果不确定; - 缓存对象是"结果列表"本身:
readOnly=false(默认)时通过序列化存取(每次返回的是深拷贝);readOnly=true时返回的是同一个对象引用——业务代码一改就把缓存改脏了。
追问"为什么
readOnly=false还要实体implements Serializable?"——因为Cache的默认装饰链里有SerializedCache,它用序列化做深拷贝,避免多线程共享同一对象。
6. 如何启用二级缓存?
答: 四步,缺一不可:
<!-- ① 全局开关(默认就是 true,一般不用动) -->
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
<!-- ② 在 Mapper.xml 声明 cache(或接口上用 @CacheNamespace) -->
<mapper namespace="com.x.UserMapper">
<cache eviction="LRU" <!-- 淘汰策略:LRU/FIFO/SOFT/WEAK -->
flushInterval="60000" <!-- 定时清空(毫秒),默认不清 -->
size="1024" <!-- 最多缓存对象数 -->
readOnly="false" <!-- false=可读写(序列化深拷贝);true=只读(返回同一引用) -->
blocking="false"/> <!-- 可选:缓存未命中时阻塞(防击穿) -->
<select id="get" resultMap="userMap" useCache="true"> ... </select>
</mapper>// ③ 实体类必须可序列化(readOnly=false 时)
public class User implements Serializable { ... }<!-- ④(可选)跨 Mapper 共享同一个 Cache 实例 -->
<cache-ref namespace="com.x.UserMapper"/>各属性语义对照:
| 属性 | 作用 | 备注 |
|---|---|---|
eviction | 淘汰策略 | LRU(默认)、FIFO、SOFT、WEAK |
flushInterval | 定期清空 | 不配置则永不过期(只靠写操作清) |
size | 最多缓存条数 | 超出按策略淘汰 |
readOnly | 是否只读 | true 性能高但有"对象被改脏"风险;false 安全但要序列化 |
blocking | 缓存未命中时是否加锁 | 高并发下防缓存击穿(同一 key 并发查库) |
7. 二级缓存是什么时候写入的?为什么"不提交读不到"?
答: 因为二级缓存不是直接写底层 Cache,而是先写"事务级暂存区",提交时才刷入。
核心两个类:TransactionalCache 与 TransactionalCacheManager
| 结构 | 作用 |
|---|---|
entriesToAddOnCommit | 本次事务查库得到的、待提交时写入的缓存条目 |
entriesMissedInCache | 本次事务未命中的 key(提交时一并写入 null 对应的"未命中标记") |
clearOnCommit | 本次事务是否有写操作(有则提交时清空整个 namespace 缓存) |
流程:
查询:tcm.getObject(cache, key)
→ 先在 TransactionalCache 的暂存区找 → 再找底层 Cache → 都没有则查库,
结果放进 entriesToAddOnCommit(此时其他 SqlSession 看不到)
写操作:flushCacheIfRequired() → tcm.clear(cache) → 标记 clearOnCommit
提交(commit):TransactionalCacheManager.commit()
→ clearOnCommit ? delegate.clear() : 把 entriesToAddOnCommit 刷入 delegate
回滚(rollback):清空暂存区(不写入)由此可以解释三个常见现象(面试极爱考):
- "同一事务内两次相同查询,第二次没发 SQL" → 命中的是一级缓存(同一
SqlSession),二级缓存在提交前根本还没写; - "事务提交后,另一个 SqlSession 才查得到缓存" → 二级缓存的可见性以事务提交为界;
- "写操作一提交,整个 namespace 缓存全没了" → 因为
clearOnCommit是按 namespace 整体清空,无法按行/条件精确失效——这也是二级缓存命中率上不去的根因。
一句话:二级缓存是"事务提交时批量刷入、按 namespace 整体失效"的缓存,所以它天生适合只读数据,不适合频繁更新的业务数据。
8. 二级缓存有哪些坑?生产环境应该怎么用?
答: 五个坑,前三个足以让大多数团队放弃二级缓存:
| 坑 | 说明 | 后果 |
|---|---|---|
| ① 多表关联脏数据 | 二级缓存按 namespace 隔离,但 SQL 可能 join 了别的表。更新 A 表不会清 B 表的 namespace 缓存 | 读到过期数据(最经典的生产事故) |
| ② 集群/分布式不一致 | 默认实现是本地内存,多节点各自缓存、互不感知 | 同一次查询在不同节点结果不同 |
| ③ 失效粒度过粗 | 任何写操作 → 清空整个 namespace 缓存 | 写多读少时命中率极低,等于白开 |
④ readOnly=true 的对象污染 | 返回的是同一对象引用,业务一改就污染缓存 | 后续查询拿到被改坏的数据 |
| ⑤ 对象与序列化约束 | readOnly=false 需 Serializable 且每次走序列化 | 大对象序列化有 CPU/内存开销 |
生产建议(这是"做过"与"背过"的分水岭):
- 默认不开二级缓存,统一用应用层 Redis 缓存:可控(能按业务 key 精确失效)、可跨语言、可集中管理——MyBatis 二级缓存能做的,Redis 做得更好;
- 只在"极少变更的字典/配置表"上开(如城市表、类目表):这类表天然只读、且通常单表查询,没有坑①;
- 确实要用且要跨节点一致:实现
org.apache.ibatis.cache.Cache接口对接 Redis(每次读写走 Redis),但必须解决"清缓存"的广播问题(本地也要清,否则同一 JVM 内仍有旧值); - 别忘了
flushInterval:给缓存加个兜底过期时间,避免"缓存永不过期 + 脏数据永久存在"。
缓存装饰器链(加分点):
CacheBuilder.build()会把基础PerpetualCache层层包装(装饰器模式):PerpetualCache(HashMap 存储)→ScheduledCache(按flushInterval定时清空)→LruCache(按size淘汰)→SerializedCache(深拷贝)→LoggingCache(命中率日志)→SynchronizedCache(同步),再叠加可选的BlockingCache(按 key 加锁防击穿)。
能说出"MyBatis 的缓存是装饰器链"比背配置项更值钱。
三、Executor 执行器与批量操作
9. MyBatis 有哪几种 Executor?区别是什么?
答: ExecutorType 有三种(对应 BaseExecutor 的三个子类),作用范围都限定在 SqlSession 生命周期内:
| 执行器 | 行为 | 适用 |
|---|---|---|
SIMPLE(默认) | 每执行一条语句都新建 Statement,用完即关 | 通用场景,最安全 |
REUSE | 以 SQL 为 key 缓存 Statement 并复用(同一 SqlSession 内),减少重复预编译开销 | 同一会话内反复执行相同 SQL(如循环调用) |
BATCH | 把多条 update addBatch() 累积,由 flushStatements()/commit() 统一执行 | 大批量插入/更新 |
别把它们和 CachingExecutor 搞混(高频提问):
SIMPLE / REUSE / BATCH是ExecutorType(使用者可选);CachingExecutor是装饰器,只在cacheEnabled=true时套在最外层,与ExecutorType正交——即"BATCH+ 二级缓存"也是合法组合。
三者共用的模板逻辑:BaseExecutor 用模板方法模式实现了 query/update/flushStatements/commit/rollback/close 的骨架(含一级缓存、事务提交、延迟加载入口),只把 doQuery/doUpdate/doFlushStatements/doCommit/doRollback 留给子类实现——"模板方法 + 装饰器"是 MyBatis 执行器的整体设计。
10. 如何指定使用哪种 Executor?BatchExecutor 有什么注意事项?
答:
<!-- 全局默认(不推荐全局改成 BATCH) -->
<settings><setting name="defaultExecutorType" value="BATCH"/></settings>// 代码中按会话指定(推荐,按需使用)
try (SqlSession session = factory.openSession(ExecutorType.BATCH)) {
UserMapper mapper = session.getMapper(UserMapper.class);
for (User u : list) mapper.insert(u); // 只是 addBatch,尚未执行
session.commit(); // 这一行才真正发往数据库
}BatchExecutor 的四个注意点(前两个是硬伤):
- 只支持
update(insert/update/delete),不支持select—— JDBC 批处理本身就没有"批量查询";若在同一会话中混用查询,MyBatis 会先flushStatements()再查询,语义上不会出错,但批处理收益被打破; - 提交前拿不到自增主键 ——
useGeneratedKeys也要等commit()/flushStatements()之后才回填。依赖"插入后立刻用 id"的业务不能用 BATCH(这也是它只能全局配的少见场景); - 必须显式
commit()(或flushStatements()),否则一条都不会发出去;close()不会自动提交; REUSE的坑:以 SQL 为 key 复用Statement,如果 SQL 相同但结果集处理方式不同(如RowBounds),可能相互影响;且长时间会话会持有大量Statement(游标/资源占用)。
11. MyBatis 批量插入有哪几种方式?怎么调优?
答: 三种主流方式,性能差异的关键往往不在 SQL 写法,而在 JDBC 参数:
| 方式 | 写法 | 优点 | 缺点 |
|---|---|---|---|
① foreach 拼多值 | insert into t(...) values (...),(...),(...) | 一次网络往返,MySQL 下最快 | 单条 SQL 过大(包体/max_allowed_packet);数据库方言限制 |
② foreach 拼多条语句 | insert ...; insert ...; | 直观 | 需要连接开启 allowMultiQueries=true,不通用、也不比①快 |
③ ExecutorType.BATCH | addBatch + commit | 数据库无关、内存可控、适合超大批量 | 提交前拿不到主键;必须显式 commit |
性能真相(必考):MySQL 下用方式③必须加 rewriteBatchedStatements=true
jdbc:mysql://host:3306/db?rewriteBatchedStatements=true- 默认
false时,MySQL 驱动会把addBatch的多条 insert 拆成逐条发送("伪批处理"),性能提升非常有限; - 开启后,驱动会把多条 insert 重写成一条多值 insert 发送,性能可提升数倍到十倍;
- 这个参数只对 insert 生效最好,update 的合并效果较差。
大数据量批量的工程要点:
- 分批提交(如每批 500~1000 条)+ 手动控制事务:避免单个大事务导致 undo/redo log 暴涨、锁等待、主从复制延迟;
foreach方式注意 SQL 长度:一批几万条会拼出 MB 级 SQL,可能触发max_allowed_packet限制,也可能让解析/执行计划缓存受影响;- 优先"批量接口 + 必要索引":批量写最怕的是每条都去更新二级索引/唯一索引,索引越多越慢——批量场景要审视索引是否必要;
insert ignore/on duplicate key update与批量结合时,要注意自增 id 跳号与死锁概率上升。
一句话:小批量用
foreach多值(最少的网络往返),超大批量用BATCH+rewriteBatchedStatements=true+ 分批提交。
12. Executor、CachingExecutor 与 LocalCache 是什么关系?(把执行链路串起来)
答: 这是把前 11 题"串成一条线"的题,也是回答"MyBatis 底层原理"最稳的抓手。
SqlSessionFactory.openSession()
└─ newExecutor(ExecutorType) // 简单工厂 + 装饰器
├─ base = Simple/Reuse/BatchExecutor // 模板方法:BaseExecutor
│ ├─ localCache (PerpetualCache,一级缓存)
│ └─ transaction / configuration
└─ executor = new CachingExecutor(base) // 仅 cacheEnabled=true
mapper.selectOne(...)
└─ MapperProxy(JDK 动态代理)
└─ SqlSession.selectOne()
└─ CachingExecutor.query()
├─ ① 二级缓存(TransactionalCacheManager,提交时刷入)
├─ ② BaseExecutor.query()
│ ├─ createCacheKey() → 查 localCache(一级缓存)
│ └─ 未命中 → queryFromDatabase()
│ ├─ StatementHandler.prepare() 生成 PreparedStatement
│ ├─ ParameterHandler.setParameters()
│ ├─ StatementHandler.query()
│ └─ ResultSetHandler.handleResultSets()
└─ ③ 结果回填(一级即时、二级待提交)三层拦截点(顺带复习插件,见《MyBatis(三)》):
| 层 | 组件 | 能否被插件拦截 |
|---|---|---|
| 会话层 | SqlSession(DefaultSqlSession) | ❌ |
| 执行器层 | Executor(含 CachingExecutor) | ✅ |
| 语句层 | StatementHandler | ✅ |
| 参数层 | ParameterHandler | ✅ |
| 结果层 | ResultSetHandler | ✅ |
记住三个结论:
LocalCache在BaseExecutor里,CachingExecutor只负责二级缓存——所以"一级缓存是每次查询必然经过的",二缓存则要先满足cacheEnabled + <cache/> + useCache;- 缓存的 key 是同一个
CacheKey对象(由CachingExecutor创建并传给BaseExecutor),所以"两级缓存找不到同一个 key 才能到数据库"; Executor是 MyBatis 的核心扩展点:分页插件、读写分离插件、SQL 监控插件几乎都拦Executor(因为这里能拿到MappedStatement+ 参数 + 最终 SQL,且只需改一次就能覆盖所有语句);而StatementHandler更适合"改 SQL 文本/设置超时"这类场景。
一句话总结:
SqlSession是门面,Executor是大脑,StatementHandler是手脚;缓存(一级在BaseExecutor、二级在CachingExecutor)和执行器一起,构成了 MyBatis 的"查询主干道"。
