MyBatis 缓存机制详解定位MyBatis 系列第 4 篇——一级缓存与二级缓存的完整机制、脏读分析与生产实践结论适用版本MyBatis 3.5.xJDK 8目录缓存全景一级缓存Local Cache一级缓存脏读分析二级缓存Global Cache缓存执行顺序与装饰结构生产实践与外部缓存整合总结常见高频面试题一、缓存全景MyBatis 内建两级缓存查询时依次经过SELECT 查询路径 二级缓存namespace 级跨 SqlSession │ 未命中 ▼ 一级缓存SqlSession 级 │ 未命中 ▼ 数据库 │ 结果回填 ▼ 一级缓存立即写入二级缓存在事务提交后写入维度一级缓存二级缓存作用域SqlSession 生命周期namespaceMapper级默认状态开启无需配置关闭需显式开启承载者BaseExecutor.localCacheMappedStatement 绑定的 Cache CachingExecutor跨会话否是关闭方式只能缩小作用域localCacheScope不配置cache/即关闭缓存键与值键是 CacheKey见 2.2值是查询结果对象列表底层都是PerpetualCacheHashMap 包装二级缓存额外叠加淘汰策略等装饰器见第五节。二、一级缓存Local Cache2.1 基本特征实现BaseExecutor内的localCache字段PerpetualCache查询入口BaseExecutor.query先查缓存。默认开启没有配置项可以整体关闭——只能通过localCacheScopeSTATEMENT把作用域缩小到语句级。同一次查询相同缓存键在同一会话内重复执行第二次直接返回缓存结果不再访问数据库。2.2 CacheKey 构成CacheKey statementIdnamespace.方法名 RowBoundsoffset / limit BoundSql 的 SQL 文本 各参数值按 parameterMappings 顺序 environment id任一要素不同即视为不同查询。这解释了同一方法不同参数不会互相污染缓存而动态 SQL 拼出的不同 SQL 文本也会产生不同缓存键。2.3 失效时机以下任一情况发生后会话的一级缓存被清空时机说明insert / update / delete 执行后同会话任意写语句无论是否影响缓存中的数据commit / rollback事务边界close会话关闭flushCachetrue语句级强制刷新查询语句也可设置session.clearCache()手动清空不清理二级缓存注意写操作清空的是整个会话缓存不是精准失效某条缓存——MyBatis 不做依赖分析不知道哪条查询依赖了被更新的表。2.4 localCacheScopesettingssettingnamelocalCacheScopevalueSESSION/!-- 默认会话级 --!-- setting namelocalCacheScope valueSTATEMENT/ --/settingsSTATEMENT模式下每条语句执行完即清空一级缓存等价于放弃会话内重复查询优化用于规避特定脏读场景见第三节。2.5 Spring 整合下的实际表现重要SqlSessionTemplate在无事务场景下每次 Mapper 方法调用都创建独立 SqlSession、执行完即关闭——因此一级缓存在两次独立调用之间基本不会命中。只有在同一Transactional方法内事务同步机制让多次调用共享同一 SqlSession一级缓存才真正生效。推论很多团队「一级缓存没起作用」的困惑根源就是无事务调用模式这不是 Bug是 SqlSessionTemplate 的生命周期设计。三、一级缓存脏读分析3.1 场景还原教学示例时间轴 t1 会话ASELECT * FROM user WHERE id 1 → name张三写入 A 的一级缓存 t2 会话BUPDATE user SET name李四 WHERE id 1提交 t3 会话ASELECT * FROM user WHERE id 1 → 命中一级缓存返回张三脏读 t4 会话A任意写操作/commit/close → 缓存清空之后查询才能读到李四3.2 窗口与本质窗口期A 首次查询之后到 A 会话内发生任何写操作/关闭之前。本质缓存读取绕过了数据库数据库隔离级别即使 SERIALIZABLE也管不到应用进程内的 HashMap。影响放大长会话如批处理任务中一个 SqlSession 处理整个批次窗口更大分布式多节点下各节点的会话缓存互不感知。3.3 缓解手段手段代价localCacheScopeSTATEMENT放弃会话内重复查询优化缩短 SqlSession 生命周期Spring 无事务模式天然如此关键业务读后校验 /flushCachetrue语句级精准控制增加 DB 压力四、二级缓存Global Cache4.1 开启方式!-- Mapper XMLnamespace 级开启 --mappernamespacecom.example.mapper.UserMappercacheevictionLRUflushInterval60000size512readOnlyfalse/.../mapper// 注解方式CacheNamespace(evictionLruCache.class,flushInterval60000,size512)publicinterfaceUserMapper{...}前提全局cacheEnabledtrue默认即为 true仅控制是否允许readOnlyfalse默认时实体类必须实现 Serializable——缓存存取的是序列化副本。4.2 cache 属性详解属性默认值说明evictionLRU淘汰策略LRU最近最少使用/ FIFO / SOFT软引用/ WEAK弱引用flushInterval不设置定时全量清空间隔毫秒不设置则只靠写操作触发刷新size512最多缓存多少个「语句结果」条目readOnlyfalsefalse返回序列化副本安全、有开销true返回共享引用快但调用方修改会污染缓存4.3 写入与刷新机制写入时机——TransactionalCache二级缓存被TransactionalCache装饰查询结果先放入事务临时区事务提交或会话关闭后才真正写入缓存——避免未提交数据被其他会话读到。刷新时机该 namespace 内任意 C/U/D 语句执行后清空整个 namespace 缓存写语句flushCache默认为 true。语句级控制!-- 该查询不使用二级缓存 --selectidselectRealtimeuseCachefalse...!-- 该查询执行前强制刷新二级缓存 --selectidselectFreshflushCachetrue...4.4 两类经典脏读脏读一跨 namespace场景教学示例 OrderMapper.selectOrderWithUserJOIN user 表结果进入 OrderMapper namespace 缓存 UserMapper.updateName更新 user 表 → 只刷新 UserMapper namespace 缓存 → OrderMapper 的 JOIN 缓存仍旧有效返回过期的用户名MyBatis 不追踪 SQL 依赖了哪些表缓存失效以 namespace 为单位跨 namespace 的表依赖必然出现失效盲区。脏读二多节点集群部署 节点A、节点B 各自持有本地二级缓存JVM 内存 HashMap 节点A 执行更新 → 只刷新节点A 自己的缓存 → 流量打到节点B 时持续读到旧值且无失效广播4.5 结论二级缓存的设计目标是「单 namespace 只读为主的数据加速」其失效粒度namespace与部署形态单机 JVM都难以匹配现代微服务场景。生产环境一般不开启二级缓存缓存需求交给应用层外部缓存见第六节。五、缓存执行顺序与装饰结构5.1 查询完整路径// CachingExecutor.query简化Cachecachems.getCache();// 该语句所属 namespace 的二级缓存if(cache!nullms.isUseCache()){ListEresult(ListE)tcm.getObject(cache,key);// 查二级缓存if(resultnull){resultdelegate.query(ms,param,rowBounds,handler,key,boundSql);// ↑ 委托被装饰 ExecutorBaseExecutor 内部再查一级缓存tcm.putObject(cache,key,result);// 暂存提交后生效}returnresult;}returndelegate.query(...);// BaseExecutor.query简化一级缓存ListEresult(ListE)localCache.getObject(key);if(resultnull){resultqueryFromDatabase(...);// 真正执行 SQLlocalCache.putObject(key,result);}returnresult;5.2 Cache 装饰链MyBatis 用装饰器模式组合缓存能力cache/的典型装配结果TransactionalCache提交时才写入 └── SynchronizedCache并发安全 └── LoggingCache命中率日志 └── SerializedCachereadOnlyfalse 时的序列化副本 └── LruCache淘汰策略 └── PerpetualCacheHashMap 基础实现各装饰器职责装饰器职责PerpetualCacheHashMap 存取一切缓存的地基LruCache / FifoCache / SoftCache / WeakCache淘汰策略SerializedCache存取时序列化/反序列化保证返回副本LoggingCache记录命中率SynchronizedCachesynchronized 包装TransactionalCache事务提交前暂存提交后提交到下层六、生产实践与外部缓存整合6.1 实践结论缓存建议一级缓存保留默认长会话 高一致性要求的批处理场景评估localCacheScopeSTATEMENT二级缓存生产不开启不写cache/仅接受 namespace 粒度失效的单机只读场景可例外6.2 自定义 Cache 对接 Redis技术上可以实现org.apache.ibatis.cache.Cache接口把存储指向 RedispublicclassRedisCacheimplementsCache{OverridepublicStringgetId(){returnnamespaceId;}OverridepublicvoidputObject(Objectkey,Objectvalue){/* redis set */}OverridepublicObjectgetObject(Objectkey){/* redis get */}OverridepublicObjectremoveObject(Objectkey){/* redis del */}Overridepublicvoidclear(){/* 按 namespace 前缀删除 */}// getSize 等省略}但不推荐原因失效粒度仍是 namespace跨表 JOIN 的脏读问题原样存在TransactionalCache 提交后写入仍存在「DB 已提交、缓存未写入」的一致性窗口缓存键由 MyBatis 生成CacheKey无法按业务语义管理与精准失效。6.3 推荐模式应用层缓存读路径应用代码 → Spring CacheCaffeine/Redis→ 未命中 → MyBatis 查询 → 回填缓存 写路径更新数据库 → 删除对应缓存 keyCache-Aside要点单机高频读用 Caffeine进程内、纳秒级跨节点共享用 Redis失效策略首选「先更新 DB再删缓存」而非更新缓存避免并发写覆盖与无效计算缓存 key 按业务语义设计如user:profile:{id}粒度可控与 MyBatis 解耦缓存逻辑在 Service 层切换 ORM 框架不受影响。6.4 认知分层ORM 内建缓存解决的是「会话内 / 命名空间内」的重复读优化不解决分布式一致性。分布式场景的一致性要靠外部缓存 明确的失效协议删缓存、延迟双删、binlog 订阅等来保证——这是缓存选型的基本分界线。七、总结两级结构查询顺序为二级缓存namespace 级→ 一级缓存SqlSession 级→ 数据库一级默认开启、二级默认关闭。一级缓存PerpetualCache 实现CacheKey 由 statementId 分页 SQL 参数 environment 构成写操作/提交/关闭时整体清空localCacheScopeSTATEMENT可缩小作用域。一级缓存的真相Spring 无事务模式下每次调用独立 SqlSession一级缓存基本不命中事务内才真正生效。一级缓存脏读同会话先查后被他事务更新缓存绕过数据库导致读到旧值数据库隔离级别无法防御只能靠缩小作用域或缩短会话生命周期。二级缓存机制TransactionalCache 保证提交后才写入失效以 namespace 为单位C/U/D 触发全量清空。二级缓存两大脏读跨 namespace 的 JOIN 依赖失效盲区多节点缓存独立无失效广播。因此生产环境不建议开启。装饰器架构Cache 接口经 PerpetualCache → 淘汰策略 → 序列化 → 日志 → 同步 → 事务逐层装饰是 MyBatis 扩展性设计的范例。生产方案应用层 Spring Cache Caffeine/RedisCache-Aside 失效策略按业务 key 管理——把分布式一致性问题放在正确的层次解决。八、常见高频面试题1. MyBatis 一级缓存和二级缓存的区别要点一级缓存 SqlSession 级、默认开启、PerpetualCacheHashMap实现、写操作/提交/关闭即清空二级缓存 namespace 级、需cache/显式开启、跨 SqlSession 生效、TransactionalCache 保证提交后写入、C/U/D 后按 namespace 全量清空。查询顺序二级 → 一级 → DB。2. 一级缓存的缓存键包含哪些内容为什么这样设计要点statementId RowBounds SQL 文本 参数值 environment id。设计意图同方法不同参数隔离、动态 SQL 不同结构隔离、分页不同区间隔离任一要素不同即不同缓存条目。3. 一级缓存有什么脏读风险如何规避要点会话 A 查询后数据被他事务/会话更新并提交A 再次查询命中缓存返回旧值——缓存绕过数据库隔离级别防不住。规避localCacheScopeSTATEMENT、缩短会话生命周期Spring 无事务模式天然如此、关键语句 flushCache“true”。4. 为什么生产环境不建议开启 MyBatis 二级缓存要点① 失效粒度是 namespace多表 JOIN 的查询缓存不会因关联表被他 namespace 更新而失效跨 namespace 脏读② 缓存是 JVM 本地的多节点无失效广播③ 默认序列化副本有开销。替代方案应用层 Caffeine/Redis Cache-Aside。5. 二级缓存的写入时机为什么要这样设计要点事务提交或会话关闭后由 TransactionalCache 提交写入。目的防止未提交数据进入缓存被其他会话读到脏数据入缓存。6. Spring 整合后一级缓存还有效吗要点取决于事务。无事务时 SqlSessionTemplate 每次调用创建独立 SqlSession会话间不共享一级缓存基本不命中同一 Transactional 内共享 SqlSession一级缓存生效且事务内写操作会清空缓存。7. readOnly 属性的作用true 和 false 的区别要点false默认存取序列化副本各调用方拿到独立对象安全但有性能开销实体需 Serializabletrue 直接返回缓存中对象引用性能好但调用方修改返回值会污染缓存只适合严格只读的数据。8. MyBatis 缓存与 Redis 缓存如何取舍要点MyBatis 内建缓存是会话/namespace 粒度的进程内优化解决重复读不解决分布式一致性Redis 是分布式共享缓存可按业务 key 精准失效、支持集群。生产做法关闭二级缓存用 Spring Cache 抽象统一接入 Caffeine单机热点或 Redis共享写操作采用先更新 DB 再删缓存。
