资讯详情

Mybatis一级缓存原理与失效场景:从源码到Spring事务边界

📅 2026/10/14 17:51:25 | 华诺云谱 👁 阅读
Mybatis一级缓存原理与失效场景:从源码到Spring事务边界
1. 为什么我劝你不要迷信Mybatis一级缓存很多人第一次听到Mybatis一级缓存第一反应是好东西能省数据库查询。但当你真正在生产环境排查慢查询带着日志去定位的时候又会发现一级缓存的存在感极低——大部分时间它都在偷偷失效甚至给你添乱。我最早也被这个特性坑过一回后来认认真真把源码翻了一遍才发现网上很多说法要么只讲了个皮毛要么就是拿二级缓存的概念硬套。先说清楚一级缓存到底是个什么东西。Mybatis默认开启一级缓存它的作用范围是同一个SqlSession对象内部。也就是说你在同一个数据库会话里连续执行两次完全相同的查询第二次如果命中缓存就不会再去数据库跑一遍。这个完全相同不是肉眼看起来一样就行它包含了一整套CacheKey的严格比对。真要理解这套机制不能只看配置文件得顺着源码走一遍再拿几个典型场景实测你才能真正明白它在项目里什么时候有用、什么时候是负优化。这篇内容适合正在用Mybatis做开发的工程落地也适合准备看源码积累底子的朋友。我会从源码关键路径切入再用一个可以复现的Demo展示命中与失效的边界最后聊聊我踩过的坑和现在的使用习惯。读完之后你至少能做到不用靠猜就知道某个查询到底有没有走缓存并且能准确说出失效原因。2. 一级缓存的核心源码路径从Executor到CacheKeyMybatis一级缓存的实现在我看来属于概念简单落地细节多的典型。它不像大部分业务代码那样层层封装真正干活的类就那么几个但藏在里面的判断条件非常严格。我直接按一次正常查询调用链来拆。2.1 BaseExecutor缓存逻辑的真正宿主先明确一点一级缓存不是由MappedStatement控制的也不是由SqlSession直接管理。关键入口在org.apache.ibatis.executor.BaseExecutor。这个抽象类实现了Executor接口内部持有一个PerpetualCache类型的localCache字段。public abstract class BaseExecutor implements Executor { protected PerpetualCache localCache; // ... }PerpetualCache是一个非常朴素的缓存实现内部就是一个HashMap。它没有过期策略没有容量上限也没有淘汰机制。看到这里可能有人想问那它不怕内存爆炸吗答案是不怕因为一级缓存的生命周期被死死绑定在SqlSession上SqlSession关闭这个Map就整体丢掉。我在后面的演示部分会专门验证这个生命周期。BaseExecutor里负责查询的方法是query它对外的核心逻辑如下代码做了简化去掉了日志和一层重载Override public E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { // 先从localCache里查 ListE list (ListE) localCache.getObject(key); if (list null) { // 没有命中才走数据库 list queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); } return list; }这里唯一的查询入口是统一的但真正执行SQL的地方在queryFromDatabase。也就是说只要代码能走到BaseExecutor.query就先查缓存。问题是它怎么判断缓存里有没有答案全在CacheKey里。2.2 CacheKey比想象中严格一百倍的判等逻辑一级缓存是否命中核心是对CacheKey的equals判断。如果两次查询的CacheKey不相等哪怕SQL一模一样也是查两次数据库。CacheKey的生成规则藏在BaseExecutor.createCacheKey方法里代码大致如下public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) { CacheKey cacheKey new CacheKey(); cacheKey.update(ms.getId()); cacheKey.update(rowBounds.getOffset()); cacheKey.update(rowBounds.getLimit()); cacheKey.update(boundSql.getSql()); cacheKey.update(parameterObject); // 还有环境变量等附加信息 if (configuration.getEnvironment() ! null) { cacheKey.update(configuration.getEnvironment().getId()); } return cacheKey; }这段代码信息量很大我拆开讲。第一ms.getId()是MappedStatement的唯一标识在XML或者注解中通常是命名空间.操作id这种字符串。不同Mapper接口下的同名SQLid一定不同所以天然不会串缓存。第二rowBounds包含offset和limit如果你使用了物理分页组件比如某常见分页插件分页参数会反映在BoundSql里也会参与CacheKey生成。这其实是好事避免不同分页结果互相污染。第三boundSql.getSql()是最终处理后的SQL字符串。注意这个SQL可能包含动态SQL拼出来的不同片段。如果两条查询的where条件不一样即使最终查的是同一个表CacheKey也不一样。这很好理解。第四parameterObject会直接参与CacheKey的update。这个最容易被误解不是只看参数的toString也不是只看参数个数而是把整个参数对象塞进去。Mybatis在CacheKey.update里对参数做了多层处理如果是基本类型、简单对象会追加值如果是复杂对象会把对象的每个字段值都考虑进去。网上常说的参数对象没重写equals导致缓存不生效其实就是这里卡的——因为CacheKey内部用的是自定义哈希和equals逻辑它会遍历持有参数的HashCode和值。另外cacheKey.update里还包含一个checkout标志以及MappedStatement里配置的keyProperties等信息。整体构造下来哪怕只是参数顺序变了CacheKey都有可能变。在BaseExecutor中真正创建CacheKey的调用发生在query的重载方法里也就是在真正执行前先算好key再传给简化版的query。所以你可以理解为缓存命中的前提是在SqlSession存活期内MappedStatement id、SQL、参数、分页信息、环境信息全部一致。2.3 缓存命中与写入的时序细节如果缓存没有命中BaseExecutor会调用queryFromDatabase。这个方法的代码细节很值得看private E ListE queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { localCache.putObject(key, EXECUTION_PLACEHOLDER); // 先放一个占位符 ListE list; try { // 真正执行JDBC查询 list doQuery(ms, parameter, rowBounds, resultHandler, boundSql); } finally { localCache.removeObject(key); } localCache.putObject(key, list); return list; }为什么会先放一个占位符这里是Mybatis为了防止递归查询时的死循环。比如你在某个嵌套查询的ResultMap里又触发了对同一个MappedStatement的查询如果缓存里没有该key就会再次进入queryFromDatabase再次尝试放占位符无限套娃。有了占位符之后第二次递归进来时getObject能拿到占位符就不会继续查库。这个细节大多数文章不会讲但它是理解一级缓存线程模型的一个关键。执行完doQuery拿到结果list之后立刻putObject放入缓存。此时缓存key对应的value是完整的List对象。后续如果同一个SqlSession里再次查询相同key就能直接从localCache里拿List。2.4 缓存失效不是你想清就能清一级缓存不是只有显式调用clearCache才失效。Mybatis在以下场景会自动清理缓存代码分布在BaseExecutor的各个update方法里。update方法的开头有这一行Override public int update(MappedStatement ms, Object parameter) throws SQLException { // 每次updateinsert/update/delete都会先清理一级缓存 clearLocalCache(); return doUpdate(ms, parameter); }这段代码说明只要是增删改操作哪怕操作的表和之前的查询毫无关系也会把整个SqlSession的一级缓存全部清空。这个设计粗看很暴力细想是有道理的。一级缓存在同一个会话里共享但SqlSession的缓存没有细粒度到表级也没办法知道一条insert是否影响到了之前select出来的数据。为了保证数据一致性干脆一刀切——任何写操作都清空整个缓存。这也是网上吐槽一级缓存命中率低的主要原因之一。另一个自动清缓存的点是commit和rollbackOverride public void commit(boolean force) { // ... clearLocalCache(); } Override public void rollback(boolean force) { // ... clearLocalCache(); }当然SqlSession的close方法也会清理。如果你在Spring环境下用SqlSessionTemplate执行操作每次mybatis-spring都会在操作结束后把SqlSession关闭那么一级缓存实际上根本来不及跨两次查询保留。这个问题我留到效果演示部分实测给你看。3. 动手复现一级缓存命中与失效一个干净可跑的Demo光看源码容易看懂了但没感觉。我准备了一个非常小的Demo不依赖复杂的业务表就用一张用户表在Spring Boot环境里把一级缓存的行为一点点照出来。3.1 工程结构和SQL准备我用的是Spring Boot 2.x mybatis-spring-boot-starter数据库用H2内存库这样任何人拉下来都能跑不需要外部依赖。建表语句和执行结果都能直接在日志里看到。准备一个最简单的用户表CREATE TABLE t_user ( id BIGINT PRIMARY KEY, name VARCHAR(50) ); INSERT INTO t_user (id, name) VALUES (1, able), (2, boot);对应实体类和Mapper接口public class User { private Long id; private String name; // getter/setter省略 }Mapper public interface UserMapper { User selectById(Long id); int updateName(Param(id) Long id, Param(name) String name); }配套XML里就是两个select和一个update完全常规不需要任何特殊配置。我建议把Mybatis的SQL日志打开这样能直观看到SQL的执行次数logging: level: com.demos: debug这里有一点值得说明mybatis-spring-boot-starter默认会把SqlSessionTemplate的SqlSessionInterceptor打开每次Mapper接口方法调用如果当前没有Spring事务都会从SqlSessionTemplate里获取一个新的SqlSession方法结束后立刻关闭。这意味着默认情况下同一个Service里连续两次调用mapper方法使用的是两个不同的SqlSession一级缓存天然不互通。很多人没意识到这一点以为Mybatis一级缓存一定能在应用层生效其实在Spring Boot默认代理模式下它几乎是不生效的。3.2 演示一在同一个SqlSession内命中缓存这是最基础的实验。我不用Spring容器直接手动创建一个SqlSession看连续查询是否能命中。Test public void testHitCacheInSameSession() { try (SqlSession session sqlSessionFactory.openSession(true)) { UserMapper mapper session.getMapper(UserMapper.class); User u1 mapper.selectById(1L); User u2 mapper.selectById(1L); System.out.println(u1 u2? (u1 u2)); } }这里我故意用u1 u2做判断而不是equals正是因为一级缓存缓存的是同一个List对象所以第二次查出来的对象引用和第一次一模一样。实际运行日志里只有一条SQL控制台输出true。这个结果说明两点同SqlSession内相同CacheKey的查询确实走了缓存。缓存保存的是对象引用如果你修改了u1的字段u2也会跟着变。后面我会专门踩这个坑。3.3 演示二两条不同参数的查询彼此不干扰我把查询id1和id2都执行一遍即使这两个查询的SQL文本相同只是参数不同因为CacheKey包含了参数对象所以不会串。try (SqlSession session sqlSessionFactory.openSession(true)) { UserMapper mapper session.getMapper(UserMapper.class); mapper.selectById(1L); mapper.selectById(2L); }观察日志会打印两条SQL验证了CacheKey的严格性。这个看似简单但如果你用了复杂的parameter对象可能会因为对象里某个无关字段变化导致本来可以命中的查询也总是去查库这就是缓存命中率低的另一种常见原因。3.4 演示三增删改操作导致缓存失效这是最影响日常开发的一个行为。我在同一次SqlSession里先查id1再更新这条数据的name再查id1看看第二次查询是否还会走缓存。try (SqlSession session sqlSessionFactory.openSession(true)) { UserMapper mapper session.getMapper(UserMapper.class); mapper.selectById(1L); mapper.updateName(1L, charlie); User u3 mapper.selectById(1L); System.out.println(after update: u3.getName()); }日志输出会是这样 Preparing: SELECT * FROM t_user WHERE id ? Parameters: 1(Long) Preparing: UPDATE t_user SET name ? WHERE id ? Parameters: charlie(String), 1(Long) Preparing: SELECT * FROM t_user WHERE id ? Parameters: 1(Long)看到没有第二次select重新执行了SQL。原因就是update方法开头调用clearLocalCache()清空了整个localCache。哪怕你更新的是完全不相干的表比如清个计数表也照样清缓存。这是设计取舍也意味着如果你在一个长事务里先查了很多数据再更新一个无关表之前缓存的查询会全部失效。3.5 演示四Spring事务下的一级缓存生命周期Spring环境下如果方法上有TransactionalSpring会让整个事务方法共享同一个SqlSession。此时一级缓存从第一个查询开始生效直到事务提交或回滚。我用一个Service方法演示Service public class UserService { Autowired private UserMapper userMapper; Transactional public void queryTwiceInSameTx() { System.out.println(userMapper.selectById(1L)); System.out.println(userMapper.selectById(1L)); } }在这个有事务的调用里第二条查询会命中一级缓存所以日志里只有一条SELECT。这个现象经常让初学者误以为是Mybatis一级缓存生效了其实是Spring事务帮SqlSession续了命让两次Mapper调用对应到了同一个SqlSession。但注意一旦事务里发生update操作同样会清空缓存。我把上面的方法改一下在两次select之间插入updateTransactional public void txWithUpdate() { userMapper.selectById(1L); userMapper.updateName(1L, dave); userMapper.selectById(1L); }日志会打印两条SELECT加一条UPDATE第二次select没有走缓存而且查出来的是更新后的新数据。这里能保证一致性但也就失去了缓存的意义。如果去掉Transactional仅仅在一个普通Service方法里连续查询两次由于mybatis-spring的SqlSession是方法级的每次Mapper调用都开闭一个新的SqlSession二级日志会正常打印两条SELECT。我这边实测多次都是这个结果。所以留意你的依赖版本和Spring的事务边界才能判断一级缓存的真实表现。4. 从效果反推源码设计为什么缓存要绑死在SqlSession上看完上面的演示会发现一个很拧巴的事实一级缓存设计出来是想提升性能但在Spring默认配置下几乎不起作用只有在带事务的方法里才会发挥一点点威力而一个update又让全部缓存灰飞烟灭。那Mybatis为什么要这么设计要回答这个得回到Mybatis最早的使用场景。Mybatis的前身是iBATIS最早定位是SqlMap你要管理SqlSession的生命周期手动openSession和close。在这个模型里一级缓存很自然地和SqlSession对应起来——一次会话里反复查同一个对象是符合直觉的局部优化。那时候没有Spring事务模板也没有Mapper代理自动提交开发者手握一个会话连着跑多次查询缓存命中很常见。后来到了Spring环境SqlSessionFactoryBean和SqlSessionTemplate介入生命周期管理交给了容器一级缓存的默认行为就变味了。特别是mybatis-spring把SqlSessionTemplate设计成线程安全和每次操作独立会话之后一级缓存更多是作为一个纸面上的特性存在真正吃它红利的是那些自己掌控会话的批处理任务或者是带事务的长流程方法。这个设计还有一个容易被忽略的好处避免跨会话脏读。同一个SqlSession内能查到的数据在缓存生命周期内都不会变化除非你手动清缓存所以缓存数据不会因为别的会话提交而产生不一致。如果把缓存做成全局共享就需要处理并发失效、版本号、写入合并等一堆复杂机制Mybatis把一级缓存限制在会话级别就不用操心这些问题。二级缓存做成全局时才有了更复杂的读写锁和序列化策略那是另一套逻辑。从这个角度看一级缓存的局限实际上是作者刻意保留下来的简单性。我们把它理解成会话级别的查询结果暂存而不是全局查询缓存很多行为就不会再意外。5. 我在实际项目中踩过的三个一级缓存的坑这几个坑都不是从文档里看出来的是我在具体项目里一点一点Debug出来的。记录在这里希望帮你少走弯路。5.1 坑一同一个对象被缓存引用字段修改互相污染在一次事务方法里我先查出User对象然后业务逻辑需要临时改一下这个对象的某个字段再做后续判断同事直接调用了user.setName(临时值)。虽然事务中没有再去触发更新操作但第二次在同一个事务里再次查询这个User时拿到的是同一个对象引用name字段已经是临时值业务结果直接错了。这个问题的本质就是一级缓存缓存了对象引用而不是深拷贝。解决方案也很简单需要修改对象时先手动拷贝一份再改或者明确在当前业务结束后让缓存失效。如果你用Mybatis Plus这种增强框架它的一级缓存行为和原生一致照样有这个问题。5.2 坑二循环里重复查询导致缓存大小失控在批处理场景下我写过类似这样的代码for (Long id : idList) { User user userMapper.selectById(id); // 处理逻辑 }本来想着id不同每一条都会查库但问题不在这。真正的风险是如果某个id在列表里出现了多次而你在同一个SqlSession内执行第二次虽然是缓存命中但SqlSession在整个批处理过程中一直不关闭随着idList越来越大里面所有查询过的结果都被存在PerpetualCache的HashMap里内存占用逐步上升。虽然最终SqlSession关闭会释放但长跑任务中内存压力不可忽视。我后来改成分批提交或者定期在循环体中判断是否要clearCache。注意Mybatis并没有提供只清除某个key的公开APIPerpetualCache的removeObject是给查询内部用的你只能通过clearLocalCache整体清理。5.3 坑三事务边界太长缓存失效反而保证不了数据新鲜有人觉得一级缓存既然能命中那事务里不管怎么查都是最新数据这个认知很危险。一级缓存只保证在当前会话内第一次查询拿到的是数据库数据之后如果同一个会话里有了写操作缓存会清空后续查询再次拿到新数据。但如果是多个会话并发一个会话修改了数据并提交另一个长事务的会话Session如果之前查过相同数据它的缓存不会自动感知其他会话的提交。举例说明事务A开启后查询id1的User事务B修改了id1的User并提交此时事务A再次查询id1由于一级缓存尚未清理它拿到的还是旧快照。这个旧不一定是错的得看你需要的隔离级别是什么。如果业务要求强一致性的最新数据你需要考虑在查询前强制刷新缓存或者干脆不使用长事务。这个坑跟前两个不同它关乎数据正确性不能靠清理对象引用来绕过。我在实践中最终形成了一套自己的使用习惯分享出来供参考默认Spring Boot无事务的读写操作完全忽略一级缓存不指望它提升性能。需要频繁查询相同对象的场景比如登录后多次获取用户信息可以在一个事务中执行但务必备份对象后再做变更操作。定时任务或批处理场景SQL本身如果可以合并就合并不要依赖缓存同时注意SqlSession的释放。如果项目确实需要全局缓存优先考虑业务层自己加Redis或者启用Mybatis二级缓存并配置好合适的淘汰策略。一级缓存更适合作为会话内状态而非跨请求共享。6. 一级缓存和二级缓存的关系经常被搞混的边界网上经常有文章把一级缓存和二级缓存混在一起说其实它们完全是两个层级的组件。我在这里简单理一遍免得看源码时绕晕。一级缓存是SqlSession级别的本地缓存它位于BaseExecutor内部默认开启无法彻底关闭你只能通过配置让其效果变小。二级缓存是namespace级别的全局缓存位于CachingExecutor中需要在Mapper XML里配置cache/标签才会启用。二级缓存的作用域是跨SqlSession的同一个Mapper namespace下不同会话可以共享缓存结果。从源码上看二级缓存的装饰器链依次是SynchronizedCache、LoggingCache、SerializedCache、LruCache等包在某个PerpetualCache之上。当CachingExecutor执行查询时是先查二级缓存没有命中再去调BaseExecutor说明二级缓存优先于一级缓存。而二级缓存命中返回的对象有可能是被序列化过的不一定和数据库对象是同一引用。这两级缓存有各自的脏数据风险。二级缓存因为跨会话需要由你手动保证底层数据更新时能触发对应的缓存清理。Mybatis的机制是任何在同一个namespace下的增删改操作会清空该namespace的二级缓存。但如果你直接改了数据库而没有走Mybatis二级缓存就成脏缓存了。相对而言一级缓存因为只绑会话脏数据风险小得多这也是它能默认开启的底气。在实际项目里我几乎不主动开启二级缓存。原因很简单分布式环境下多实例部署每个实例的本地缓存互不感知数据一致性很难保证而如果真有高频共享读的场景Redis这类集中式缓存更合适。二级缓存只在单机、低并发、明确能控制失效范围的内部系统里才有价值。关于如何选择我的观点是缓存应该放在离热点数据最近的业务层开发者要能清晰说出每个缓存的key、过期时间、失效触发点。一级缓存和二级缓存都更像是框架自带的隐身优化使用起来有太多隐式规则不如自己动手来得踏实。7. 一级缓存的局限能否被绕开参数动态化与自定义扩展看到这里可能有朋友想问那有没有办法让一级缓存更可控比如在特定查询上强制关闭缓存或者自定义CacheKey的生成规则。这些需求在Mybatis源码层面都是可以动手的但我要先泼盆冷水Mybatis没有提供优雅的运行时关闭一级缓存的开关它的默认行为是写在BaseExecutor里的你能做的更多是骗过它。一种常见做法是给不需要缓存的查询动态拼接一个随机参数比如一个永远不会进去的条件字段迫使每次CacheKey不同。例如select idselectByIdNoCache resultTypeUser SELECT * FROM t_user WHERE id #{id} AND #{randomVal} #{randomVal} /select这样一来每次调用传入不同的randomValCacheKey就会变化缓存无法命中从而绕过一级缓存。这种做法很丑陋但确实有人这么干。我不推荐因为它污染SQL而且容易让Mybatis的预编译优化失效。另一种更干净的办法是直接用SqlSession.clearCache()强制清空当前会话缓存但这也只能整体清空。如果你要精细控制每个查询的缓存行为最合理的路径还是上升一层在Service层手动管理查询结果缓存保持Mapper层清爽。毕竟Mybatis一级缓存的核心目的并不是让你依赖它来构建架构它更像是一个会话内的默认安全网。如果你想读源码研究透我建议按下面顺序看BaseExecutor#query与queryFromDatabase看key与value的存储时机。PerpetualCache和CacheKey看equals/hashCode实现对比String的写法差异。SimpleExecutor与ReuseExecutor看存储过程与预编译语句复用的差异如何影响缓存。MybatisMapperProxymybatis-spring里是MapperProxy的调用链理解为什么Spring代理下SqlSession是线程绑定而不是全局单例。把这些看完你对一级缓存的认知就能超过大部分写业务代码的同行。这不算难贵在耐心。8. 写在最后的实践心得根据我这几年的使用观察真正能用好一级缓存的人往往不是那些追求命中率的人而是清楚知道缓存何时消失、为何消失的人。我现在的做法很简单把一级缓存当成Mybatis会话模型的一部分来理解而不是把它当成性能优化手段。在Spring Boot默认环境下压根不指望它能减少数据库压力在自管理SqlSession的批处理任务里如果SQL确实存在重复查询我会刻意利用它但一定注意对象引用带来的副作用。如果你正准备深入学习Mybatis源码一级缓存是一个特别好的切入口。它代码量少、逻辑清晰却能牵引出Executor、CacheKey、SqlSession生命周期、Spring整合代理等一大串核心知识。读透它之后再去理解二级缓存和插件机制会顺畅很多。最后分享一个小技巧做源码阅读时别只盯着命中分支看多想想为什么不命中。每一个失效路径背后都代表着Mybatis对于一致性和复杂性的取舍。把这些取舍想明白你写业务代码时就不再会为为什么缓存没生效而困惑了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑