ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MyBatis缓存机制详解:一级缓存、二级缓存与Spring整合实战

MyBatis缓存机制详解:一级缓存、二级缓存与Spring整合实战 这是我 MyBatis 系列的第 43 篇也是被催更最久的一篇MyBatis 缓存机制。网上讲缓存的文章不少但大多停在“一级缓存是 SqlSession 级别、二级缓存是 Mapper 级别”的层面真正能把命中规则、失效条件、Spring 环境下的行为差异讲清楚的并不多。这篇我打算把这些全部串起来先讲两级缓存整体结构再从源码层面拆解读写链路接着分析 Spring Boot 整合后的实际表现最后给出一份可以当面试底稿的问题清单以及一次线上脏数据排查的完整复盘。适合正在用 Spring Boot MyBatis 做业务开发、准备 MyBatis 面试或者想深入源码的读者。1. 两级缓存整体结构与核心链路1.1 一级缓存SqlSession 里的本地缓存先看最核心的类Executor。MyBatis 执行 SQL 的所有操作都经过 Executor一级缓存就挂在 BaseExecutor 这个基类上。BaseExecutor 构造的时候会创建一个 PerpetualCache 对象内部本质就是一个 HashMap名字叫 localCache。每个 SqlSession 创建时都会 new 一个 Executor 实例也就是说每个 SqlSession 都带着自己独立的一份 localCache互相之间完全不共享。查询流程可以这样理解客户端调用 Mapper 方法后经过 SqlSession 到 ExecutorExecutor 先根据当前的查询语句、参数、分页等信息生成一个 CacheKey拿这个 key 去 localCache 里查查到了就直接返回结果不再触碰数据库没查到才真正走 JDBC并把结果以 CacheKey 为 key 放回 localCache供后续使用。用生活里的场景类比一级缓存就像厨房灶台旁随手放的调料同一个厨子同一个 SqlSession在短时间内连续做菜伸手就能拿到不用每次跑去楼下超市数据库。不过要记住它的边界SqlSession 一旦关闭缓存就跟着销毁。后面我会专门讲 Spring 场景下这个生命周期会被压缩到什么程度。1.2 二级缓存跨 SqlSession 的命名空间级缓存二级缓存常被叫作“Mapper 级别”缓存更准确的说法是 namespace 级别。在 XML 映射文件里namespace 就是mapper namespace...指定的那个名字如果用的是注解方式namespace 对应的就是当前 Mapper 接口的类名。二级缓存的作用范围比一级缓存大得多同一个 namespace 下的所有 SqlSession 可以共享同一份缓存数据。需要澄清一个常见误解MyBatis 的配置项 cacheEnabled 默认值实际上是 true但光有这个还不够必须同时在 mapper 里声明cache/标签或加CacheNamespace注解二级缓存才会真正生效。所以大家平时说“二级缓存默认不开启”本质上是指“默认情况下没有为任何 mapper 创建对应的 Cache 对象”。从存储上看Configuration 对象里维护了一个MapString, Cachekey 是 namespacevalue 就是二级缓存对象。查询时通过 MappedStatement 找到当前语句所属的 namespace再取出对应的 Cache。二级缓存解决的问题很直接在同一个命名空间里SqlSession A 查过的数据SqlSession B 也能直接命中不用重新查数据库。1.3 缓存配置在初始化流程中是怎么落地的这里把初始化流程顺带理一下因为网上很多人问 MyBatis 的 XMLConfigBuilder 到底在初始化时做了什么事。启动时 XMLConfigBuilder.readConfiguration 会解析 mybatis-config.xml其中 settingsElement 方法会把 cacheEnabled、localCacheScope、logImpl 等配置写进 Configuration 对象随后解析每个 mapper 文件时XMLMapperBuilder 如果遇到cache/标签会调用 cacheElement 方法解析属性再交给 CacheBuilder 构建真正的 Cache 对象并注册到 Configuration 里。CacheBuilder 默认构建的底层存储是 PerpetualCache然后根据 eviction 属性套上对应的装饰器比如 LruCache、FifoCache根据 readOnly 决定是否包一层 SerializedCache默认还会包 LoggingCache 和 SynchronizedCache。这些都是装饰器模式的应用也是 MyBatis 源码里比较值得读的一块。另外实际项目中常见的“自定义 Configuration”玩法比如修改环境 ID、自定义 setting 解析、加全局参数校验本质上都是围绕这些初始化阶段的操作在做文章。TypeHandler 不直接参与缓存构建但它负责把 JDBC 类型的值转换成 Java 对象而二级缓存缓存的是转换完成后的对象所以当 TypeHandler 实现不当或对象序列化方式有问题时也可能间接踩到缓存序列化的坑。2. 一级缓存命中规则、失效场景与源码级拆解2.1 CacheKey 是怎么算出来的很多人在面试里会被问到 CacheKey 的组成。MyBatis 创建 CacheKey 时会把 MappedStatement 的 id、分页参数 offset 和 limit、BoundSql 里的完整 SQL、查询参数、environmentId 全部追加进去然后组合成一个对象。最终一级缓存查询时比较的不是 String 类型的 key而是整个 CacheKey 对象的 equals 方法。这就带来一个很容易被忽略的结论SQL 相同、参数值不同CacheKey 一定不同绝不可能命中。有人以为一级缓存像 Redis 那样按前缀模糊匹配完全不成立。比如select * from blog where id ?传 id1 和 id2在同一个 SqlSession 里连续执行结果不会互相命中因为 key 里的参数对象不同。还有一点要提醒使用${}拼接 SQL 时SQL 文本本身就变了CacheKey 自然也不同使用#{}预编译时 SQL 文本不变但参数对象变了CacheKey 同样不同。另外 RowBounds 参与 CacheKey 计算所以分页参数不同的查询也不命中。2.2 一级缓存的 4 个失效场景我整理了一个速查表面试基本围绕这几个点展开。失效场景底层原因说明两次查询之间执行了增删改BaseExecutor.update 会先调用 clearLocalCacheupdate/insert/delete 后一级缓存整体清空使用不同 SqlSession 查询每个 SqlSession 持有独立的 localCache跨 SqlSession 无法命中彼此缓存查询条件、SQL、分页参数变化CacheKey 内容发生变化equals 不相等自然不命中手动调用 sqlSession.clearCache()显式清空当前 SqlSession 的本地缓存主要为应对特殊业务需要其中第一个场景最容易被踩。同一个 SqlSession 内先查询、再更新、再查询同一条数据第二次查询一定不会命中缓存而是重新查库这正是 MyBatis 为了避免脏读做的基本保障。BaseExecutor.update 里执行 doUpdate 之前就会 clearLocalCache所以无论更新是否真的改了数据缓存都会被清掉。还有一个隐藏失效点如果 select 标签上配置了flushCachetrue那么即使它是一条查询执行前也会清空一级缓存。这个参数在嵌套查询场景中要特别注意配置不当会导致同一段嵌套查询里反复打数据库。2.3 一级缓存可以关闭吗什么场景需要关一级缓存默认是开启的localCacheScope 配置项默认值是 SESSION。也就是说默认情况下同一个 SqlSession 内部所有查询共享一份缓存。如果把 localCacheScope 改成 STATEMENT那么每条查询语句执行完就立刻 clearLocalCache等效于关闭一级缓存。从定位上讲一级缓存的核心价值是为嵌套查询提供一个保护伞。ResultMap 里通过 association/collection 做嵌套子查询时外层查一批文章列表内层每篇文章查一次作者信息如果写了循环N 篇文章就会产生 N 次作者查询这时代理对象又处于同一个 SqlSession 内一级缓存可以将相同 authorId 的子查询收敛成一次数据库访问。我见过一些团队因为“一级缓存导致数据不变”就一刀切改成 STATEMENT结果性能明显下降。正常业务开发中一级缓存出问题的概率很低不建议随意关闭只有在明确需要拿到最新数据且能够接受每次查询都走数据库的场景下才去改它。2.4 Spring 场景下一级缓存为什么经常“等于没有”这是在 Spring Boot MyBatis 项目里最常见的误区。很多人说“MyBatis 一级缓存在 Spring 下没什么用”这个说法需要加前提。MyBatis-Spring 整合后Mapper 实际是一个动态代理调用 Mapper 方法最终会走进 SqlSessionTemplate。SqlSessionTemplate 每次获取 SqlSession 时调用 SqlSessionUtils如果当前线程已经通过 Spring 事务同步机制绑定了一个 SqlSession就复用这个否则新建一个。没有事务的情况下每次 Mapper 方法调用都会新建 SqlSession方法执行完立刻关闭一级缓存随之销毁。所以哪怕你在同一个业务方法里连续执行两次相同的查询如果中间没有事务包裹第二次仍然要重新查库。有事务的情况下整个事务范围会复用同一个 SqlSession一级缓存跨 Mapper 方法生效此时性能收益非常明显。这个差异直接决定了我们调优的思路想让一级缓存发挥价值优先保证业务方法有事务想让一级缓存“不捣乱”只要确保无事务时不要依赖它的命中即可。3. 二级缓存三步开启、读写链路与隐藏很深的坑3.1 开启二级缓存的三步走开启二级缓存需要同时满足几个条件。第一步确认 settings 里 cacheEnabled 是 true默认值就是 true如果你没动过基本不用管第二步在 mapper XML 中加cache/标签或给 Mapper 接口加CacheNamespace第三步如果 readOnly 为 false实体类必须实现 Serializable否则序列化缓存时会抛异常。一个完整的配置示例如下。settings setting namecacheEnabled valuetrue/ setting namelocalCacheScope valueSESSION/ /settingsmapper namespacecom.example.mapper.BlogMapper cache evictionLRU flushInterval60000 size512 readOnlyfalse blockingfalse/ select idselectById resultTypeBlog useCachetrue select * from blog where id #{id} /select /mapper这里有两个补充参数很实用select 上的 useCache 可以控制这条查询是否走二级缓存默认 trueflushCache 可以控制这条查询执行后是否清空二级缓存查询语句默认 false。到了第 3.4 我会讲为什么要用它们。3.2 cache 标签里的参数每个都对应一种语义参数默认值说明实际建议evictionLRU缓存淘汰策略可选 LRU、FIFO、SOFT、WEAK读多写少选 LRU实时性要求高用 FIFOflushInterval无周期性清空缓存单位毫秒设置太短没意义太长容易读到旧数据size1024最多缓存多少个对象根据数据量和内存来调不要盲目加大readOnlyfalsetrue 表示返回同一实例false 表示序列化复制默认 false但要求实体可序列化blockingfalsetrue 时使用 BlockingCache未命中会加锁适合并发高、怕穿透的场景eviction 这四个值里SOFT 和 WEAK 是基于 Java 弱引用和软引用的淘汰策略一般 Web 应用不建议用垃圾回收的不确定性太强。blockingtrue 的原理是给每个 CacheKey 加锁一个线程查不到缓存时其他线程会等待而不是同时冲进数据库相当于单机版的防止缓存穿透措施但要注意锁粒度是按 key 的极端场景下可能造成线程阻塞。3.3 二级缓存读写链路与事务提交的关系二级缓存为什么要等事务提交后才对其他会话可见这是源码层面的设计。MyBatis 里有一个 CachingExecutor专门负责二级缓存读写。它内部维护了一个 TransactionalCacheManager每个 SqlSession 对应一组 TransactionalCache。TransactionalCache 的核心思想是本次查询结果先不直接写入真正的二级缓存而是放进当前 SqlSession 的“待提交区域”等事务 commit 时统一 flush 进去如果事务回滚这些待提交的数据直接丢弃。完整查询流程按顺序是这样的CachingExecutor 从 MappedStatement 中取出当前 namespace 的 Cache 对象如果这条语句配置了 flushCachetrue先清空这个 Cache调用 TransactionalCacheManager.getObject 查二级缓存命中就直接返回未命中则把查询交给内部包装的 BaseExecutorBaseExecutor 内部先查一级缓存没查到再走数据库数据库返回结果后同时放进一级缓存CachingExecutor 再把结果放进当前 SqlSession 的 TransactionalCache 待提交区域事务提交时TransactionalCacheManager 把所有缓存数据真正写入二级缓存。更新流程同样关键。CachingExecutor.update 会先根据 MappedStatement 的 flushCacheRequired 决定是否清空二级缓存然后再执行真正的 update同时 BaseExecutor.update 也会清掉一级缓存。所以“增删改会清空缓存”这句话基本成立但必须准确理解成只清空当前 namespace 对应的二级缓存其他 namespace 的缓存不受影响。这就是下面脏读问题的导火索。3.4 二级缓存的三个高频坑第一个坑是跨 namespace 脏读。假设 OrderMapper 和 OrderStatMapper 的 SQL 都涉及 order 表OrderMapper 更新订单后只会清空 OrderMapper 这个 namespace 的缓存OrderStatMapper 里的统计缓存还在用户查统计就可能一直看到旧数据。解决办法有三个一是多表关联查询的语句不开启二级缓存二是用cache-ref namespaceOrderMapper/让多个 namespace 共享同一个 Cache 对象三是干脆不用本地二级缓存统一走 Redis。第二个坑是序列化。readOnlyfalse 时结果要经过 SerializedCache 做深拷贝实体没有实现 Serializable 会直接抛异常。更麻烦的是实体内部如果嵌套了其他对象、集合、甚至 MyBatis 懒加载代理序列化时会连带触发代理初始化问题非常隐蔽。我实际遇到过一次实体类实现了 Serializable但内部某个字段是第三方类它的字段是 final反序列化后状态不完整排查了很久。第三个坑是分布式环境下的缓存漂移。二级缓存在每个应用节点本地各存一份节点 A 更新数据库后只清自己那份节点 B 的缓存依然是旧的。集群规模越大这个问题越明显这也是很多团队坚持不开启 MyBatis 二级缓存的原因。3.5 把二级缓存替换成 Redis 的可行方案如果你确实想让 MyBatis 的二级缓存基于 Redis 实现需要实现 org.apache.ibatis.cache.Cache 接口。核心方法就六个getId、putObject、getObject、removeObject、clear、getSize。一个最简实现思路是这样的。public class RedisCache implements Cache { private final String id; private final RedisTemplateObject, Object redisTemplate; public RedisCache(String id) { this.id id; this.redisTemplate SpringContextHolder.getBean(redisTemplate, RedisTemplate.class); } Override public String getId() { return id; } Override public void putObject(Object key, Object value) { redisTemplate.opsForValue().set(id : key, value, 30, TimeUnit.MINUTES); } Override public Object getObject(Object key) { return redisTemplate.opsForValue().get(id : key); } Override public Object removeObject(Object key) { Object value getObject(key); redisTemplate.delete(id : key); return value; } Override public void clear() { // 按 id 前缀批量删除 } Override public int getSize() { return 0; } }然后在 mapper 里指定cache typecom.example.cache.RedisCache/。这里有几个细节key 必须带上 namespace 前缀否则不同 Mapper 之间可能互相覆盖value 要满足 Redis 的序列化要求JSON 或 JDK 序列化都可以clear 方法不能只是删当前 key要按前缀把整个 namespace 的缓存全部删掉。不过说实话在自己动手之前建议先评估一下团队里已经引入了 Redis 做业务缓存往往直接在 Service 层做缓存更灵活可以自定义过期时间、淘汰策略、缓存 key 结构而不是让 MyBatis 接管 Redis 连接。我在多个项目里对比过Service 层统一管理缓存的代码可维护性明显优于改造 MyBatis Cache 的方式。4. Spring 整合场景下缓存机制的变化与实践选型4.1 SqlSession 在 Spring 里的生命周期真相前面提过 SqlSessionTemplate 是 MyBatis-Spring 的门面。每次调用 Mapper 方法实际是进入 SqlSessionTemplate 的动态代理逻辑通过 SqlSessionUtils.getSqlSession 获取一个 SqlSession。SqlSessionUtils 的规则可以总结成一句话当前线程有 Spring 事务同步时复用事务绑定的 SqlSession没有则新建。无事务和事务两种状态下MyBatis 缓存表现截然不同。无事务时每个 Mapper 方法执行完 SqlSession 就关闭一级缓存每次请求等于从零开始有事务时从进入事务到提交整个线程共享同一个 SqlSession一级缓存能跨方法命中。所以一个很实用的调优建议是对于同一个业务方法内有多次相同查询的情况加Transactional可以显著提高一级缓存命中率同时还能保证数据一致性。这里要特别纠正一个直觉循环里调用同一个 Mapper 方法并不等价于在同一个 SqlSession 里执行。无事务时每次调用都会获取新 SqlSession 并关闭循环一万次就是一万次独立查询。真正想批量复用应该显式指定 ExecutorType或者干脆用 MyBatis 的 SqlSessionTemplate 手动管理 SqlSession 的生命周期。4.2 MyBatis-Plus 批量操作与缓存清理的关系很多人用 MyBatis-Plus 的 saveBatch 做批量插入同时还在 Mapper 上开着二级缓存这会产生一个非常尴尬的现象saveBatch 底层是同一个 insert MappedStatement 执行多次批量参数每次插入都会触发 flushCache 清空整个 namespace 的二级缓存等于批量期间缓存一直被反复清空任何查询都享受不到命中。另一个容易困惑的是 MP 分页插件。分页查询时会自动执行 count 查询和列表查询它们生成的 CacheKey 不一样所以二级缓存的命中率日志里会出现 count 不命中的现象这是正常的不是 bug。逻辑删除和自定义注入方法同理底层还是 MyBatis 的 Executor 机制只是 MP 帮你省去了写 XML 的重复劳动。所以无论用不用 MyBatis-Plus缓存机制的核心没变。遇到“为什么开完 MP 批量后缓存失效”这类问题先回到 MyBatis 本身的 update 清缓存逻辑去找答案比在 MP 配置里翻半天更有效。4.3 企业实践本地缓存 Redis 的分层策略经历了各种线上事故后我在团队里推的默认方案很简单MyBatis 一级缓存保持默认 SESSION不特意关二级缓存不开启除非某个查询的实时性要求极低、语句只涉及单表且团队能接受手动刷新需要跨请求共享的数据统一走 Redis放在 Service 层做。Service 层缓存的基本套路是key 设计带上业务前缀和参数value 统一 JSON设置合理过期时间更新数据时先更新数据库再删除 Redis 中对应的 key让下一次请求重建缓存对热点 key 加锁防止缓存击穿对批量接口增加兜底逻辑。这套方案下 MyBatis 只负责 ORM缓存一致性的所有问题都收拢到一个明确的管理层排查起来心里有底。如果你仍然想保留 MyBatis 二级缓存我见过的相对稳妥组合是单表查询、读并发高、写频率低、允许分钟级延迟并且系统是单节点部署同时给 flushInterval 设置一个可容忍的窗口。只要集群化本地二级缓存的收益就会被一致性成本抵消。5. 面试考点与问题排查实录5.1 高频面试题速查表问题参考答案要点一级缓存和二级缓存有什么区别一级缓存是 SqlSession 级二级缓存是 namespace 级一级默认开启二级需要显式配置一级缓存有哪些失效场景增删改后清空、SqlSession 不同、CacheKey 不同、手动 clearCache一级缓存可以关闭吗可以把 localCacheScope 设为 STATEMENT或直接不依赖 SqlSession 复用二级缓存如何开启cacheEnabledtruemapper 中配置 cache 标签实体实现 Serializable二级缓存为什么会产生脏数据namespace 之间缓存隔离跨表更新无法联动清空其他 namespace 的缓存二级缓存的读写为什么跟事务绑定TransactionalCache 先暂存commit 才写入真实缓存rollback 丢弃CacheKey 包含哪些元素MappedStatement id、SQL、参数、RowBounds、environmentId 等Spring 环境下一级缓存为什么常常无效无事务时每次 Mapper 方法调用创建并关闭新的 SqlSession如何查看二级缓存命中率配置 logImpl 为 StdOutImpl日志中出现 Cache Hit Ratio这里面最容易被问住的是 TransactionalCache 那题。背出“先暂存再提交”还不够最好能把提交前对其他会话不可见这个副作用讲出来。5.2 一次线上“缓存导致数据变旧”的排查复盘有个后台报表接口每天早上定时任务更新数据白天运营查询时偶尔会看到旧数据应用重启后立刻恢复。排查时先看日志发现日志里大量出现Cache Hit Ratio [ReportMapper]: 0.92这说明数据基本都从二级缓存返回了数据库里已经更新的数据根本走不到查询层。接着看 cache 配置flushInterval 设成了 3600000也就是一小时刷新一次而定时任务更新数据后没有任何主动清缓存的动作。更严重的是这条报表 SQL 关联了多张表其他 Mapper 更新关联表时只清自己的 namespaceReportMapper 的缓存纹丝不动。最终方案是去掉 ReportMapper 的二级缓存改成 Service 层的 Redis 缓存定时任务更新后主动删除对应的业务缓存 key。这次复盘让我意识到一个问题很多人遇到数据不变第一反应是去查 MyBatis 的缓存配置但如果不先确认“这条 SQL 到底有没有真正执行”所有猜测都是空转。正确顺序永远是先看日志确认是缓存命中还是真的走了数据库再决定要不要动缓存方案。5.3 配置打印与缓存命中率排查技巧排查缓存问题最重要的工具是把 SQL 日志打出来。MyBatis 支持配置输出 SQL 日志方式是在 settings 里设置 logImpl也可以在 Spring Boot 的 yml 里直接写。settings setting namelogImpl valueSTDOUT_LOGGING/ /settingsmybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl配置之后控制台会输出每个 Mapper 的Cache Hit Ratio [xxx]。这个数值代表二级缓存命中率。如果命中率接近 1.0说明查询几乎全走缓存如果长期是 0说明没命中如果日志里压根没有这一行说明当前 namespace 没有开启二级缓存。一级缓存因为没有命中率日志只能靠对比 SQL 产生次数判断。同一个 SqlSession 内同一条 SQL 第二次执行如果没有再次打印 Preparing多半是命中了一级缓存。如果再次打印了 Preparing说明要么 CacheKey 变了要么中间执行过 clearLocalCache。记住这个判断逻辑排查时就能少走弯路。我个人在实际操作中的体会是缓存机制看着简单真正难点永远在“边界”上SqlSession 生命周期、Spring 事务边界、namespace 隔离、事务提交时机每一个边界漏掉了都可能让线上数据出现旧值。面试时把第 2、3 章讲明白基本就够用但生产环境里我强烈建议优先采用 Service 层 Redis 的统一缓存方案让 MyBatis 回归 ORM 的本职这套组合是我踩过多次坑之后最推荐的选择。
返回列表