尧图精选

MyBatis缓存机制完全解析:一级缓存、二级缓存原理与实战排坑

🕒 发布时间:2026/10/1 20:42:18 📁 来源:尧图网络
最近收到不少读者关于MyBatis缓存的私信问的内容几乎能拼出一套完整的高频面试题一级缓存和二级缓存到底什么区别为什么SpringBoot项目里连续调用两次查询SQL却照样打印两次为什么开了二级缓存反而读到脏数据还有人在看源码时被CacheKey、TransactionalCacheManager、装饰器这一堆类搞得晕头转向。这些问题我自己在项目里都踩过也有过拿着源码一点点对日志的排查经历。今天就把MyBatis缓存机制从架构设计到源码原理、从配置细节到实战坑位完整拆一遍包含我实测过的代码和排查记录希望能帮你彻底把这个机制吃透。1. MyBatis缓存机制整体架构与设计思路1.1 两级缓存的定位与分工MyBatis的缓存体系分为两级这个划分本质上是按照生命周期和作用范围来设计的而不是随意拍脑袋定出来的。一级缓存也叫本地缓存Local Cache作用范围是SqlSession这一个会话。也就是说在同一个数据库会话里连续执行两次完全相同的查询第二次会直接从内存中拿结果不再发SQL。一级缓存在MyBatis中默认开启不需要任何配置。它的生命周期非常短会话关闭、提交、回滚或执行增删改操作都会让它失效。二级缓存也叫全局缓存Second Level Cache作用范围是同一个Mapper命名空间namespace可以跨多个SqlSession共享。简单理解就是线程A查了一次UserMapper线程B再去查同一个UserMapper时能直接命中线程A留下的缓存。二级缓存默认关闭需要我们在Mapper映射文件里显式开启。这里有个非常容易混淆的点查询顺序。当二级缓存开启后一次查询请求从 Executor 开始会先查二级缓存没命中再到一级缓存一级也没有会去数据库查最后把结果逐级回填。这个顺序决定了二级缓存优先级更高也决定了命中二级缓存时压根不会走到一级缓存。维度一级缓存二级缓存默认开启是否作用范围SqlSession会话级Mapper namespace映射级共享范围同一会话内跨会话、跨线程生命周期短随会话结束而结束长随Mapper清理底层实现PerpetualCacheHashMap装饰器包装的PerpetualCache与Spring整合后无事务时基本失效不受事务影响1.2 缓存实现的核心链条要理解MyBatis缓存绕不开一个核心设计Executor执行器体系。MyBatis的SQL执行并不是一条直线而是包了一层又一层。默认情况下核心执行器是BaseExecutor它内部维护着一个PerpetualCache localCache这就是一级缓存的本体其实就是一个HashMap的简单封装。当配置了二级缓存之后MyBatis会拿CachingExecutor把原来的执行器包一层形成一个装饰器链。整个调用链路是这样的SqlSession门面 → CachingExecutor二级缓存处理 → BaseExecutor执行SQL、一级缓存处理 → StatementHandler最终与数据库打交道CachingExecutor负责二级缓存的读写它内部持有一个TransactionalCacheManager事务缓存管理器用于管理每个Mapper各自对应的二级缓存区块。BaseExecutor则负责一级缓存的读写和SQL真正执行。这样设计的核心价值在于职责单一可插拔。缓存逻辑被装饰器拆开不需要侵入SQL执行的核心流程你想关闭二级缓存直接把CachingExecutor摘掉即可一级缓存逻辑完全不受影响。实际上源码里Configuration.newExecutor就是根据cacheEnabled属性决定要不要加这层装饰器。1.3 为什么设计成这种结构我在看源码时有个很深的体会MyBatis的缓存设计是谨慎的宁可不够智能也不愿意引入复杂的一致性控制。它没有像Hibernate那样提供精细的缓存策略而是把选择权完全交给使用者。一级缓存的失效条件非常激进——只要执行了增删改立刻清空整个localCache因为MyBatis无法追踪某条SQL到底影响了哪些表最安全的做法就是全清。二级缓存则干脆默认关闭因为跨会话共享意味着并发和脏数据风险成倍增加。从使用者角度我觉得理解这个结构可以帮你建立一张地图面试官问缓存你就按Executor装饰器 → 二级缓存 → 一级缓存 → 数据库这条链讲再补充每个环节的生命周期和失效条件基本能覆盖90%的问题。2. 一级缓存SqlSession级别的隐式缓存2.1 一级缓存的工作原理与源码演进一级缓存的代码核心在BaseExecutor里。它有一个字段protected PerpetualCache localCache;PerpetualCache是Cache接口的默认实现内部就是一个简单的HashMap。每次查询时BaseExecutor.query会先尝试从localCache取数据public E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { ListE list resultHandler null ? (ListE) localCache.getObject(key) : null; if (list ! null) { return list; } // 缓存没命中走数据库查询 list queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); return list; }这里的CacheKey是一级缓存命中的关键它由多个信息共同参与hash计算CacheKey key new CacheKey(); key.update(ms.getId()); // statementId也就是 mapperId方法名 key.update(rowBounds.getOffset()); // 分页偏移 key.update(rowBounds.getLimit()); // 分页条数 key.update(boundSql.getSql()); // 实际SQL语句 key.update(parameter); // 参数对象一句话概括同一个Mapper方法的相同SQL、相同参数、相同分页条件才能命中同一条缓存。这个设计本身是合理的但有个众所周知的坑如果参数对象没有正确实现equals/hashCode两个值相等的参数会被认为是不同的key导致缓存命中率暴跌。我排查过一个事故查询入参是一个MapMap的hashCode已经是正确的但问题出在Map里的某个值对象没重写hashCode导致两次查询生成不同CacheKey缓存形同虚设。2.2 一级缓存失效的常见场景我整理了几种一级缓存失效的具体场景都是我在实际项目中验证过的第一种SqlSession已关闭或重新创建。每次sqlSession.selectList()发生在不同会话中一级缓存自然无法共享。这在Spring整合场景下尤其常见后面专门讲。第二种执行了增删改操作。只要执行insert/update/deleteBaseExecutor.update方法会调用clearLocalCache()把整个localCache清空。这是最保守但也最正确的策略因为MyBatis无法知道一次update到底影响了哪些缓存数据。第三种手动调用sqlSession.clearCache()。这个方法会直接清空当前会话的一级缓存虽然平时很少用但当你需要强制刷新某个查询结果时会用到。第四种查询语句配置了flushCachetrue。在select标签上添加flushCachetrue后每次执行该查询之前都会先清空一级缓存。这个属性很多人不熟悉但它会导致你反复查询同一个数据每次都真实走数据库。第五种查询结果集过大或返回XML/JSON大数据时使用ResultHandler来处理。传入自定义ResultHandler后会绕过一级缓存因为MyBatis认为你已经自定义了消费逻辑缓存命中意义不大。2.3 实测一级缓存如何验证命中和清空我写了一个最简单的验证用例。假设有一张user表使用SpringBoot整合MyBatisMapper接口定义selectByIdSpringBootTest class LocalCacheTest { Autowired private SqlSessionFactory sqlSessionFactory; Test void testLocalCache() { // 手动创建同一个SqlSession能复现一级缓存 try (SqlSession session sqlSessionFactory.openSession()) { UserMapper mapper session.getMapper(UserMapper.class); User u1 mapper.selectById(1); User u2 mapper.selectById(1); System.out.println(u1 u2 是否为同一对象: (u1 u2)); // 打开SQL日志可以看到 selectById 只执行了一次 } } Test void testLocalCacheClearedAfterUpdate() { try (SqlSession session sqlSessionFactory.openSession()) { UserMapper mapper session.getMapper(UserMapper.class); mapper.selectById(1); // 第一次查询SQL执行 mapper.selectById(1); // 命中缓存SQL不执行 // 执行一个更新 User user new User(); user.setId(1); user.setName(新名字); mapper.updateById(user); // 触发 clearLocalCache() mapper.selectById(1); // 缓存已清空SQL重新执行 } } }前面这个测试里有一个细节当开启了useGeneratedKeys或keyGenerator的insert语句时MyBatis需要把自增主键回填到参数对象上所以即使插入操作也必然清空缓存。这是我们在分析缓存一致性时比较容易忽略的一点。日志输出里也能看到非常直观的对比第一次测试只打印一条select日志第二次测试会打印select、update、select三条日志。3. 二级缓存跨SqlSession的全局缓存3.1 二级缓存开启步骤与XML配置详解在Mapper XML文件里加上一个cache标签即可开启该namespace的二级缓存mapper namespacecom.example.mapper.UserMapper cache evictionLRU flushInterval60000 size512 readOnlytrue blockingfalse/ select idselectById resultTypecom.example.entity.User select * from user where id #{id} /select /mapper各个参数含义如下配置项可选值默认值说明evictionLRU、FIFO、SOFT、WEAKLRU淘汰策略LRU最常用flushInterval正整数毫秒无定时刷新缓存的时间间隔size正整数1024最多缓存的对象个数readOnlytrue/falsefalse是否只读缓存blockingtrue/falsefalse是否使用阻塞缓存防止并发穿透这里最容易被忽略的是readOnly参数。默认readOnlyfalse代表可读写缓存此时MyBatis为每个返回对象做序列化反序列化拷贝保证每次拿到的都是新对象避免并发修改污染缓存。但代价是所有缓存的POJO必须实现java.io.Serializable接口否则启动后第一次查询就会抛SerializationException。如果你的实体是继承某个框架基类或者引用了复杂的嵌套对象序列化还可能带来性能损耗。如果readOnlytrueMyBatis直接返回缓存对象的同一个引用读取速度更快但任何线程修改这个对象都可能影响缓存中的数据引发脏读。我的建议是大多数业务场景用默认readOnlyfalse即可追求极致读性能或缓存对象不可变时可考虑true。用注解方式对应的配置是CacheNamespace(eviction LruCache.class, flushInterval 60000, size 512, readWrite true) public interface UserMapper { ... }3.2 装饰器模式二级缓存的底层设计二级缓存的强大之处不在缓存本身而在于装饰器组合。MyBatis的Cache接口定义了缓存的基本行为真正工作的是围绕它的一圈装饰器public class Configuration { private final MapString, Cache caches new HashMap(); // ... }在解析cache标签时MyBatis会根据配置创建一条装饰器链通常是SynchronizedCache 保证线程安全 → LoggingCache 统计命中率 → ScheduledCache 定时清空 → LruCache LRU淘汰 → PerpetualCache 真正的HashMap当readOnlyfalse时还会在最外层加一层SerializedCache负责对象的序列化拷贝。BlockingCache在blockingtrue时替换SynchronizedCache实现简单的锁效果避免缓存失效瞬间大量请求同时穿透到数据库。每个装饰器只干一件事组合起来就有了完善的缓存治理能力。以前我在阅读这段源码时最大的感受是装饰器模式用得极其标准每一个关注点都被拆成独立的类你完全可以仿照这个思路为自己的缓存框架写扩展。比如面试时能把LoggingCache计算命中率、LruCache淘汰逻辑讲明白面试官会觉得你真是看过源码的。3.3 查询顺序与缓存更新策略二级缓存的查询入口在CachingExecutorpublic E ListE query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { Cache cache ms.getCache(); if (cache ! null) { // 先查二级缓存 ListE list (ListE) tcm.getObject(cache, key); if (list null) { // 二级缓存未命中再交给下一级执行器会走到一级缓存或数据库 list delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); // 把结果放进事务缓存管理器但不直接放入二级缓存 tcm.putObject(cache, key, list); } return list; } return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); }这里有个非常容易踩坑的点tcm.putObject并不是直接写入二级缓存而是先存到TransactionalCache里做暂存只有当前事务提交后才会真正写入二级缓存。如果事务回滚暂存的数据会被丢弃。对应地更新操作会触发public int update(MappedStatement ms, Object parameterObject) throws SQLException { flushCacheIfRequired(ms); return delegate.update(ms, parameterObject); }flushCacheIfRequired会检查这条SQL是否需要清空二级缓存。默认情况下insert/update/delete都会清空二级缓存但你可以在语句级别设置flushCachefalse来关闭这个行为。我不建议这样做除非你非常确定其他表不会修改这些数据。3.4 多表操作的脏读问题与实战坑二级缓存尽管好用但在多表关联场景下有一个非常著名的坑缓存的粒度是Mapper namespace不是表。举例OrderMapper里有一条SQLselectOrderWithUser它join了user表。MyBatis把查询结果缓存到OrderMapper的二级缓存里。另一个地方执行了UserMapper的updateById更新了用户昵称。因为更新的是UserMappernamespace的缓存OrderMapper的缓存完全不会被清空于是你读到的是更新前的旧用户昵称。我踩过的一次具体事故运营后台改完用户昵称C端接口查订单列表时仍显示旧昵称差不多持续到缓存被LRU自然淘汰才消失。排查到最后才发现就是OrderMapper里SQL关联了用户字段但缓存刷新依赖的是OrderMapper自己的更新操作。应对方案主要有三种只对低频变更的单表数据开启二级缓存关联查询、JOIN查询一律不开。设置合理的flushInterval即使别的Mapper更新了数据也不会长期命中脏数据。干脆不用内置二级缓存换成你自己的外部缓存如Redis再配合SQL级缓存key设计来控制失效。另外MyBatis-Plus对二级缓存的支持也不是无缝的。用MyBatis-Plus内置的selectById、selectList接口会走二级缓存但如果你在XML里自定义了SQLnamespace还是同一个缓存依然共享。最麻烦的是批量操作比如saveBatch内部Mapper每次insert都会触发缓存清空批量插入几千条数据会把二级缓存频繁清空导致有效命中率直线下降。4. 与Spring整合后缓存变化的原理与陷阱4.1 SqlSessionTemplate的代理逻辑很多人在SpringBoot项目里测缓存发现一级缓存好像失效了——同一个Mapper接口连续调用两次selectByIdSQL日志打印了两遍。这个问题解释起来要深入到 mybatis-spring 的SqlSessionTemplate。SqlSessionTemplate实现了SqlSession接口但它的所有方法都是通过动态代理转发给一个内部代理对象来执行的。这个代理会从Spring容器中获取SqlSession核心逻辑可以简化成public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 从当前事务上下文或连接池获取SqlSession SqlSession sqlSession getSqlSession(); return method.invoke(sqlSession, args); }关键在于getSqlSession()的规则如果当前没有Spring事务每次调用Mapper的方法都会通过SqlSessionUtils创建新的SqlSession用完立即关闭。不同SqlSession之间的一级缓存是完全隔离的所以你就会看到连续两次查询都查了数据库的现象。4.2 事务与一级缓存生命周期反过来如果给Service方法加上TransactionalSpring会让整个方法共享同一个数据库连接和同一个SqlSession。mybatis-spring 会把SqlSession绑定到当前事务线程的ThreadLocal上后续所有Mapper调用都复用这个会话于是一级缓存立即生效。我是用下面的方式验证的Transactional public void testWithTransaction() { User u1 userMapper.selectById(1); // 打印SQL User u2 userMapper.selectById(1); // 不打印SQL命中一级缓存 }这个现象很多人第一次看到会吓一跳同一事务里查两次怎么第二次不打印SQL了其实不是SQL没发而是MyBatis在会话内部做了缓存。这里有一个特别的坑如果没有在事务方法里开启批量操作而是手动调用了多个update一级缓存会在每个update之后被清空。所以你如果一边循环update一边查询查询始终不会命中缓存性能问题会被放大。另外需要注意Spring整合下每次请求几乎都会绑定到新的SqlSession所以很多成熟团队的约定是不要指望MyBatis内置一级缓存帮你扛QPS它的作用更多是避免一个方法里重复查询。真要扛并发要么上Redis要么优化数据库本身。4.3 集群环境下的缓存对策MyBatis内置的二级缓存是JVM进程级别的。单机部署时问题不大但一上多节点节点A更新了数据节点B的缓存还是旧数据就会出现数据不一致。这种现象我在一次灰度发布后遇到过部分请求打到新节点部分打到老节点用户看到的接口数据一会新一会旧排查了很长时间才定位到缓存不一致。分布式环境下的常规做法是把二级缓存换成外部缓存中间件。MyBatis的Cache接口是开放的你可以实现自定义Cache把数据放到Redis里public interface Cache { String getId(); void putObject(Object key, Object value); Object getObject(Object key); Object removeObject(Object key); void clear(); int getSize(); ReadWriteLock getReadWriteLock(); }实现一个RedisCache并不复杂核心逻辑就是putObject时序列化并写入RedisgetObject时从Redis读取并反序列化。但有几个细节要想清楚key如何序列化建议用CacheKey.toString()因为它已经包含了statementId、SQL、参数哈希天然不冲突。value序列化方案选JDK序列化还是JSON性能、可读性、版本兼容性都要权衡。缓存刷新策略怎么定MyBatis的flushCache只会调用cache.clear()你需要决定是删除整个Redis key前缀还是只删除当前namespace相关的key。我的实测经验是如果不想引入太多额外代码优先考虑在Service层做Redis缓存而不是去替换MyBatis的Cache实现。因为MyBatis缓存的key规则在分布式环境下很难精确控制而Service层缓存能结合业务key用户ID、商品ID精确失效心智负担小得多。5. 常见问题与面试必备速查5.1 高频面试题与回答要点这部分我整理了近期后台私信里问得最多的面试题直接附上回答要点。问题1一级缓存和二级缓存的区别回答要点一级缓存是SqlSession级别默认开启生命周期短增删改或会话关闭即失效二级缓存是mapper级别默认关闭需要显式配置跨会话共享。查询顺序是二级缓存优先。问题2为什么在SpringBoot项目里同一Mapper查询两次SQL执行了两次回答要点因为mybatis-spring的SqlSessionTemplate在无事务场景下每次方法调用都会创建并关闭新的SqlSession一级缓存隔离在各自会话里自然无法命中。加上Transactional后同一线程复用SqlSession一级缓存才生效。问题3二级缓存出现脏数据是什么原因回答要点缓存刷新粒度是namespace不同Mapper操作同一张表或多表JOIN时缓存无法感知其他namespace的数据变更解决方法是限制缓存范围、设置合理刷新间隔或使用外部缓存。问题4readOnlytrue和readOnlyfalse的区别回答要点readOnlytrue直接返回缓存对象引用性能高但有并发修改风险默认readOnlyfalse会通过序列化拷贝返回新对象要求实体实现Serializable接口。问题5如何实现基于Redis的MyBatis二级缓存回答要点实现org.apache.ibatis.cache.Cache接口覆盖putObject/getObject/clear等方法在cache type...里指定自定义实现类要注意序列化方案、key规则和刷新策略。5.2 排查实用技巧实录排查MyBatis缓存问题我常用的手段主要有这么几招都是实打实踩过坑后积累的。第一招打开SQL日志。在配置里设置logging.level.com.example.mapperDEBUG或者修改MyBatis配置在日志中打印SQL和参数。通过SQL是否打印可以准确判断查询是否走了缓存。如果打印了SQL说明没命中如果没打印但返回了正确数据说明命中了一层缓存。第二招查看命中率日志。启用了二级缓存后日志里会出现类似Cache Hit Ratio [com.example.mapper.UserMapper]: 0.85命中率低于0.5说明要么缓存key不稳定要么频繁被清空。这时候优先检查CacheKey生成的参数对象是否正确再看更新操作是否过于频繁。第三招使用flushCache和useCache做对比实验。在SQL语句上加useCachefalse可以强制绕过二级缓存加flushCachetrue可以强制清空缓存。通过对比这两种情况下的SQL执行次数可以快速判断问题出在缓存命中还是缓存刷新。第四招自定义Configuration观察内部状态。如果有特殊排查需求可以在MyBatis配置里指定自定义Configuration重写必要的钩子方法把CacheKey和命中结果打印出来。这个办法比较重但能精确定位到某条SQL没命中的原因。第五招清理缓存。开发和测试环境需要手动刷新时可以通过SqlSession的clearCache()清空一级缓存通过Mapper namespace对应的缓存清空二级缓存。实际生产中如果想强制刷新可以重启应用或者实现一个隐藏的运维接口专门调cache.clear()。写在最后的一点个人的体会说实话MyBatis缓存这套机制我从入门到真正吃透经历了三次重新学习第一次是看缓存配置文档以为自己全懂了第二次是遇到脏数据事故才发现缓存刷新和事务的耦合比想象中复杂第三次是分布式场景下被迫彻底放弃内置二级缓存才真正理解了MyBatis对缓存的克制态度。如果你正在面试或者准备接手一个性能敏感的项目我的建议是先别急着开二级缓存而是把一级缓存生命周期、Spring事务对其的影响、CacheKey生成规则这三个问题彻底搞清楚。等你能闭着眼说出一次查询在开启二级缓存、处于事务中、执行了一条update后缓存到底经历了什么你才算真正掌握了MyBatis缓存。带缓存的代码一定要小步验证我最喜欢的方式就是先写一个跑通完整链路的测试再慢慢把事务、分页、批量操作加进去这样踩过的坑永远能留下记录。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →