MyBatis一级缓存源码详解:命中规则、失效机制与踩坑指南
有段时间没碰Mybatis的源码了直到前阵子在排查一个“莫名其妙的少查一次数据库”的问题才又把一级缓存的实现翻出来仔细走了一遍。说实话一级缓存在日常开发里存在感很低很多人只知道“Mybatis默认开了一级缓存”但问到底怎么命中、什么时候失效、源码里到底怎么存的多半答不上来。这篇文章就从一个实际开发者的视角把一级缓存的源码实现和效果演示一次性讲透包括缓存命中的判定规则、失效触发点、以及怎么通过日志验证缓存行为。如果你写过Mybatis项目或者正在被“查了两次数据库但SQL只打印了一条”这类现象困扰这篇应该能帮上忙。1. 一级缓存的整体设计它到底缓存在哪一层1.1 从一次普通查询请求说起先还原一个典型的查询链路你调用userMapper.selectById(1)实际经过了 SqlSession - Executor - StatementHandler - PreparedStatementHandler - JDBC 这条管道。一级缓存的存储和执行都发生在 Executor 这一层更准确地说在BaseExecutor这个抽象类里。在较新的稳定版本中BaseExecutor内部有两个关键缓存对象protected PerpetualCache localCache; protected PerpetualCache localOutputParameterCache;localCache负责存储查询结果对象localOutputParameterCache额外存储存储过程调用后的输出参数。这两个对象都是PerpetualCache类型它的实现相当朴素内部就是一个HashMap然后所有方法都用synchronized做了同步。所以一级缓存本质上是 Executor 实例的成员变量生命周期跟 Executor 一致。再往后追一层Executor 是由 SqlSession 创建出来的默认实现DefaultSqlSession里持有 Executor 引用。因此“一级缓存的生命周期 SqlSession 的生命周期”这种说法在源码层面是完全成立的。很多人在 Spring 管理下用 Mybatis感受不到 SqlSession 的存在因为SqlSessionTemplate会在每次方法调用时创建新的 SqlSession用完了立刻关掉。这也是为什么很多人从来没遇到过一级缓存的命中因为每次查询都换了一个全新的 Executor本地缓存自然也就跟着丢了。1.2 为什么选本地 HashMap而不是引入第三方缓存我最初也有个疑问既然叫“缓存”为什么不直接上 Redis 或者引入 Caffeine 之类的本地缓存库看完源码就明白了一级缓存的定位非常明确——它只是会话内部的去重手段不是跨会话的共享存储。HashMap 的优势在于零依赖、访问速度快、实现简单。Mybatis 的核心目标之一是轻量不可能为了会话级缓存引入重量级组件。另外一级缓存的数据是从数据库查出来的完整对象不是序列化后的字节直接放堆内存即可。如果换成 Redis 这类外部缓存就要考虑序列化、反序列化、网络开销反而会拖慢一次简单的重复查询。还有一点值得注意PerpetualCache的HashMap是无界存储不做淘汰策略。它只依赖清空操作来避免内存膨胀清空时机包括 update 语句执行、事务提交/回滚、SqlSession close。这带来一个隐患如果某个 SqlSession 长时间不关闭而且一直在查不同数据缓存对象会不断堆积。在长会话场景比如手动打开 SqlSession 跑批处理内存增长会比想象中快需要自行控制查询总量或及时关闭会话。2. 缓存命中判定CacheKey 的构成和规则2.1 缓存键不只是 SQL 字符串如果一级缓存直接用 SQL 字符串当 key那不同 Mapper 方法里的相同 SQL 就会串数据而且参数变化时也没法区分。所以 Mybatis 专门设计了CacheKey类用来唯一标识一次查询请求。从源码看构造 CacheKey 的关键在update方法public void update(Object object) { int baseHashCode this.hashCode; this.count; this.checkSum baseHashCode; baseHashCode 31 * baseHashCode (object null ? 1 : object.hashCode()); this.hashCode baseHashCode; this.updateList.add(object); }接下来看BaseExecutor创建 CacheKey 的完整过程public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) { final CacheKey cacheKey new CacheKey(); cacheKey.update(ms.getId()); cacheKey.update(rowBounds.getOffset()); cacheKey.update(rowBounds.getLimit()); cacheKey.update(boundSql.getSql()); cacheKey.update(ms.getKeyProperties()); // 省略了 parameterObject 参与 hash 的拼接逻辑 return cacheKey; }所以最终缓存键中至少包含这些要素MappedStatement 的唯一 ID即namespace idRowBounds 的 offset 和 limit分页参数BoundSql 解析后的完整 SQLMapper 接口方法上的Options等注解里的 KeyProperties 配置参数对象本身通过 update 逐个塞进 updateList也就是说哪怕 SQL 完全相同只要 Mapper ID 不同、分页范围不同、参数对象 hash 不同都会被判定为不同查询。这个设计的核心目的是保证缓存键精确对应数据库返回的结果集宁可多存几个 key也不能把不同请求的结果混在一起。2.2 参数对象对命中率的实际影响这里要展开说一个很常见的“坑”如果查询参数是一个普通的 POJO 对象而你没有重写它的equals和hashCode那两次“内容相同”的查询很可能会被判定为不同请求。比如下面这种查询UserQuery query1 new UserQuery(); query1.setId(1); UserQuery query2 new UserQuery(); query2.setId(1); userMapper.selectByQuery(query1); userMapper.selectByQuery(query2);两个对象的字段值一模一样但默认继承自 Object 的hashCode()基于对象身份计算结果很可能不同于是 CacheKey 里的校验和、hashCode 都会不一样第二次查询照样会发出 SQL一级缓存形同虚设。我在实际项目里见到过不少类似现象本地开发环境数据库响应快测试没压力看不出问题一旦上了生产对同一个接口的重复查询每次都穿透到数据库慢查询大量堆积。排查到最后才发现是因为参数对象没有实现equals/hashCode一级缓存根本没法命中。所以如果你想让一级缓存发挥明显作用查询参数尽量使用基础类型、String、Integer 这类不可变对象或者确保自定义参数对象重写了equals和hashCode。2.3 缓存命中时的精确匹配流程命中判定还不只是“两个 CacheKey 的 hashCode 相等”这么简单。PerpetualCache的 get 方法调用底层 HashMap 的 get但 CacheKey 自身重写了equals会逐个比对updateList中的每个元素是否一致。Override public boolean equals(Object object) { if (this object) { return true; } if (!(object instanceof CacheKey)) { return false; } final CacheKey cacheKey (CacheKey) object; if (hashCode() ! cacheKey.hashCode()) { return false; } if (checkSum ! cacheKey.checkSum) { return false; } if (count ! cacheKey.count) { return false; } // 逐个比较 updateList 中的元素 for (int i 0; i updateList.size(); i) { Object thisObject updateList.get(i); Object thatObject cacheKey.updateList.get(i); if (!Objects.equals(thisObject, thatObject)) { return false; } } return true; }这个流程既高效又严谨先比 hash 和 checkSum 这种整数可以快速排除大量不匹配的 key最后才做元素级比对。如果参数对象没有重写 equals这里就会出问题——hash 可能一样但Objects.equals判断两个新对象不相等依然不命中。另外提醒一下如果查询参数是一个 MapMybatis 会把 Map 包装成 ParamMap里面可能包含额外的param1、param2之类键。只要两次查询传入 Map 的内容一致ParamMap 的 equals/hashCode 也是基于关键字段一般能正常命中。但如果 Map 里混入了不可序列化、hash 不稳定的对象同样会造成反复不命中的假象。这个细节在排查时要多加留意。3. 命中与失效BaseExecutor 源码里的完整链路3.1 查询操作如何先查缓存再查数据库二级标题讲了 CacheKey 的生成接下来看BaseExecutor.query()的核心逻辑。这里把关键的代码流程简化一下Override public E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException { BoundSql boundSql ms.getBoundSql(parameter); CacheKey key createCacheKey(ms, parameter, rowBounds, boundSql); return query(ms, parameter, rowBounds, resultHandler, key, boundSql); } SuppressWarnings(unchecked) Override public E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { // 记录栈轨迹用于动态代理或调试判断 ErrorContext.instance().resource(ms.getResource()).activity(executing a query).object(ms.getId()); // 先处理需要强制清空缓存的场景 if (queryStack 0 ms.isFlushCacheRequired()) { clearLocalCache(); } ListE list; try { queryStack; // 重点先从本地缓存取值 list resultHandler null ? (ListE) localCache.getObject(key) : null; if (list ! null) { handleLocallyCachedOutputParameters(ms, key, parameter, boundSql); } else { // 缓存未命中执行真正的数据库查询 list queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); } } finally { queryStack--; } if (queryStack 0) { for (DeferredLoad deferredLoad : deferredLoads) { deferredLoad.load(); } deferredLoads.clear(); if (configuration.getLocalCacheScope() LocalCacheScope.STATEMENT) { clearLocalCache(); } } return list; }这里的执行顺序是创建 CacheKey - 检查是否需要强制清缓存 - 从 localCache 取数据 - 未命中则 queryFromDatabase - 等嵌套查询完成后再按 STATEMENT 作用域清空。queryFromDatabase方法内部更直接它先把一个占位符塞进缓存防止嵌套循环递归查询然后执行 doQuery最后把真实结果覆盖进去private E ListE queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { localCache.putObject(key, EXECUTION_PLACEHOLDER); try { ListE list doQuery(ms, parameter, rowBounds, resultHandler, boundSql); localCache.putObject(key, list); return list; } catch (Exception e) { localCache.removeObject(key); throw e; } }占位符放在缓存里的时间非常短主要作用是防止带 的嵌套查询在递归执行时无限套娃。这个细节在阅读源码时很容易忽略但对理解 Mybatis 的查询链路很重要。3.2 update 操作总是先清空缓存一级缓存中缓存的是数据库对象只要数据库里的数据发生了变化缓存就不可靠了。Mybatis 的处理方式非常“简单粗暴”一旦执行 update/insert/delete就把本地缓存全部清空。看 BaseExecutor.update 方法Override public int update(MappedStatement ms, Object parameter) throws SQLException { ErrorContext.instance().resource(ms.getResource()).activity(executing an update).object(ms.getId()); if (ms.isFlushCacheRequired()) { clearLocalCache(); } return doUpdate(ms, parameter); }这里注意只有ms.isFlushCacheRequired()为 true 时才会清空。对于普通 insert/update/delete 语句Mybatis 默认在 MappedStatement 构建时把flushCacheRequired设为 true所以最终表现就是所有写操作都会触发一级缓存清空。对于查询语句默认允许使用缓存除非你在 XML 里显式设了flushCachetrue。清空缓存的真正意义在于Mybatis 无法保证数据库端会不会有触发器、其他会话修改等外部因素影响数据只要本次会话内发生了写操作最稳妥的策略就是放弃之前的缓存下次查询老老实实走数据库。有个容易被忽视的坑是如果你在同一个 SqlSession 里先 select 一条数据然后执行了一次 update但 update 行数为 0比如更新条件不匹配一级缓存照样被清空。因为清空逻辑发生在执行 update 语句本身而不是“更新成功才算”。所以哪怕你 update 了一条不存在的数据后续查询的结果也一定是新的 SQL 查询这个行为和数据库事务隔离级别无关。3.3 事务提交、回滚和关闭时缓存去哪了BaseExecutor.commit 方法里有一行非常关键public void commit(boolean force) { if (closed) { throw new ExecutorException(Cannot commit, transaction is already closed); } clearLocalCache(); // ... 省略 flushStatements 和 transaction.commit() }rollback 方法也是同样操作先clearLocalCache()再执行回滚。这样设计的理由其实很简单事务提交后数据库状态可能已经变化同时缓存中的对象和数据库中的快照也可能不一致事务回滚则意味着这一段会话内的查询结果全都建立在即将废弃的数据状态上继续留着没有意义。close()方法在关闭 Executor 前同样会调用clearLocalCache()。这一点在日常使用中非常重要——Spring 环境下每次访问数据库都是新 SqlSession查询完立即关闭缓存自然清零如果你手动复用同一个 SqlSession 做多步操作事务边界要规划好否则很容易把“缓存清空”误判成“缓存没生效”。下面用一张表整理一下常见操作的缓存影响操作是否清空一级缓存原因普通 select否只读查询缓存可复用select 且 flushCachetrue是显式要求查询前清缓存存储过程调用是输出参数可能改变状态insert/update/delete是数据库状态发生变化SqlSession.commit是事务结束快照失效SqlSession.rollback是恢复到回滚点之前缓存不可信SqlSession.close是Executor 生命周期结束看到这里你应该能发现一级缓存的“生命周期”其实非常短暂只要会话结束或发生写操作缓存就会被清得一干二净。它的价值主要体现在“同一个会话内、事务内、不涉及写操作”的重复查询场景。4. 效果演示通过日志观察缓存命中与未命中4.1 搭一个最小可跑的演示工程源码分析得再多不如亲手跑一遍。我建议你用一个最小工程来做验证不要一开始就引入 Spring 框架因为 Spring 的 SqlSessionTemplate 每次都会新建会话会让演示变得混乱。工程结构大致如下mybatis-cache-demo ├── pom.xml ├── src/main/resources/mybatis-config.xml └── src/main/java/com/demo/ ├── MybatisCacheDemo.java ├── User.java └── UserMapper.javapom.xml 只需要引入 mybatis 和数据库驱动。为了方便演示我用 H2 内存数据库这样不需要额外装数据库服务dependencies dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.16/version /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId version2.2.224/version /dependency /dependenciesUserMapper 接口只留两个方法一个查询一个更新public interface UserMapper { User selectById(int id); int updateName(int id, String name); }对应的 XML 映射文件放在和接口同包目录这个我不多写相信你能搞定。重点是mybatis-config.xml里开启日志这样才能在控制台看到 SQL 执行次数configuration settings setting namelogImpl valueSTDOUT_LOGGING/ /settings environments defaultdevelopment environment iddevelopment transactionManager typeJDBC/ dataSource typePOOLED property namedriver valueorg.h2.Driver/ property nameurl valuejdbc:h2:mem:demo/ property nameusername valuesa/ property namepassword value/ /dataSource /environment /environments mappers mapper resourcecom/demo/UserMapper.xml/ /mappers /configuration4.2 同一个 SqlSession 下的两次查询核心测试代码就这几行public static void main(String[] args) { try (SqlSession session createSqlSessionFactory().openSession()) { UserMapper mapper session.getMapper(UserMapper.class); System.out.println( 第一次查询 ); User user1 mapper.selectById(1); System.out.println( 第二次查询 ); User user2 mapper.selectById(1); System.out.println(同一对象引用: (user1 user2)); } }运行结果中日志只会打印一条select语句因为第二次查询命中了缓存压根没走到 JDBC。而user1 user2返回 true说明不仅没查数据库连返回的对象引用都和第一次是同一个。这里有个很重要的细节一级缓存缓存的是对象引用不是对象拷贝。如果第一次查询后你把user1的某个字段改了第二次拿到的user2也会是改过的值哪怕数据库里实际还是旧值。这就是所谓的“缓存对象被污染”会带来潜伏很深的 bug。这也是 Mybatis 官方对一级缓存态度谨慎的原因之一它只适合作为会话内短生命周期的临时加速手段不适合承载业务长时间持有的数据。然后再演示一下缓存被 update 清掉的场景System.out.println( 执行 update ); mapper.updateName(1, 新名字); System.out.println( 第三次查询 ); User user3 mapper.selectById(1); System.out.println(新的名字: user3.getName());这次日志里会打出 update 语句紧接着第三次查询也会打印出一条新的 select 语句因为 update 已经把 localCache 清空了。如果你在这个 update 之后插入一条新的 select那个 SQL 会真实执行并把最新结果重新放入缓存。4.3 SESSION 作用域与 STATEMENT 作用域对比Mybatis 配置里有一个localCacheScope参数默认是SESSION可以改成STATEMENT。两者的差异用一句话概括SESSION 模式下缓存存活到会话结束或写操作发生STATEMENT 模式下每次查询结束后立刻清空当前 CacheKey 相关的缓存等于查询后缓存即失效。我把配置改成 STATEMENT 再跑同一套代码settings setting namelocalCacheScope valueSTATEMENT/ /settings日志立刻发生了变化就算同一个 SqlSession 里连续两次selectById(1)也会打印两条 SQL。因为在query方法的 finally 块里查询一旦结束就会调用clearLocalCache()第二次查询等于重新从数据库读。这两种模式的选择逻辑其实很清晰如果你的服务全部由 Spring 管理每次查询都用新会话SESSION 和 STATEMENT 的实际体验差别不大因为新会话一开始就没有缓存。如果你自己管理 SqlSession 生命周期同一个会话内经常做多次查询且没有写操作SESSION 模式能明显减少数据库压力但如果你的系统并发高、多线程共享同一个 SqlSession那 STATEMENT 模式更安全因为缓存对象本身不是线程安全的多个线程同时操作同一个缓存很容易出问题。下面列出两种模式的适用场景对比对比维度SESSIONSTATEMENT缓存存活时间跟随 SqlSession单次查询结束后立即失效重复查询命中率高低并发安全风险较高缓存对象可能被共享修改较低典型使用场景长事务、批量处理、同一会话内多次查询高并发共享会话、对实时性要求高的场景4.4 多 SqlSession 之间的“缓存隔离”演示一级缓存只存在于 Executor 内部不同 SqlSession 之间的缓存完全隔离。我用两个 SqlSession 来演示SqlSession session1 factory.openSession(); SqlSession session2 factory.openSession(); UserMapper mapper1 session1.getMapper(UserMapper.class); UserMapper mapper2 session2.getMapper(UserMapper.class); mapper1.selectById(1); mapper2.selectById(1);日志会打印两条 select这就是最直观的证据二级缓存负责跨 SqlSession 共享一级缓存没有这个能力。很多业务里“我明明已经查过了为什么第二次还查数据库”的疑问多半是因为每次请求都新建了 SqlSession一级缓存根本来不及发挥作用。在 Spring Mybatis 的组合下如果没有开启事务每次 mapper 方法调用都会经历SqlSessionTemplate—— 它内部绑定到当前事务的 SqlSession不存在事务时创建临时会话用完立即关闭。因此一级缓存的实际命中窗口非常窄只有在同一个事务内的重复查询才有机会命中。这对本来就不指望一级缓存解决性能问题的项目影响不大但如果你天真地以为“Mybatis 默认有一级缓存重复查询不会打数据库”那结果基本会让人失望。5. 实战踩坑记录与故障排查建议5.1 日志里 SQL 出现次数和预想不一致开发中最常见的困惑是明明同一个事务里查了三次同一条数据日志只打印了一条 SQL或者反过来两次查同一条数据打了两次 SQL自己却以为缓存一定会命中。我的排查经验很简单先在配置里把 SQL 日志完整打开然后数一数每一条 SQL 的执行次数。不要靠直觉判断缓存是否命中日志不会骗人。如果同一段代码、同一事务、同一个 SqlSession 内SQL 重复执行了优先检查这几个方向查询的参数对象是否重写了 equals/hashCode是否在查询后执行过任意 update/insert/delete哪怕是 DDL 或存储过程两个查询是否用的是不同的 Mapper 方法但正好 SQL 一样是否存在分页插件包裹逻辑导致 RowBounds 参数不一致我遇到过一个真实案例某同事用 Map 传入查询条件Map 里塞了一个每次调用都会重新生成的自定义对象那个对象没有重写 equals/hashCode结果一级缓存从未命中数据库连接池长期跑满。排查过程花了不少时间最后定位到参数对象上重写 equals/hashCode 后问题立刻缓解。5.2 缓存对象被修改导致的“幽灵数据”一级缓存返回的是同一个对象引用这一点必须刻在脑子里。试想一个场景同一个事务内你先查了一个订单对象因为某些逻辑把它改成了“已支付”第二次再查同一个订单你拿到的依然是那个被改过的对象而不是从数据库重新读出来的最新值。这种行为在某些业务下非常危险如果第一次查询返回的对象是为了展示结果被某个方法不小心改动了字段后续所有读同一订单的地方都会看到被污染的数据。我的建议是如果业务代码里有修改查询结果对象的习惯并且这些修改不打算真正写回数据库要么使用flushCachetrue让查询强制绕过缓存要么对查询结果做浅拷贝。虽然会多消耗一点内存但能避免那些让人抓狂的疑难 bug。5.3 flushCache 参数的正确使用姿势XML 映射中查询标签可以显式设置select idselectById resultTypecom.demo.User flushCachetrue select * from user where id #{id} /select加了这个配置后每次执行该查询都会先clearLocalCache()查询结果也会重新放入缓存。它的应用场景是查询之前可能要看到最新数据但又不想放弃后续复用这条缓存。比如在同一个事务里你执行过一次 update 导致缓存被清空然后接着马上查同一条数据并希望后续查到的是这条新数据这时可以用 flushCachetrue 强制让该查询结果成为新的缓存内容。但要提醒一点不要给所有查询都加 flushCachetrue。这样做的后果是每次查询都会清空整个会话缓存一级缓存彻底失去作用和 STATEMENT 模式的效果没什么区别。只有那些对实时性要求特别高的关键查询才需要这个配置。5.4 自定义拦截器对一级缓存的隐性破坏Mybatis 允许通过拦截器修改 Executor 或 StatementHandler 的逻辑很多分页插件、审计插件就是这么做的。但拦截器如果处理不当极容易破坏一级缓存的行为。常见的坑是分页插件在执行前动态修改 SQL 和分页参数但它没有同步更新 Executor 层已经构造好的 CacheKey。结果第一次查询的 CacheKey 是基于原始 SQL 的第二次查询时 SQL 被插件替换成了不同内容CacheKey 又不一致导致永远不命中。反过来如果插件对结果集做了后处理但把处理后的对象值覆盖到缓存里下一次查询拿到的就是“二次处理”过的脏数据。如果你要编写自定义拦截器建议在拦截 Executor.query 时不要随意修改 MappedStatement 的 BoundSql除非你同时处理好 CacheKey 的更新。如果只是对查询结果做装饰尽量保证它是幂等操作否则缓存数据会被反复装饰越描越黑。另外如果你开启了二级缓存拦截器里clearLocalCache的调用时机也要谨慎两层缓存之间存在级联关系一层清了另一层未必跟着清二次缓存命中的对象也可能藏着旧状态。5.5 那二级缓存呢顺带提一句很多人容易把一级缓存和二级缓存搞混。一级缓存在 Executor 层默认开启作用域是 SqlSession二级缓存在 CachingExecutor 层需要手动开启作用域是 namespace即 Mapper多个 SqlSession 可以共享。二级缓存的存取会经过序列化通常配置 Redis 或 Ehcache 这类外部存储。如果一个查询同时开启了两级缓存查找顺序是二级缓存 - 一级缓存 - 数据库。更新顺序反过来写数据库 - 清一级缓存 - 清相关 namespace 的二级缓存。由于二级缓存跨会话共享并发场景下数据一致性问题更明显实际开发中我很少直接依赖二级缓存做核心业务它更适合读多写少、实时性要求不高的查询场景。回到一级缓存本身我的结论是它不像很多人想象中那样是“性能救星”也不是完全无用的摆设。把它理解成“会话内的临时去重工具”最合适。理解源码里那几个关键决策点——为什么用 HashMap、CacheKey 怎么构建、什么时候清空——之后你在排查线上问题时会多一条清晰的路子。最后再分享一个我自己的观察很多团队在一级缓存上的误解都来自没有认真看日志。把它当成一个“黑盒”就会出现各种猜测但在源码里走一遍你就知道问题到底出在 Session 生命周期、参数对象 hash还是缓存命中后的对象引用污染上了。如果你也遇到过类似情况不妨从这几个角度重新排查一下。