
Java-11 MyBatis 一级缓存详解SqlSession 本地缓存、CacheKey 与失效场景TL;DR场景在 MyBatis 持久层开发中同一个查询方法连续执行两次第二次可能不会再次访问数据库。核心结论MyBatis 一级缓存是SqlSession级别的本地缓存默认开启。相同SqlSession内相同 SQL、相同参数、相同分页条件的查询可能命中缓存。失效条件执行insert、update、delete、commit、rollback、close或者手动调用clearCache()都会导致当前 session 的本地缓存被清理。源码结构一级缓存主要由BaseExecutor持有底层使用PerpetualCache当前 MyBatis 3.5.x 中PerpetualCache内部使用HashMapObject, Object存储数据。注意点一级缓存不是业务缓存也不是跨请求缓存。它主要用于同一个SqlSession内减少重复查询并辅助处理嵌套查询、循环引用等问题。核心关键词MyBatis、一级缓存、SqlSession、本地缓存、CacheKey、BaseExecutor、PerpetualCache、localCacheScope、缓存失效背景与问题在使用 MyBatis 查询数据库时经常会看到这样的现象同一个SqlSession中连续执行两次相同的查询方法控制台只打印了一次 SQL。这不是日志丢失也不是数据库没有执行成功而是 MyBatis 的一级缓存生效了。一级缓存是 MyBatis 默认启用的本地缓存。它对开发者基本透明所以很多时候并不会被显式感知。但如果不了解它的作用范围和失效条件就容易产生几个误判为什么第二次查询没有打印 SQL为什么执行一次update后第二次查询又重新访问数据库一级缓存是不是全局缓存一级缓存能不能用来做业务缓存在 Spring 项目中它和事务、Mapper、SqlSession 又是什么关系本文围绕这些问题结合示例代码和源码结构梳理 MyBatis 一级缓存的工作方式。环境与版本本文以 MyBatis 3.5.x 的常见行为为说明对象示例代码基于普通 Java main 方法演示。项目说明JDKJava 8 均可理解本文示例MyBatis以 MyBatis 3.5.x 为主数据库示例使用用户表和订单表数据库类型不影响一级缓存原理日志需要开启 MyBatis SQL 日志便于观察 SQL 是否真正执行示例方式通过SqlSessionFactory手动创建SqlSession说明MyBatis 的一级缓存属于框架内部行为核心机制长期稳定。但源码细节、默认配置说明和版本文档仍建议以当前项目实际依赖版本为准。如果是 Spring Boot MyBatis 项目SqlSession通常由框架托管观察方式和普通 main 方法略有不同后文会单独说明。一级缓存是什么MyBatis 中有两类缓存缓存类型作用范围默认状态主要用途一级缓存 / 本地缓存SqlSession级别默认开启减少同一 session 内重复查询二级缓存Mapper namespace 级别需要额外配置跨 session 复用查询结果本文只讨论一级缓存。一级缓存可以简单理解为MyBatis 在当前SqlSession内部维护了一个本地 Map。当执行查询时MyBatis 会根据当前查询生成一个CacheKey。如果这个 key 已经存在于本地缓存中就直接返回缓存中的结果如果不存在就访问数据库并把查询结果放入缓存。这个缓存不是全局缓存也不是 Redis、Caffeine 这类业务缓存。它只在当前SqlSession生命周期内有效。一级缓存为什么是 SqlSession 级别SqlSession是 MyBatis 执行 SQL、获取 Mapper、管理事务边界的核心入口。一级缓存绑定在SqlSession上主要有两个原因第一避免同一个会话中重复执行完全相同的查询。例如同一个业务流程里多次查询同一个用户信息如果 SQL 和参数完全一致第二次可以直接复用前一次查询结果。第二辅助处理嵌套查询和对象引用关系。MyBatis 在处理复杂结果映射、嵌套查询时需要避免重复加载和循环引用问题。本地缓存不仅是性能优化点也是框架内部执行流程的一部分。因此MyBatis 一级缓存默认无法完全关闭但可以通过localCacheScopeSTATEMENT把缓存范围缩小到单条语句执行期间。示例准备Mapper 与 SQL假设项目中有一个UserMapper提供两个方法publicinterfaceUserMapper{ListWzkUserfindAll();intupdateById(WzkUseruser);}对应的查询 SQL 类似如下selectidfindAllresultMapuserOrderMapselect *, o.id oid from wzk_user u left join wzk_orders o on u.id o.uid/select更新 SQL 类似如下updateidupdateByIdparameterTypecom.example.WzkUserupdate wzk_user set username #{username}, password #{password} where id #{id}/update为了观察 SQL 是否执行需要开启 MyBatis 日志。例如可以在 MyBatis 配置中使用settingssettingnamelogImplvalueSTDOUT_LOGGING//settings如果项目使用 Logback、Log4j2 或 Spring Boot也可以通过日志框架把 Mapper 包或 MyBatis 相关包调整到DEBUG级别。实验一同一个 SqlSession 内重复查询下面的代码在同一个SqlSession中连续执行两次findAll()。publicclassWzkicuCache01{publicstaticvoidmain(String[]args)throwsIOException{InputStreaminputStreamResources.getResourceAsStream(sqlMapConfig.xml);SqlSessionFactorysqlSessionFactorynewSqlSessionFactoryBuilder().build(inputStream);SqlSessionsqlSessionsqlSessionFactory.openSession();try{UserMapperuserMappersqlSession.getMapper(UserMapper.class);ListWzkUserwzkUseruserMapper.findAll();System.out.println(wzkUser);ListWzkUserwzkUser2userMapper.findAll();System.out.println(wzkUser2);}finally{sqlSession.close();}}}原文中的控制台截图如下从现象上看两次findAll()之间没有第二次 SQL 打印。原因是第一次查询时本地缓存中还没有对应的CacheKeyMyBatis 会访问数据库并把查询结果放入当前SqlSession的一级缓存。第二次查询时SqlSession没有关闭SQL、参数、分页条件等信息也没有变化因此生成的CacheKey相同MyBatis 可以直接从本地缓存中返回结果。这里要注意一个细节命中一级缓存的前提不是“查询了同一张表”而是“生成的CacheKey一致”。SQL 语句、参数、分页条件、MappedStatement id 等因素都会影响缓存 key。实验二查询后执行 UPDATE缓存为什么失效下面的示例在两次查询中间加入一次updateById()然后执行commit()。publicclassWzkicuCache02{publicstaticvoidmain(String[]args)throwsIOException{InputStreaminputStreamResources.getResourceAsStream(sqlMapConfig.xml);SqlSessionFactorysqlSessionFactorynewSqlSessionFactoryBuilder().build(inputStream);SqlSessionsqlSessionsqlSessionFactory.openSession();try{UserMapperuserMappersqlSession.getMapper(UserMapper.class);// 第一次查询ListWzkUserwzkUseruserMapper.findAll();System.out.println(wzkUser);// 执行一次 UPDATEWzkUserwzkUserUpdateWzkUser.builder().id(1).username(wzk-update).password(123-update).build();userMapper.updateById(wzkUserUpdate);// 提交事务sqlSession.commit();// 再次查询ListWzkUserwzkUser2userMapper.findAll();System.out.println(wzkUser2);}finally{sqlSession.close();}}}原文中的控制台截图如下这一次可以观察到执行顺序变成了第一次查询 SQL UPDATE SQL 第二次查询 SQL这说明第二次查询没有复用第一次查询的缓存。这里需要把原因说准确不是只有commit()会清空缓存update()本身也会清空当前SqlSession的本地缓存。在 MyBatis 的执行流程中只要发生数据修改操作本地缓存就不应该继续保留旧查询结果。否则同一个 session 后续查询可能读到修改前的数据。所以这个实验验证的是在同一个SqlSession中查询之后如果执行了写操作原来的一级缓存会失效后续相同查询需要重新访问数据库。一级缓存的基本工作流程可以把一级缓存的流程简化为下面几步第一次查询MyBatis 根据当前查询生成CacheKey。到当前SqlSession的本地缓存中查找。如果没有命中执行数据库查询。查询结果写入本地缓存。返回查询结果。第二次相同查询再次生成CacheKey。到当前SqlSession的本地缓存中查找。如果命中直接返回缓存结果。不再执行 SQL。中间发生写操作或事务操作执行insert、update、delete时清空本地缓存。执行commit、rollback时清空本地缓存。调用clearCache()时清空本地缓存。SqlSession关闭后本地缓存随 session 生命周期结束。关键配置解释localCacheScopeMyBatis 提供了一个和一级缓存相关的重要配置settingssettingnamelocalCacheScopevalueSESSION//settings默认值是SESSION。配置值含义SESSION默认值。同一个SqlSession内查询结果会在整个 session 生命周期内复用。STATEMENT本地缓存只在语句执行期间使用不会在同一个SqlSession的多次调用之间共享数据。如果配置为STATEMENT前面“同一个 session 连续查询两次只打印一次 SQL”的现象通常就不会出现。因为每条语句执行结束后本地缓存就会被清空。示例配置settingssettingnamelocalCacheScopevalueSTATEMENT//settingsSTATEMENT并不等于完全关闭一级缓存。MyBatis 仍然会在语句执行过程中使用本地缓存只是不会把缓存数据保留到下一次查询调用。源码链路DefaultSqlSession → Executor → BaseExecutor → PerpetualCache从调用链看一次 Mapper 查询大致会经过以下层级Mapper 接口方法 ↓ DefaultSqlSession ↓ Executor ↓ BaseExecutor ↓ PerpetualCache原文中的源码截图如下一级缓存的核心字段在BaseExecutor中protectedPerpetualCachelocalCache;protectedPerpetualCachelocalOutputParameterCache;创建BaseExecutor时会初始化本地缓存this.localCachenewPerpetualCache(LocalCache);this.localOutputParameterCachenewPerpetualCache(LocalOutputParameterCache);PerpetualCache是 MyBatis 的一个缓存实现。当前 3.5.x 源码中它内部维护的是一个MapObject, Object具体实现是HashMap。可以简化理解为privatefinalMapObject,ObjectcachenewHashMap();当调用clear()时本质上就是清空这个 MapOverridepublicvoidclear(){cache.clear();}原文中的源码截图如下CacheKey 如何生成一级缓存能否命中关键在于CacheKey是否一致。在BaseExecutor中查询前会创建CacheKeyCacheKey不是简单地只用 SQL 字符串作为 key。它通常会综合以下信息组成部分作用MappedStatement id区分不同 Mapper 方法RowBounds offset区分页偏移RowBounds limit区分页大小BoundSql sql区分最终 SQL 文本参数值区分不同查询条件Environment id区分不同环境配置因此下面这些情况都可能导致一级缓存不命中Mapper 方法不同SQL 文本不同参数不同分页条件不同动态 SQL 最终生成结果不同不在同一个SqlSession中。原文中提到“SQL 和参数作为键”是一个便于理解的简化说法。更准确的说法是MyBatis 会基于查询上下文生成CacheKeySQL 和参数只是其中最重要的组成部分。查询时如何使用一级缓存在BaseExecutor.query()中核心逻辑可以简化为listresultHandlernull?(ListE)localCache.getObject(key):null;if(list!null){handleLocallyCachedOutputParameters(ms,key,parameter,boundSql);}else{listqueryFromDatabase(ms,parameter,rowBounds,resultHandler,key,boundSql);}也就是先通过CacheKey到localCache查询。如果命中直接返回。如果未命中调用queryFromDatabase()查询数据库。数据库查询结果会写入本地缓存。原文中的源码截图如下一级缓存的命中条件一级缓存命中通常需要同时满足以下条件条件说明同一个SqlSession不同 session 之间一级缓存隔离相同 Mapper 语句MappedStatement id需要一致相同 SQL动态 SQL 最终生成结果需要一致相同参数查询参数值需要一致相同分页条件RowBounds信息会影响CacheKey中间没有清空缓存没有执行写操作、事务操作或clearCache()localCacheScopeSESSION如果是STATEMENT跨语句复用会失效示例ListWzkUserlist1userMapper.findAll();ListWzkUserlist2userMapper.findAll();如果这两次调用发生在同一个SqlSession中并且中间没有写操作或清理缓存就可能命中一级缓存。一级缓存的失效场景一级缓存失效主要有以下几类。1. 使用了不同的 SqlSessionSqlSessionsqlSession1sqlSessionFactory.openSession();SqlSessionsqlSession2sqlSessionFactory.openSession();sqlSession1和sqlSession2各自有独立的本地缓存。即使两次查询 SQL 完全相同只要不在同一个 session 中就不能共享一级缓存。2. 执行了 insert、update、deleteuserMapper.findAll();userMapper.updateById(user);userMapper.findAll();执行写操作后当前 session 的本地缓存会被清空。原因是写操作可能改变数据库状态。为了避免后续查询继续读取旧缓存MyBatis 会主动清理本地缓存。3. 执行了 commit 或 rollbackuserMapper.findAll();sqlSession.commit();userMapper.findAll();commit()和rollback()都属于事务边界操作。事务状态发生变化后本地缓存继续保留可能造成数据一致性问题因此会被清空。4. 手动调用 clearCache()userMapper.findAll();sqlSession.clearCache();userMapper.findAll();clearCache()用于主动清空当前SqlSession的本地缓存。如果怀疑缓存影响后续查询可以手动调用它。但正常业务代码中不建议到处调用clearCache()掩盖事务边界设计问题。5. SQL 或参数发生变化下面两次查询不会共用同一个CacheKeyuserMapper.findById(1L);userMapper.findById(2L);即使查询的是同一张表只要参数不同就不会命中同一个缓存项。动态 SQL 也需要注意selectidfindByConditionresultTypeWzkUserselect * from wzk_userwhereiftestusername ! nullusername #{username}/if/where/select如果传入条件不同最终生成的 SQL 可能不同CacheKey也会不同。6. localCacheScope 设置为 STATEMENT如果配置为settingssettingnamelocalCacheScopevalueSTATEMENT//settings那么本地缓存不会在同一个SqlSession的多次查询调用之间共享。这种配置更保守适合不希望查询结果在 session 内被复用的场景。验证方式原文已经通过控制台日志截图观察到一级缓存现象。为了让验证更清晰可以按下面方式补充验证。验证一同一个 SqlSession 连续查询执行代码ListWzkUserlist1userMapper.findAll();ListWzkUserlist2userMapper.findAll();观察点控制台是否只打印一次Preparing: select ...第二次查询是否没有再次输出 SQL两次查询是否在同一个SqlSession中预期现象第一次查询执行 SQL 第二次查询不执行 SQL命中一级缓存不要直接伪造日志。不同日志框架、不同 MyBatis 配置下输出格式可能不同。验证二查询后执行 UPDATE执行代码ListWzkUserlist1userMapper.findAll();userMapper.updateById(user);sqlSession.commit();ListWzkUserlist2userMapper.findAll();观察点第一次查询是否打印 SQLupdateById()是否打印 UPDATE SQL第二次findAll()是否重新打印 SELECT SQL。预期现象第一次查询执行 SQL UPDATE执行 SQL并清空本地缓存 commit提交事务并清空本地缓存 第二次查询重新执行 SQL验证三手动 clearCache可以增加一个单独实验ListWzkUserlist1userMapper.findAll();sqlSession.clearCache();ListWzkUserlist2userMapper.findAll();预期现象第一次查询执行 SQL clearCache清空当前 SqlSession 本地缓存 第二次查询重新执行 SQL验证四设置 localCacheScopeSTATEMENT修改配置settingssettingnamelocalCacheScopevalueSTATEMENT//settings然后执行ListWzkUserlist1userMapper.findAll();ListWzkUserlist2userMapper.findAll();预期现象第一次查询执行 SQL 第二次查询重新执行 SQL这个实验可以帮助理解SESSION和STATEMENT的区别。常见问题1. 一级缓存是不是默认开启是。MyBatis 默认会使用本地缓存。但默认开启不等于可以跨 session 使用。一级缓存只在当前SqlSession内有效。2. 一级缓存能不能关闭严格来说MyBatis 的本地缓存不能完全关闭因为它还承担了处理循环引用和嵌套查询的内部职责。如果不希望同一个SqlSession内跨语句复用查询结果可以把localCacheScope设置为STATEMENT。settingssettingnamelocalCacheScopevalueSTATEMENT//settings3. 一级缓存和二级缓存有什么区别对比项一级缓存二级缓存作用范围SqlSessionMapper namespace默认状态默认开启需要配置生命周期随 session 结束而结束可以跨 session使用复杂度低开发者通常无感更高需要关注序列化、刷新策略、一致性适用场景同一 session 内重复查询特定读多写少场景一级缓存是基础机制二级缓存是更显式的缓存能力。二者不能混为一谈。4. Spring 项目中一级缓存还存在吗存在但观察方式不同。在 Spring MyBatis 项目中通常不会手动创建SqlSession而是通过SqlSessionTemplate和事务管理器托管。如果方法运行在同一个事务中底层可能复用同一个 session从而出现一级缓存命中的情况。如果没有事务边界或者每次 Mapper 调用对应不同 session就不一定能观察到连续查询命中一级缓存的现象。因此在 Spring 项目中讨论一级缓存时要结合事务边界一起看。5. 查询结果对象可以修改吗不建议直接修改 MyBatis 返回的对象或集合后又在同一个SqlSession中依赖相同查询结果。原因是当localCacheScopeSESSION时MyBatis 可能返回本地缓存中的同一个对象引用。你对返回对象的修改可能影响当前 session 后续从缓存中取出的结果。如果确实需要修改对象建议明确区分“查询结果对象”和“用于更新的对象”不要把本地缓存当成对象状态管理工具。6. 一级缓存能解决性能问题吗不能把一级缓存当成主要性能优化手段。一级缓存只解决同一个SqlSession内重复查询的问题。对于跨请求、跨线程、跨服务的重复查询它没有作用。真正的性能优化通常应该优先考虑SQL 是否合理索引是否命中是否存在 N1 查询是否需要分页是否需要业务缓存是否需要读写分离是否需要改造数据模型。一级缓存只是 MyBatis 的局部优化机制。错误速查卡症状可能原因定位方式处理方式两次相同查询都执行了 SQL两次查询不在同一个SqlSession中检查 session 创建位置和事务边界确认是否需要在同一事务或同一 session 内执行第二次查询没有执行 SQL一级缓存命中查看是否同一 session、相同 SQL、相同参数正常现象不需要处理UPDATE 后第二次查询重新执行 SQL写操作清空了本地缓存查看两次查询之间是否执行了 insert/update/delete正常现象符合一致性设计手动clearCache()后缓存失效主动清空了本地缓存搜索代码中的clearCache()删除不必要的清理或保留明确注释设置localCacheScopeSTATEMENT后无法复用缓存缓存范围被缩小到语句级别检查 MyBatis settings如果需要 session 级复用改回SESSION查询相同表但没有命中缓存SQL、参数、分页或 Mapper statement 不同对比最终 SQL、参数和 Mapper 方法确保生成的CacheKey一致Spring 项目中现象和 main 方法不同SqlSession 由 Spring 托管检查事务传播和 Mapper 调用边界结合事务分析不要直接套用 main 方法结论适用边界一级缓存适合用来理解 MyBatis 的执行机制但不适合直接当业务缓存使用。适用场景学习 MyBatis 查询执行流程分析为什么重复查询没有打印 SQL理解SqlSession生命周期排查同一事务内查询结果复用问题阅读BaseExecutor、CacheKey、PerpetualCache源码。不适用场景跨请求缓存跨线程缓存跨服务缓存高并发业务缓存数据强一致性要求很高但事务边界不清晰的场景希望通过一级缓存替代 Redis、本地缓存组件或数据库优化的场景。生产环境中不建议为了“提高缓存命中率”而刻意延长SqlSession生命周期。SqlSession应该短生命周期使用并且不能跨线程共享。总结MyBatis 一级缓存是默认开启的本地缓存作用范围是当前SqlSession。它的核心逻辑可以概括为查询时MyBatis 根据当前查询上下文生成CacheKey。如果当前SqlSession的本地缓存中存在该 key就直接返回缓存结果。如果没有命中就查询数据库并把结果写入本地缓存。当执行写操作、事务提交、事务回滚、关闭 session 或手动清理缓存时本地缓存会失效。如果配置localCacheScopeSTATEMENT缓存只在语句执行期间有效不会在多次查询调用之间复用。理解一级缓存的重点不是“记住它底层是 HashMap”而是理解它和SqlSession、CacheKey、事务边界之间的关系。在日常开发中一级缓存通常不需要手动配置。但当你看到“同样的查询为什么没有再次打印 SQL”或者“为什么更新后查询重新执行 SQL”时就需要知道这背后就是 MyBatis 一级缓存和缓存失效机制在起作用。作者武子康的个人博客