
最近组里招人几乎每个面试候选人都会在简历上写熟悉MyBatis但当我问到缓存机制时十个有八个只能答出有一级缓存和二级缓存再追问一句一级缓存什么时候失效、二级缓存的脏数据是怎么产生的基本就卡壳了。更关键的是很多实际项目里因为缓存没搞明白出现数据错乱、查不到最新数据最后只能靠重启解决。这篇文章就把MyBatis的一级缓存和二级缓存从源码到实战彻底拆一遍包括缓存归属、失效场景、配置参数、脏数据成因以及面试官真正想听到的回答思路。不管是正在排查线上bug的同学还是准备面试的开发者都能从中拿到可以直接用的东西。1. 先给缓存定个性它到底属于谁又能被谁看到1.1 一个两分钟的自我检测题在看源码之前先做一个简单的判断。假设我的项目用的是Spring Boot MyBatis有两个Service方法A和BA方法里查询了一条id1的用户数据B方法里也查询id1的用户数据两次查询之间没有任何数据库写入。请问第二次查询是否命中了缓存很多人的答案是命中了理由是MyBatis默认开启一级缓存。但这个答案只对了一半甚至在实际的Spring Boot项目里大概率是错的。这个问题的答案取决于A和B这两个方法是否共用同一个SqlSession。如果A和B各开一个SqlSession那两次查询根本不会命中任何缓存如果A和B被包在同一个事务里并且都用同一个SqlSession那第二次查询会拿到第一次查询的缓存结果。这个检测题背后牵出的核心概念就是缓存的归属范围一级缓存属于SqlSession数据库会话二级缓存属于Mapper的namespace命名空间。1.2 用生活类比理解两级缓存的边界如果把一次数据库操作比作一次点餐一级缓存就像是这次用餐的餐桌你这次坐在这个桌子上点的菜没吃完的放在桌上下一次在这个桌上的客人同一个SqlSession可以直接吃但你走了SqlSession关闭桌子就收了剩菜全部倒掉。二级缓存更像是餐厅的中央厨房所有桌子的客人都能共享但问题在于厨房里的菜是提前做好的如果有人跑到隔壁餐厅另一个namespace去改了原材料你这边厨房里的成品菜不会跟着更新这就产生了脏数据。这个类比对应到MyBatis一级缓存的生命周期和SqlSession完全一致SqlSession关闭缓存就没了二级缓存的生命周期和namespace绑定只要应用程序不重启、缓存不超时数据就一直存在而这个缓存数据可能因为各种原因和数据库真实状态不一致。1.3 MyBatis为什么非要设计两层缓存说实话MyBatis作为一个持久层框架它的缓存设计并不能算非常出色但它提供的两层级联思路却是理解整个OR Mapping框架缓存体系的最佳入口。一级缓存是为了解决同一会话内的重复查询比如一个事务里先查一个用户后面又查同一个用户避免两次网络往返数据库。二级缓存则是为了跨会话共享热点数据多个SqlSession都查同一张配置表可以共用一份缓存结果减少数据库压力。不过这两层缓存各有各的坑。一级缓存的坑在于Spring Boot项目里SqlSession的复用机制和很多人以为的不一样二级缓存的坑则在于namespace级别的隔离会导致跨表操作时缓存不失效。这两块恰恰是面试中的高频考点也是实际生产中问题最多的地方。2. 一级缓存源码追踪从一次普通查询到HashMap命中2.1 查询语句的完整执行链要理解一级缓存最直接的方法是打开源码看一次普通查询走过的路径。这段链路我建议大家自己跟一遍面试的时候能讲出函数名说服力是完全不同的。我们通过sqlSession.selectOne(com.example.UserMapper.selectById, 1)发起查询时执行流程大致如下DefaultSqlSession.selectOne内部调用selectList最终把参数传给Executor的query方法。如果项目配置了二级缓存SqlSession持有的Executor会被包装成CachingExecutor如果没配则直接是BaseExecutor。CachingExecutor.query先查二级缓存ms.getCache()二级缓存没命中才调用delegate.query。BaseExecutor.query中会先通过CacheKey去一级缓存localCache中取数据取不到就去数据库查。这个链路中一级缓存的载体是BaseExecutor里的localCache属性它的真实类型是PerpetualCache。2.2 PerpetualCache的内部真相PerpetualCache的代码非常直白本质上就是一个被包了一层壳的HashMap。它实现了MyBatis的Cache接口核心字段就一个MapObject, Object cache new HashMap()。putObject就是把key和value塞进HashMapgetObject就是根据key取值removeObject就是移除clear就是清空整个Map。就这么简单一级缓存没有任何过期时间也没有LRU淘汰它的生命周期完全掌握在SqlSession手中。这个设计说好听是轻量说难听是简陋所以一旦SqlSession长期存活比如长会话、长事务一级缓存就可能膨胀这也是为什么生产环境通常不把一级缓存当作性能优化手段它更像是一个顺手做的去重。2.3 CacheKey决定了你能否命中缓存既然PerpetualCache就是一个HashMap那判断是否命中缓存的关键就在于key是否相等。MyBatis的key不是简单的字符串拼接而是一个CacheKey对象。CacheKey的计算在BaseExecutor中完成核心逻辑包括以下几个部分的hashkey.update(ms.getId()); // MappedStatement的唯一id key.update(rowBounds.getOffset()); // 分页offset key.update(rowBounds.getLimit()); // 分页limit key.update(boundSql.getSql()); // 完整SQL语句文本 key.update(parameterObject); // 参数对象也就是说只要查询的Mapper id不同、SQL文本不同、参数不同、分页条件不同CacheKey就不相等那就没机会命中一级缓存。这里有个容易被忽略的细节parameterObject是一个对象MyBatis在update时会递归展开对象把每个字段的值都算进hash里。如果你传了一个带时间字段的对象时间的毫秒值不同CacheKey就不同即便看起来查的是同一行数据也不会命中。另外SQL文本是整个语句不是剥离参数的模板。所以where name ?和where name ? order by id哪怕参数一样CacheKey也不同。2.4 一级缓存命中后的返回值问题再提醒一个容易踩的细节一级缓存命中后返回的是缓存里的同一个对象引用不是新拷贝的对象。也就是说你拿到这个对象后如果直接修改它的字段值缓存里的对象也会被改掉。同一个SqlSession内再查一次拿到的是被你改过的数据就算你用update语句更新了数据库如果SQL没触发缓存清空比如用了flushCachefalse依然可能读到脏数据。这个坑在实际代码里非常隐蔽尤其在事务内做先查询后修改再查询的业务逻辑时计算结果会有偏差。3. 哪些操作让一级缓存失效踩坑案例和正确用法3.1 任何DML操作都会清空缓存这是有意为之BaseExecutor.update方法里在执行真正的doUpdate之前有一句clearLocalCache()。这意味着只要执行了insert、update、delete中的任何一条语句一级缓存就会被整体清空。这个设计的初衷是避免缓存与数据库不一致但它也带来了一个反直觉的结果即便你更新的是一条完全不同的记录也会让之前的查询缓存全部失效。举一个实际例子。一个会话里先查了id1的用户又调用了某个更新语句把id2的用户改名了接下来再查id1依然会落到数据库。所以如果某个事务里频繁做查询更新的混合操作一级缓存基本是全程罢工的这也是很多团队感觉MyBatis有没有缓存好像没什么区别的原因之一。3.2 跨SqlSession查询为什么等于没缓存前面说过一级缓存挂在SqlSession上。如果业务代码里通过SqlSessionFactory.openSession()每次open一个全新的SqlSession用完close那就意味着每次查询的缓存池都是全新的。这种情况下一级缓存只对同一SqlSession内完全相同的第二次查询生效跨会话完全失效。最常见的错误写法是手动封装了一个DAO工具类每个方法里都getSession、query、closeSession。如果这个封装真的每次都新建SqlSession那一级缓存就形同虚设还会带来额外的对象创建开销。实测下来这种工具类在高频查询场景下性能比不用缓存还差一点因为增加了SqlSession创建和销毁的成本。3.3 Spring Boot项目里一级缓存为何经常不存在Spring Boot整合MyBatis之后SqlSession由SqlSessionTemplate管理。SqlSessionTemplate内置了动态代理默认情况下每个方法调用都会走一次代理逻辑通过SqlSessionUtils.getSession获取SqlSession。判断逻辑是如果当前线程绑定了事务并且事务同步管理器里已经有SqlSession那就复用如果没有事务就新建一个SqlSession方法执行完就关闭。所以结论是这样的在无事务的普通方法调用中每次查询基本都新建SqlSession一级缓存证明确实存在但跨方法、跨调用完全无法命中在有Transactional事务的方法里同一个事务内的多次查询共用同一个SqlSession一级缓存才真正发挥同一事务内去重的作用。3.4 何时需要手动调用clearCache如果封装过自己的BaseMapper或者做过批量操作可能会遇到一种情况同一个事务里第一次查询后被缓存然后通过另一个Mapper做了数据变更但因为特殊原因没有触发缓存清空接下来说不清的看不到新数据出现了排查半天最终找到是缓存命中。这时候可以直接调用sqlSession.clearCache()强制清空一级缓存。还有一种场景需要注意MyBatis-Plus提供的批量插入接口底层其实是多条insert语句每一条都会清一次一级缓存。如果一个事务里先查了大量数据再做批量插入这批插入期间缓存反复清空事务后段的查询基本全部走数据库。理解了任何DML清空一级缓存这个规则你就能准确预判这些行为而不是靠猜。4. 二级缓存配置与源码从XML解析到装饰器组装4.1 开启二级缓存的完整步骤二级缓存默认是关闭的因为它在带来跨会话共享的同时也引入了数据一致性风险。开启需要三步第一步在mybatis-config.xml确认全局开关是开启状态cacheEnabled默认就是true这里是确认而不是主动打开很多文章容易说反。第二步在Mapper的XML中添加cache/标签或者在Mapper接口上标注CacheNamespace。第三步让缓存的实体类实现Serializable接口。如果配置了readOnlyfalse默认就是falseMyBatis默认使用SerializedCache装饰器查询结果会先被序列化再存入缓存取出来时再反序列化这就要求实体类可序列化。配置示例mapper namespacecom.example.mapper.UserMapper cache evictionLRU flushInterval60000 size512 readOnlytrue blockingfalse/ select idselectById resultTypecom.example.entity.User useCachetrue select * from user where id #{id} /select /mapperuseCachetrue是select的默认值表示这条查询结果要放进二级缓存。如果你想对某条查询单独关闭二级缓存就把useCache设为false。4.2 XMLConfigBuilder与CacheBuilder如何构建缓存这涉及到热词里提到的XMLConfigBuilder。MyBatis启动时的XMLConfigBuilder负责解析全局配置文件它解析到mapper映射时会创建XMLMapperBuilder去解析Mapper XML文件。XMLMapperBuilder遇到cache标签时会调用CacheBuilder来构建缓存对象。CacheBuilder的构建逻辑非常清晰从实例化到装饰器包装我直接贴一个关键源码片段以MyBatis 3.5.x为例略去了属性设置等细节public Cache build() { setDefaultImplementations(); Cache cache newBaseCacheInstance(implementation, id); setCacheProperties(cache); if (PerpetualCache.class.equals(cache.getClass())) { for (Class? extends Cache decorator : decorators) { cache new Decorator(cache); } } cache setStandardDecorators(cache); return cache; }setDefaultImplementations里有两个重要默认值implementation默认为PerpetualCachedecorators默认是LruCache。也就是说即使你在cache/里什么都不配底层缓存也是PerpetualCache LruCache 标准装饰器的链条。如果想换成FIFO就在cache的eviction属性里指定FIFO装饰器列表就会变成FifoCache。4.3 装饰器模式下的缓存能力组合setStandardDecorators是理解二级缓存源码的核心它按条件给缓存不断套壳private Cache setStandardDecorators(Cache cache) { if (clearInterval ! null) { cache new ScheduledCache(cache); } if (readWrite) { cache new SerializedCache(cache); } cache new LoggingCache(cache); cache new SynchronizedCache(cache); return cache; }所以默认配置下二级缓存的实际对象链是这样的由内到外PerpetualCache底层HashMap存储LruCacheLRU淘汰策略最多保存size个keyScheduledCache按flushInterval定期清空SerializedCache读写时序列化/反序列化对象LoggingCache打印缓存命中率日志SynchronizedCache保证并发安全理解这条装饰器链面试时被问到MyBatis二级缓存为什么支持LRU、为什么支持定时刷新就不需要死记硬背了直接描述装饰器一层层包裹的过程就行。4.4 参数细读eviction、flushInterval、size、readOnly、blocking这些参数虽然常见但每个都有容易误解的地方。eviction指定的是缓存淘汰策略。默认LRULeast Recently Used最近最少使用含义是缓存快满时先把最久没被访问的key踢出去。FIFO则按插入顺序淘汰先放进来的先被淘汰。还有SOFT和WEAK两种基于Java引用类型的策略分别对应软引用和弱引用由GC回收来决定缓存何时消失实际项目中用得不多但面试提一句基于引用类型实现能加分。flushInterval是定时清空缓存的间隔单位毫秒。注意它不是缓存数据存活时间而是每隔多久把整个缓存清空一次。如果你设置了60000意味着无论缓存数据是否被访问每60秒整体清空一次。这里有个常见误解有人以为它像Redis的TTL一样是每条数据单独过期其实ScheduledCache的clearInterval是全局的到点就全清没有每条数据的过期时间。size是缓存能存多少个key-value条目默认1024。超过后触发淘汰策略默认LRU开始清理。readOnly是面试里容易被深挖的点。readOnlytrue表示所有使用者拿到的都是同一个对象实例谁改了对象字段缓存里的对象也被改了优点是省掉了序列化开销缺点是数据安全隐患。readOnlyfalse默认则通过SerializedCache做序列化每次取出都返回一个新对象修改它不会影响缓存代价是必须支持Serializable并且有CPU和存储开销。blocking可选默认false。开启后如果缓存未命中只有一个线程去查数据库其他线程阻塞等待防止大量请求同一缓存键时并发打到数据库缓存击穿。底层是BlockingCache装饰器内部维护了一个锁集合每个CacheKey一把锁。5. 二级缓存的致命伤脏数据是怎么一步步产生的5.1 join查询引发的缓存不失效问题二级缓存最大的坑在于缓存失效的粒度是namespace也就是单个Mapper。可实际的业务查询往往跨表比如UserMapper里有个方法join了Order表select idselectUserWithOrder resultTypecom.example.vo.UserOrderVO useCachetrue select u.*, o.order_no from user u left join order o on u.id o.user_id where u.id #{id} /select这条查询的结果被缓存在UserMapper的namespace下。现在假设OrderMapper更新了这个用户的订单号order_noOrderMapper的清缓存操作只会清掉OrderMapper自己的namespace缓存UserMapper里的缓存依然保存着旧数据。于是下一次查询selectUserWithOrder拿到的是更新前的订单号——典型的脏数据场景。这个问题的根源就是MyBatis的细粒度namespace缓存无法感知跨Mapper的表关联。解决思路有三个按推荐程度排序关闭这种join查询的二级缓存useCachefalse把相关Mapper的缓存统一到一个namespace通过cache-ref namespace.../更新关联表之后手动调用clearCache基于SqlSession.clearCache需要谨慎设计容易漏。5.2 多表操作与手动清缓存实际项目里最稳妥的做法往往是只要一个查询涉及多表关联就默认不开启二级缓存。很多团队在数据库设计阶段就把关联查询必须走实时库当团队规范避免线上出现查不到最新数据的诡异问题。另一个容易被忽略的点是insert/update/delete默认会清空当前namespace的缓存这个清空是flushCachetrue的效果。如果把一条update语句显式设置成flushCachefalse那就意味着这条更新不会清掉缓存后续查询很可能读到旧数据。这个属性虽然不常用但一旦被滥用排查起来非常痛苦。我自己排查过的一个线上问题就是有同事为了提高批量更新性能给update加了flushCachefalse结果第二天业务方反馈数据一直展示旧值查了整整半天才定位到这一行配置。5.3 只有单机缓存是不够的分布式环境下的局限MyBatis自带的二级缓存是JVM进程内的本地缓存不是分布式缓存。这意味着如果系统部署了多台应用服务器每台机器各自维护自己的缓存副本一台机器更新了数据库另一台机器的缓存还是旧的。解决思路是用Redis或本地Caffeine替代MyBatis二级缓存或者干脆在Service层做缓存管理。主流做法中Spring的Cacheable配合Redis是更通用的方案缓存key由人肉设计失效逻辑由TTL保证可以精确控制每类数据的过期时间也天然支持跨JVM共享。这也是很多团队选择关闭MyBatis二级缓存直接上Redis的原因——不是MyBatis的缓存不能用而是它在分布式场景下确实力不从心。5.4 哪些项目真正适合开启二级缓存虽然二级缓存的坑这么多但也不是一无是处。经过实测和多个项目的验证以下场景可以考虑开启单机部署无多实例分担流量数据以读为主、更新频率极低比如字典表、配置表、省市区数据业务上能接受一定时间内的数据延迟比如30秒到几分钟涉及关联查询的SQL都做了缓存隔离或统一namespace。如果以上条件不全满足我的建议是别开。宁可用一二级缓存默认行为业务层针对热点数据做专门缓存也不要把潜在一致性风险引进来。6. 面试环节缓存的八连问与回答思路6.1 描述一次查询的完整缓存命中流程面试官喜欢让面试者讲一下你理解的MyBatis缓存查询流程。一个比较完整的回答可以按下面的顺序展开首先SqlSession发起查询时Executor会先看MappedStatement上有没有配置Cache。如果有先查二级缓存——查询时拿CacheKey去namespace对应的Cache中找找不到才走下一层。其次走一级缓存从BaseExecutor的localCache中按CacheKey取如果还没有就真正执行JDBC查询数据库。查询到结果后先回填一级缓存再在事务提交时把结果放进二级缓存。这里有两个加分细节一是二级缓存的写入是延迟到事务提交的不是查询完立刻写入二是CacheKey由MappedStatement的id、SQL文本、参数、分页信息计算而来所以看起来相同但参数不同的查询不会命中。6.2 一级缓存与二级缓存的差异对照表把这个表格背熟面试时直接展开讲即可对比项一级缓存二级缓存作用范围SqlSession会话内namespaceMapper级别默认状态开启关闭是否需要配置不需要需要cache/或CacheNamespace生命周期SqlSession关闭即销毁应用运行期间长期存在缓存实现PerpetualCacheHashMapPerpetualCache装饰器链并发安全不安全会话内单线程使用有SynchronizedCache保证读写行为直接返回同一对象引用默认序列化取出是新对象失效时机任何DML、手动clear、SqlSession关闭flushInterval到点、DML清当前namespace6.3 如何保证缓存一致性连贯回答示范面试追问到缓存一致性怎么保证时不建议一上来就背框架。一个较好的回答逻辑是先承认MySQL和缓存之间没有强一致方案MyBatis的原始策略是更新时清缓存DML语句默认flushCachetrue把当前namespace的缓存直接清空下次query再重建一级缓存则在任何DML后整体清空。这种策略保证的是最终一致不是绝对一致。再说明MyBatis缓存粒度是namespace跨namespace的关联查询可能产生脏数据所以在关联场景要么关闭缓存要么统一namespace。最后可以提一句如果业务要求严格一致通常不用MyBatis缓存而是用Redis TTL 主动失效的组合或者干脆只依赖数据库。这套回答把框架机制、问题边界、实际选型和场景取舍都覆盖了比单纯背源码更能体现经验。6.4 容易被追问的底层细节CacheKey、事务缓存、序列化最后说一下几个追问点建议按照自己的理解组织语言而不是背诵。关于CacheKey核心讲它是多字段hash的复合对象不是字符串拼接。追问时可以提一句它用的update方法会先算hash再按位更新保证不同对象即使toString一样hash也可能不同。关于事务缓存这个很多人不知道。二级缓存写入时不是直接写Cache而是先写TransactionalCacheManager中维护的TransactionalCache事务提交时才真正将数据put到二级缓存如果事务回滚就不会写入。这就能解释为什么一个事务里查询后立即到另一个线程查二级缓存可能是空的。关于序列化如果配置了readOnlyfalse二级缓存存的是序列化后的字节数组取出来时反序列化生成新对象。所以实体类字段如果不是Serializable会在写入缓存时报SerializationException。这一点可以在面试时顺口提出来说明你真的在项目里配过。我自己在多个项目里的习惯做法是默认不碰二级缓存但把一级缓存的机制吃透——因为它在事务内的去重效果是免费的理解它才能解释为什么同一个事务里查两次结果不一样这类问题。如果确实要上二级缓存也要把flushCache、cache-ref、readOnly这些参数过一遍再动手线上问题排查的代价永远比开发时多花几分钟高得多。