MyBatis缓存机制从源码到实战:一级二级缓存失效问题排查

发布时间:2026/10/3 10:05:56
MyBatis缓存机制从源码到实战:一级二级缓存失效问题排查 1. 从“数据库改了却查出旧数据”说起两三年没碰MyBatis源码的开发者多半会把缓存机制背成两句面试口诀一级缓存是SqlSession级别的二级缓存是namespace级别的。可真到了生产环境这两句口诀往往不够用——很多问题恰恰是“知道它有缓存但不清楚缓存是怎么存的、什么时候失效、什么时候根本没走缓存”造成的。我先举一个身边真实的例子。业务同事反馈某个配置表在后台改了一条记录的status字段然后立刻在另一个页面查列表看到的依然是旧状态刷新好几次都这样过几分钟才正常。当时的直觉是前端有缓存但清掉CDN、强刷浏览器还是老样子。后来查数据库日志发现update确实已经提交查应用日志发现查询SQL也真的执行了。那问题只能出现在MyBatis这一层——数据从数据库读出来之后被某个缓存层截胡了。顺着线索排查下去果然在一段循环查询的代码里同一个SqlSession连续执行了两遍同一条查询语句第二遍的结果没有重建而是直接复用了第一遍的数据。这就是MyBatis一级缓存最典型的表现。要彻底搞清楚这类问题自然不能停留在“一级缓存是会话级”这种结论上。缓存怎么命中、什么条件清空、二级缓存又会在什么场景下坑人这些问题本文从源码角度和实际排查经验两个维度一起拆一遍。适合正在用MyBatis做业务开发、遇到缓存数据异常但不知道从哪入手的人也适合准备面试、想把手写级答案升级为源码级答案的求职者。2. 一级缓存源码真相BaseExecutor里的localCache二级缓存涉及CachingExecutor、装饰器、缓存实现类等一大堆东西下一节专门讲。先从最核心的一级缓存说起因为它的存在感最强也最能解释前面那个“旧数据”问题。2.1 一次查询的完整调用链Mapper接口的方法调用最终会进到SqlSession的selectList再交给Executor执行。如果没有配置额外的东西默认Executor是BaseExecutor的子类比如SimpleExecutor或BatchExecutor。BaseExecutor源码里有一个非常关键的成员变量protected PerpetualCache localCache;PerpetualCache是最基础的一个Cache实现内部就是一个HashMapprivate final MapObject, Object cache new HashMap();每次查询都会进入query方法统一入口核心逻辑是这样的public E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) { if (queryStack 0) { list localCache.getObject(key); if (list null) { list queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); } } }注意queryStack这个字段。它表面上是“查询栈”实际是用来处理嵌套查询比如association里的select的防止递归调用时误判缓存。只有queryStack为0时也就是最外层查询才会尝试从一级缓存拿数据。2.2 缓存Key到底由哪些东西拼出来一级缓存的Key不是简单的SQL字符串而是CacheKey对象。我在认真看这块源码时觉得设计很讲究因为参数拼进去之后同一个Mapper方法传不同参数会生成不同Key参数相同但分页offset不同Key也不同不会互相污染。CacheKey cacheKey new CacheKey(); cacheKey.update(ms.getId()); cacheKey.update(rowBounds.getOffset()); cacheKey.update(rowBounds.getLimit()); cacheKey.update(boundSql.getSql()); for (Object value : parameterValues) { cacheKey.update(value); }这里有个容易忽略的细节参数列表里的每个参数值都会被update进去。如果参数是对象实际上调的是toString。意味着对象没重写toString两个业务上等价的对象会生成不同Key命中率偏低。对象重写toString时把无关字段拼进去了也可能导致本来相同语义的数据Key对不上。这个点经常被忽略。实际调优缓存命中率时第一件事就该打开日志看CacheKey差异而不是瞎调LRU大小。2.3 一级缓存什么时候会被清空清空一级缓存的条件主要有两个一是执行了update类操作二是配置了statement级别的缓存范围每次查询结束立即清空。BaseExecutor里update方法的开头就有一句public int update(MappedStatement ms, Object parameter) throws SQLException { clearLocalCache(); return doUpdate(ms, parameter); }也就是说同一个SqlSession里先update再selectselect不会命中旧缓存。这个设计保证了在同一会话内的基本一致性。这里引出一个核心实践问题Spring整合MyBatis后每次Mapper方法调用是不是同一个SqlSession答案是否定的。默认情况下SqlSessionTemplate每次执行完Mapper方法都会关闭SqlSession下次调用再开新的。所谓“一级缓存命中”更多发生在两种场景同一个事务内多次查询或者Service方法里共享同一个SqlSession。很多人非事务地测一级缓存发现怎么都不命中就是因为SqlSession已经不是同一个了。2.4 localCacheScopeSTATEMENT的实用价值MyBatis的配置项localCacheScope有两个取值SESSION默认和STATEMENT。两者差别很简单STATEMENT级别意味着每次查询结束后立即清空localCache当前SqlSession内连续执行相同查询也会重新走数据库。什么时候需要STATEMENT我遇到过的场景是那种实时性要求比较高的数据查询比如查询当前库存、查询余额业务上不允许在一个事务里复用旧值。不想拆事务也不想在业务代码里强制刷新缓存直接把整个SqlSession范围改成STATEMENT简单粗暴。不过必须提醒这个配置是全局的会影响所有Mapper。如果只有个别方法需要实时数据更精确的做法是在mapper XML里给该查询加上flushCachetrue或者把这类查询放到独立的Mapper中避免影响其他缓存友好的查询。3. 二级缓存的实现细节装饰器模式与namespace级隔离一级缓存大家多多少少遇到过二级缓存才是生产环境最大的坑来源。下面这段尽量讲细一点因为很多“缓存失效”问题根源都在对二级缓存机制理解不透。3.1 CachingExecutor是怎么参与查询的开启了二级缓存后Executor对象会变成这样CachingExecutor包裹在真正执行数据库操作的Executor外层。public Executor newExecutor(Transaction transaction, boolean autoCommit) { Executor executor new SimpleExecutor(configuration, transaction); if (cacheEnabled) { executor new CachingExecutor(executor); } executor (Executor) interceptorChain.pluginAll(executor); return executor; }所以在Executor.query被调用时会先进入CachingExecutor的query方法。这里的大致流程是根据MappedStatement、参数、分页信息、SQL构建CacheKey。判断当前语句是否配置了useCachetrue。如果是先查二级缓存。二级缓存没有再交给delegate真实Executor去查库。查到结果后写入二级缓存。这就是二级缓存“先查全局再查一级最后查库”的执行顺序。很多开发者以为二级缓存命中后就不走一级缓存了这个理解不完全对。CachingExecutor内部拿到结果后同样会经过本地缓存的存取因为一级缓存是在delegate的query方法里处理的。3.2 cache标签背后的实现Cache接口与装饰链在mapper XML里加一行cache/MyBatis就会为该namespace创建缓存对象。这个对象实现了org.apache.ibatis.cache.Cache接口核心方法很少getId、putObject、getObject、removeObject、clear、getSize。默认实现是通过一系列装饰器叠加出来的。比如配置cache evictionLRU flushInterval60000 size512 readOnlytrue/对应生成的Cache对象结构大致是最底层PerpetualCache内部就是HashMap。外面包LruCache负责淘汰最久未使用的条目。再外面包LoggingCache或SerializedCache取决于是否配置readOnly和序列化。最终被TransactionalCache2包装负责事务提交时统一提交缓存变更。这些装饰器都实现同一个Cache接口所以可以任意叠加这也是MyBatis缓存设计里比较灵活的地方。关于四个属性的含义我用一张表总结属性可选值默认含义evictionLRU / FIFO / SOFT / WEAKLRU缓存淘汰策略flushInterval正整数毫秒不设置缓存清空周期size正整数1024最大缓存条目数readOnlytrue / falsefalse是否直接返回对象引用readOnlytrue性能高因为缓存直接返回对象引用但调用方如果修改了返回对象会污染缓存。readOnlyfalse默认做序列化拷贝安全但更慢而且要求实体类实现Serializable否则运行时会报错。3.3 二级缓存Key和一级缓存Key是同一套吗是的CacheKey的构造逻辑完全一致。所以二级缓存会区分namespace、SQL、参数等维度不同查询之间不会相互覆盖。真正需要注意的不是Key本身而是二级缓存的作用域一个namespace对应一个缓存。如果同时用userMapper和orderMapper两者二级缓存是完全独立的对象即使底层查询的是同一张表也不会共享。这直接引出了下一节要讲的脏数据问题也是整个MyBatis缓存机制里最经典、最让人头疼的部分。4. 多表查询下的脏数据问题二级缓存最经典的坑二级缓存的核心问题在于它以namespace为边界但业务查询通常横跨多张表。一条join查询的结果缓存到A namespace里如果B表发生了更新B namespace的缓存被清掉了A namespace里那条join结果却安然无恙下次查询依然能命中那份过期数据。4.1 一个能稳定复现的脏数据场景假设有user表和user_ext表页面需要展示用户信息并关联扩展字段。在userMapper.xml里写了一条join查询查的是user表left join user_ext表。同时在userExtMapper.xml里写了对user_ext表的insert和update操作也配置了二级缓存。流程是这样第一次查join列表结果写入userMapper namespace的二级缓存。用户修改user_ext表userExtMapper二级缓存被清空但userMapper缓存没受影响。再次查询join列表userMapper缓存命中返回旧数据。这就是跨namespace缓存污染。你以为改了表数据查询却一直命中旧缓存。而且这种情况在单机环境下就能复现不需要分布式。4.2 解决办法cache-ref或干脆不用二级缓存MyBatis提供了cache-ref标签可以显式指定当前namespace复用另一个namespace的缓存cache-ref namespacecom.example.mapper.UserExtMapper/这样user和user_ext两个Mapper共用同一个缓存对象。无论哪一个执行update整个缓存都会被清空join查询下次就不会命中旧数据。但cache-ref不是银弹。两个namespace共用缓存后容量、淘汰策略完全绑定在一起一个高频更新的子表会把整个缓存拖下水命中率直线下降。我在实际项目里的经验是表之间变更频率差异大时尽量不用二级缓存。如果一定要用把经常变更的关联表做成cache-ref关联用命中率的下降换数据正确性。如果业务允许一定延迟可以依赖flushInterval定期清理而不是完全不做关联。顺带说一句flushCachetrue配置在select上表示每次查询前都清空二级缓存。很多开发者以为这只是“查询不走缓存”实际上是先清空再查询代价非常大生产环境要慎用。4.3 缓存“不刷新”是清空粒度太粗还是太细这个问题我排查过好几次区分起来很简单如果更新后所有查询都刷新了说明清理粒度太粗比如整个namespace被清空如果更新后特定查询还是旧数据说明清理粒度太细没覆盖到那条查询所在的namespace。实际项目里高频遇到的是“更新A表后查询AB的join结果仍是旧数据”原因就是第二节和第三节所述的namespace隔离。排查时不要急着看SQL先确认查询和更新分别落在哪个Mapper它们的namespace是什么关系。5. 分布式环境下的缓存之痛默认缓存与第三方集成前面讲的行为都是单机场景。一旦应用多实例部署MyBatis自带的二级缓存就基本失去意义因为每个JVM进程各持有一份缓存副本任何实例上的数据更新都没法通知其他实例失效。5.1 多实例下你遇到的现象会是什么比较典型的场景是两台应用服务器A和B负载均衡轮询。用户在A实例上改了配置A的二级缓存清掉了但B实例还缓存着旧数据。下一次请求恰好落到B用户看到的就是旧值。这种旧值问题比单机时更难查因为无法稳定复现而且日志里SQL都在执行。我在这类问题上吃过亏后来定了一条规矩只要应用会水平扩容就不要用MyBatis自带的二级缓存除非设计好了分布式失效方案。5.2 基于Redis实现自定义Cache如果团队确实需要用二级缓存又有多实例需求比较常见的做法是实现自定义Cache把缓存数据放到Redis。实现起来不复杂核心是实现Cache接口。需要注意几点构造方法必须接收String id参数MyBatis用反射调用这个构造器创建实例少了它直接报错。putObject的key是CacheKey对象序列化到Redis时要保证可序列化。value也得可序列化所以开启二级缓存时实体类最好都实现Serializable。多个namespace共用一个RedisCache时key的前缀namespace id要设计好避免串数据。Redis的TTL和MyBatis的flushInterval是两层概念。如果完全委托Redis过期时间需要重新设计clear和getObject的语义。一个简化版的RedisCache实现大致长这样public class RedisCache implements Cache { private final String id; private final RedisTemplateString, Object redisTemplate; public RedisCache(String id) { this.id id; this.redisTemplate SpringContextHolder.getBean(redisTemplate); } 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) { return redisTemplate.delete(id : key); } Override public void clear() { SetString keys redisTemplate.keys(id :*); if (keys ! null !keys.isEmpty()) { redisTemplate.delete(keys); } } Override public int getSize() { return 0; } }然后在XML里这样指定cache typecom.example.common.cache.RedisCache/这里要特别提醒RedisCache的clear和removeObject行为必须在多实例下仔细设计尤其是clear如果通过keys命令删除大量缓存键Redis的性能会受到明显影响。生产环境建议用scan游标方式分批删除或者直接把namespace对应的缓存设计成独立的key方便整体淘汰。5.3 集成EhCache与Spring Cache怎么选除了自己写RedisCache还有mybatis-ehcache这类现成整合包。EhCache支持本地disk持久化配置通过XML管理但多实例仍需广播失效否则和默认缓存没本质区别所以我不把它作为分布式场景的首选。我更建议在架构层面单独用Spring Cache管理业务缓存而不是让MyBatis二级缓存承担太多。Spring Cache是注解驱动可以在Service层做精细的缓存策略还能和Redis、Caffeine自由组合。MyBatis二级缓存更适合被定位成局部优化手段不该是数据一致性的第一道防线。6. 排查缓存问题的实践工具箱讲完机制最后聊聊日常开发中怎么排查缓存问题。按下面的顺序走大多数问题半小时内能定位。6.1 先确认缓存到底开没开MyBatis配置里有全局开关cacheEnabled默认是true。如果发现二级缓存完全没生效先查这个配置。全局开关关闭后就算XML里写了cache/也没用。然后看Mapper XML里有没有cache/或cache-ref/。注意cache/必须写在namespace对应的XML里用注解开发就在接口上加CacheNamespace。6.2 通过日志确认命中与否MyBatis的BaseExecutor和CachingExecutor都有日志点。开启日志后命中二级缓存时会输出Cache Hit Ratio [com.example.mapper.UserMapper]: 0.75配置打印SQL和缓存日志常见的方式mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: com.example.mapper: debugCache Hit Ratio长期偏低说明缓存Key粒度太细或访问模式不适合缓存需要从参数和SQL两个方向分析。6.3 高频问题定位表把这些年遇到过的高频问题整理成一张表排查时直接对照现象可能原因处理方式同事务内两次查询结果不一致一级缓存被clearLocalCache触发清空检查事务边界避免中间执行无关update更新后查询还是旧值查询的namespace缓存未被目标update清空使用cache-ref关联相关namespace返回对象被调用方改脏readOnlyfalse但未做序列化拷贝实体类实现Serializable或改readOnlytrue二级缓存完全不生效全局cacheEnabled被关闭开启全局开关检查XML是否有cached标签多实例下数据不一致默认缓存是JVM本地缓存集成Redis或直接关闭二级缓存缓存命中率极低CacheKey参数包含无意义字段优化参数对象的hashCode/toString或分页设计查询报序列化异常实体类未实现Serializable补齐Serializable6.4 生产环境到底怎么用缓存说句实在话很多项目里MyBatis二级缓存是可以用“保守”两个字来描述的。如果数据更新频繁、关联查询多、多实例部署我建议直接关闭二级缓存只保留一级缓存。理由很简单一级缓存受事务边界约束和数据库事务天然绑定脏数据概率极小二级缓存则把缓存边界与事务边界分离开了很容易在“性能提升”和“数据正确性”之间失去平衡。如果确实有读多写少、数据变化慢的配置型数据比如省份表、字典表可以针对单个namespace开启二级缓存同时配合flushInterval兜底。这个粒度控制起来相对安全。最后再分享一个小技巧判断一个查询是否真的适合进二级缓存可以观察它的重复执行比例也就是同一SQL在同一时间段被重复执行的次数。这个比例很高缓存收益才明显否则缓存只是白白消耗内存和增加复杂度。我个人在实际操作中最深刻的体会是缓存机制本身不难难的是知道你改的那条数据可能影响到了哪些查询。所以每次写join或跨表查询前只要这张表也在别的Mapper里被更新过我都会先问自己一句如果这里命中缓存结果还是新鲜的吗多问这一句能省下后面无数个排查之夜。