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实现,内部就是一个HashMap:
private final Map<Object, Object> cache = new HashMap<>();每次查询都会进入query方法统一入口,核心逻辑是这样的:
public <E> List<E> 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再select,select不会命中旧缓存。这个设计保证了在同一会话内的基本一致性。
这里引出一个核心实践问题:Spring整合MyBatis后,每次Mapper方法调用是不是同一个SqlSession?
答案是否定的。默认情况下,SqlSessionTemplate每次执行完Mapper方法都会关闭SqlSession,下次调用再开新的。所谓“一级缓存命中”,更多发生在两种场景:同一个事务内多次查询,或者Service方法里共享同一个SqlSession。很多人非事务地测一级缓存,发现怎么都不命中,就是因为SqlSession已经不是同一个了。
2.4 localCacheScope=STATEMENT的实用价值
MyBatis的配置项localCacheScope有两个取值:SESSION(默认)和STATEMENT。
两者差别很简单,STATEMENT级别意味着每次查询结束后立即清空localCache,当前SqlSession内连续执行相同查询也会重新走数据库。
什么时候需要STATEMENT?我遇到过的场景是那种实时性要求比较高的数据查询,比如查询当前库存、查询余额,业务上不允许在一个事务里复用旧值。不想拆事务,也不想在业务代码里强制刷新缓存,直接把整个SqlSession范围改成STATEMENT,简单粗暴。
不过必须提醒:这个配置是全局的,会影响所有Mapper。如果只有个别方法需要实时数据,更精确的做法是在mapper XML里给该查询加上flushCache="true",或者把这类查询放到独立的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。
- 判断当前语句是否配置了useCache=true。
- 如果是,先查二级缓存。
- 二级缓存没有,再交给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 eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>对应生成的Cache对象结构大致是:
- 最底层PerpetualCache,内部就是HashMap。
- 外面包LruCache,负责淘汰最久未使用的条目。
- 再外面包LoggingCache或SerializedCache,取决于是否配置readOnly和序列化。
- 最终被TransactionalCache2包装,负责事务提交时统一提交缓存变更。
这些装饰器都实现同一个Cache接口,所以可以任意叠加,这也是MyBatis缓存设计里比较灵活的地方。
关于四个属性的含义,我用一张表总结:
| 属性 | 可选值 | 默认 | 含义 |
|---|---|---|---|
| eviction | LRU / FIFO / SOFT / WEAK | LRU | 缓存淘汰策略 |
| flushInterval | 正整数(毫秒) | 不设置 | 缓存清空周期 |
| size | 正整数 | 1024 | 最大缓存条目数 |
| readOnly | true / false | false | 是否直接返回对象引用 |
readOnly=true性能高,因为缓存直接返回对象引用,但调用方如果修改了返回对象,会污染缓存。readOnly=false默认做序列化拷贝,安全但更慢,而且要求实体类实现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 namespace="com.example.mapper.UserExtMapper"/>这样user和user_ext两个Mapper共用同一个缓存对象。无论哪一个执行update,整个缓存都会被清空,join查询下次就不会命中旧数据。
但cache-ref不是银弹。两个namespace共用缓存后,容量、淘汰策略完全绑定在一起,一个高频更新的子表会把整个缓存拖下水,命中率直线下降。
我在实际项目里的经验是:
- 表之间变更频率差异大时,尽量不用二级缓存。
- 如果一定要用,把经常变更的关联表做成cache-ref关联,用命中率的下降换数据正确性。
- 如果业务允许一定延迟,可以依赖flushInterval定期清理,而不是完全不做关联。
顺带说一句,flushCache="true"配置在select上表示每次查询前都清空二级缓存。很多开发者以为这只是“查询不走缓存”,实际上是先清空再查询,代价非常大,生产环境要慎用。
4.3 缓存“不刷新”是清空粒度太粗还是太细
这个问题我排查过好几次,区分起来很简单:如果更新后所有查询都刷新了,说明清理粒度太粗,比如整个namespace被清空;如果更新后特定查询还是旧数据,说明清理粒度太细,没覆盖到那条查询所在的namespace。
实际项目里高频遇到的是“更新A表后,查询A+B的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 RedisTemplate<String, 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() { Set<String> keys = redisTemplate.keys(id + ":*"); if (keys != null && !keys.isEmpty()) { redisTemplate.delete(keys); } } @Override public int getSize() { return 0; } }然后在XML里这样指定:
<cache type="com.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 |
| 返回对象被调用方改脏 | readOnly=false但未做序列化拷贝 | 实体类实现Serializable,或改readOnly=true |
| 二级缓存完全不生效 | 全局cacheEnabled被关闭 | 开启全局开关,检查XML是否有cached标签 |
| 多实例下数据不一致 | 默认缓存是JVM本地缓存 | 集成Redis或直接关闭二级缓存 |
| 缓存命中率极低 | CacheKey参数包含无意义字段 | 优化参数对象的hashCode/toString或分页设计 |
| 查询报序列化异常 | 实体类未实现Serializable | 补齐Serializable |
6.4 生产环境到底怎么用缓存
说句实在话,很多项目里MyBatis二级缓存是可以用“保守”两个字来描述的。如果数据更新频繁、关联查询多、多实例部署,我建议直接关闭二级缓存,只保留一级缓存。理由很简单:一级缓存受事务边界约束,和数据库事务天然绑定,脏数据概率极小;二级缓存则把缓存边界与事务边界分离开了,很容易在“性能提升”和“数据正确性”之间失去平衡。
如果确实有读多写少、数据变化慢的配置型数据,比如省份表、字典表,可以针对单个namespace开启二级缓存,同时配合flushInterval兜底。这个粒度控制起来相对安全。
最后再分享一个小技巧:判断一个查询是否真的适合进二级缓存,可以观察它的重复执行比例,也就是同一SQL在同一时间段被重复执行的次数。这个比例很高,缓存收益才明显;否则,缓存只是白白消耗内存和增加复杂度。
我个人在实际操作中最深刻的体会是:缓存机制本身不难,难的是知道你改的那条数据可能影响到了哪些查询。所以每次写join或跨表查询前,只要这张表也在别的Mapper里被更新过,我都会先问自己一句:如果这里命中缓存,结果还是新鲜的吗?多问这一句,能省下后面无数个排查之夜。