news 2026/10/1 20:42:14

MyBatis缓存机制完全解析:一级缓存、二级缓存原理与实战排坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis缓存机制完全解析:一级缓存、二级缓存原理与实战排坑

最近收到不少读者关于MyBatis缓存的私信,问的内容几乎能拼出一套完整的高频面试题:一级缓存和二级缓存到底什么区别?为什么SpringBoot项目里连续调用两次查询,SQL却照样打印两次?为什么开了二级缓存反而读到脏数据?还有人在看源码时被CacheKey、TransactionalCacheManager、装饰器这一堆类搞得晕头转向。这些问题我自己在项目里都踩过,也有过拿着源码一点点对日志的排查经历。今天就把MyBatis缓存机制从架构设计到源码原理、从配置细节到实战坑位完整拆一遍,包含我实测过的代码和排查记录,希望能帮你彻底把这个机制吃透。

1. MyBatis缓存机制整体架构与设计思路

1.1 两级缓存的定位与分工

MyBatis的缓存体系分为两级,这个划分本质上是按照生命周期和作用范围来设计的,而不是随意拍脑袋定出来的。

一级缓存也叫本地缓存(Local Cache),作用范围是SqlSession这一个会话。也就是说,在同一个数据库会话里连续执行两次完全相同的查询,第二次会直接从内存中拿结果,不再发SQL。一级缓存在MyBatis中默认开启,不需要任何配置。它的生命周期非常短,会话关闭、提交、回滚或执行增删改操作都会让它失效。

二级缓存也叫全局缓存(Second Level Cache),作用范围是同一个Mapper命名空间(namespace),可以跨多个SqlSession共享。简单理解就是:线程A查了一次UserMapper,线程B再去查同一个UserMapper时,能直接命中线程A留下的缓存。二级缓存默认关闭,需要我们在Mapper映射文件里显式开启。

这里有个非常容易混淆的点:查询顺序。当二级缓存开启后,一次查询请求从 Executor 开始,会先查二级缓存,没命中再到一级缓存,一级也没有会去数据库查,最后把结果逐级回填。这个顺序决定了"二级缓存优先级更高",也决定了命中二级缓存时压根不会走到一级缓存。

维度一级缓存二级缓存
默认开启是否
作用范围SqlSession(会话级)Mapper namespace(映射级)
共享范围同一会话内跨会话、跨线程
生命周期短,随会话结束而结束长,随Mapper清理
底层实现PerpetualCache(HashMap)装饰器包装的PerpetualCache
与Spring整合后无事务时基本失效不受事务影响

1.2 缓存实现的核心链条

要理解MyBatis缓存,绕不开一个核心设计:Executor(执行器)体系。MyBatis的SQL执行并不是一条直线,而是包了一层又一层。默认情况下,核心执行器是BaseExecutor,它内部维护着一个PerpetualCache localCache,这就是一级缓存的本体,其实就是一个HashMap的简单封装。

当配置了二级缓存之后,MyBatis会拿CachingExecutor把原来的执行器包一层,形成一个装饰器链。整个调用链路是这样的:

SqlSession(门面) → CachingExecutor(二级缓存处理) → BaseExecutor(执行SQL、一级缓存处理) → StatementHandler(最终与数据库打交道)

CachingExecutor负责二级缓存的读写,它内部持有一个TransactionalCacheManager(事务缓存管理器),用于管理每个Mapper各自对应的二级缓存区块。BaseExecutor则负责一级缓存的读写和SQL真正执行。

这样设计的核心价值在于:职责单一,可插拔。缓存逻辑被装饰器拆开,不需要侵入SQL执行的核心流程;你想关闭二级缓存,直接把CachingExecutor摘掉即可,一级缓存逻辑完全不受影响。实际上源码里Configuration.newExecutor就是根据cacheEnabled属性决定要不要加这层装饰器。

1.3 为什么设计成这种结构

我在看源码时有个很深的体会:MyBatis的缓存设计是"谨慎"的,宁可不够智能,也不愿意引入复杂的一致性控制。它没有像Hibernate那样提供精细的缓存策略,而是把选择权完全交给使用者。一级缓存的失效条件非常激进——只要执行了增删改,立刻清空整个localCache,因为MyBatis无法追踪某条SQL到底影响了哪些表,最安全的做法就是"全清"。二级缓存则干脆默认关闭,因为跨会话共享意味着并发和脏数据风险成倍增加。

从使用者角度,我觉得理解这个结构可以帮你建立一张地图:面试官问缓存,你就按"Executor装饰器 → 二级缓存 → 一级缓存 → 数据库"这条链讲,再补充每个环节的生命周期和失效条件,基本能覆盖90%的问题。

2. 一级缓存:SqlSession级别的隐式缓存

2.1 一级缓存的工作原理与源码演进

一级缓存的代码核心在BaseExecutor里。它有一个字段:

protected PerpetualCache localCache;

PerpetualCache是Cache接口的默认实现,内部就是一个简单的HashMap。每次查询时,BaseExecutor.query会先尝试从localCache取数据:

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { List<E> list = resultHandler == null ? (List<E>) localCache.getObject(key) : null; if (list != null) { return list; } // 缓存没命中,走数据库查询 list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); return list; }

这里的CacheKey是一级缓存命中的关键,它由多个信息共同参与hash计算:

CacheKey key = new CacheKey(); key.update(ms.getId()); // statementId,也就是 mapperId+方法名 key.update(rowBounds.getOffset()); // 分页偏移 key.update(rowBounds.getLimit()); // 分页条数 key.update(boundSql.getSql()); // 实际SQL语句 key.update(parameter); // 参数对象

一句话概括:同一个Mapper方法的相同SQL、相同参数、相同分页条件,才能命中同一条缓存。这个设计本身是合理的,但有个众所周知的坑:如果参数对象没有正确实现equals/hashCode,两个"值相等"的参数会被认为是不同的key,导致缓存命中率暴跌。我排查过一个事故,查询入参是一个Map,Map的hashCode已经是正确的,但问题出在Map里的某个值对象没重写hashCode,导致两次查询生成不同CacheKey,缓存形同虚设。

2.2 一级缓存失效的常见场景

我整理了几种一级缓存失效的具体场景,都是我在实际项目中验证过的:

第一种:SqlSession已关闭或重新创建。每次sqlSession.selectList()发生在不同会话中,一级缓存自然无法共享。这在Spring整合场景下尤其常见,后面专门讲。

第二种:执行了增删改操作。只要执行insert/update/delete,BaseExecutor.update方法会调用clearLocalCache(),把整个localCache清空。这是最保守但也最正确的策略,因为MyBatis无法知道一次update到底影响了哪些缓存数据。

第三种:手动调用sqlSession.clearCache()。这个方法会直接清空当前会话的一级缓存,虽然平时很少用,但当你需要强制刷新某个查询结果时会用到。

第四种:查询语句配置了flushCache="true"。在select标签上添加flushCache="true"后,每次执行该查询之前都会先清空一级缓存。这个属性很多人不熟悉,但它会导致你反复查询同一个数据,每次都真实走数据库。

第五种:查询结果集过大或返回XML/JSON大数据时使用ResultHandler来处理。传入自定义ResultHandler后会绕过一级缓存,因为MyBatis认为你已经自定义了消费逻辑,缓存命中意义不大。

2.3 实测一级缓存:如何验证命中和清空

我写了一个最简单的验证用例。假设有一张user表,使用SpringBoot整合MyBatis,Mapper接口定义selectById:

@SpringBootTest class LocalCacheTest { @Autowired private SqlSessionFactory sqlSessionFactory; @Test void testLocalCache() { // 手动创建同一个SqlSession,能复现一级缓存 try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User u1 = mapper.selectById(1); User u2 = mapper.selectById(1); System.out.println("u1 == u2 是否为同一对象: " + (u1 == u2)); // 打开SQL日志,可以看到 selectById 只执行了一次 } } @Test void testLocalCacheClearedAfterUpdate() { try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); mapper.selectById(1); // 第一次查询,SQL执行 mapper.selectById(1); // 命中缓存,SQL不执行 // 执行一个更新 User user = new User(); user.setId(1); user.setName("新名字"); mapper.updateById(user); // 触发 clearLocalCache() mapper.selectById(1); // 缓存已清空,SQL重新执行 } } }

前面这个测试里有一个细节:当开启了useGeneratedKeys或keyGenerator的insert语句时,MyBatis需要把自增主键回填到参数对象上,所以即使插入操作也必然清空缓存。这是我们在分析缓存一致性时比较容易忽略的一点。

日志输出里也能看到非常直观的对比:第一次测试只打印一条select日志,第二次测试会打印"select、update、select"三条日志。

3. 二级缓存:跨SqlSession的全局缓存

3.1 二级缓存开启步骤与XML配置详解

在Mapper XML文件里加上一个<cache>标签即可开启该namespace的二级缓存:

<mapper namespace="com.example.mapper.UserMapper"> <cache eviction="LRU" flushInterval="60000" size="512" readOnly="true" blocking="false"/> <select id="selectById" resultType="com.example.entity.User"> select * from user where id = #{id} </select> </mapper>

各个参数含义如下:

配置项可选值默认值说明
evictionLRU、FIFO、SOFT、WEAKLRU淘汰策略,LRU最常用
flushInterval正整数(毫秒)无定时刷新缓存的时间间隔
size正整数1024最多缓存的对象个数
readOnlytrue/falsefalse是否只读缓存
blockingtrue/falsefalse是否使用阻塞缓存,防止并发穿透

这里最容易被忽略的是readOnly参数。默认readOnly=false代表可读写缓存,此时MyBatis为每个返回对象做序列化+反序列化拷贝,保证每次拿到的都是新对象,避免并发修改污染缓存。但代价是:所有缓存的POJO必须实现java.io.Serializable接口,否则启动后第一次查询就会抛SerializationException。如果你的实体是继承某个框架基类,或者引用了复杂的嵌套对象,序列化还可能带来性能损耗。

如果readOnly=true,MyBatis直接返回缓存对象的同一个引用,读取速度更快,但任何线程修改这个对象都可能影响缓存中的数据,引发脏读。我的建议是:大多数业务场景用默认readOnly=false即可,追求极致读性能或缓存对象不可变时可考虑true。

用注解方式对应的配置是:

@CacheNamespace(eviction = LruCache.class, flushInterval = 60000, size = 512, readWrite = true) public interface UserMapper { ... }

3.2 装饰器模式:二级缓存的底层设计

二级缓存的强大之处不在缓存本身,而在于装饰器组合。MyBatis的Cache接口定义了缓存的基本行为,真正工作的是围绕它的一圈装饰器:

public class Configuration { private final Map<String, Cache> caches = new HashMap<>(); // ... }

在解析<cache>标签时,MyBatis会根据配置创建一条装饰器链,通常是:

SynchronizedCache (保证线程安全) → LoggingCache (统计命中率) → ScheduledCache (定时清空) → LruCache (LRU淘汰) → PerpetualCache (真正的HashMap)

当readOnly=false时,还会在最外层加一层SerializedCache,负责对象的序列化拷贝。BlockingCache在blocking=true时替换SynchronizedCache,实现简单的锁效果,避免缓存失效瞬间大量请求同时穿透到数据库。

每个装饰器只干一件事,组合起来就有了完善的缓存治理能力。以前我在阅读这段源码时最大的感受是:装饰器模式用得极其标准,每一个关注点都被拆成独立的类,你完全可以仿照这个思路为自己的缓存框架写扩展。比如面试时能把LoggingCache计算命中率、LruCache淘汰逻辑讲明白,面试官会觉得你真是看过源码的。

3.3 查询顺序与缓存更新策略

二级缓存的查询入口在CachingExecutor:

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { Cache cache = ms.getCache(); if (cache != null) { // 先查二级缓存 List<E> list = (List<E>) tcm.getObject(cache, key); if (list == null) { // 二级缓存未命中,再交给下一级执行器(会走到一级缓存或数据库) list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); // 把结果放进事务缓存管理器,但不直接放入二级缓存 tcm.putObject(cache, key, list); } return list; } return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); }

这里有个非常容易踩坑的点:tcm.putObject并不是直接写入二级缓存,而是先存到TransactionalCache里做暂存,只有当前事务提交后才会真正写入二级缓存。如果事务回滚,暂存的数据会被丢弃。

对应地,更新操作会触发:

public int update(MappedStatement ms, Object parameterObject) throws SQLException { flushCacheIfRequired(ms); return delegate.update(ms, parameterObject); }

flushCacheIfRequired会检查这条SQL是否需要清空二级缓存。默认情况下insert/update/delete都会清空二级缓存,但你可以在语句级别设置flushCache="false"来关闭这个行为。我不建议这样做,除非你非常确定其他表不会修改这些数据。

3.4 多表操作的脏读问题与实战坑

二级缓存尽管好用,但在多表关联场景下有一个非常著名的坑:缓存的粒度是Mapper namespace,不是表。

举例:OrderMapper里有一条SQLselectOrderWithUser,它join了user表。MyBatis把查询结果缓存到OrderMapper的二级缓存里。另一个地方执行了UserMapper的updateById,更新了用户昵称。因为更新的是UserMappernamespace的缓存,OrderMapper的缓存完全不会被清空,于是你读到的是更新前的旧用户昵称。

我踩过的一次具体事故:运营后台改完用户昵称,C端接口查订单列表时仍显示旧昵称,差不多持续到缓存被LRU自然淘汰才消失。排查到最后才发现就是OrderMapper里SQL关联了用户字段,但缓存刷新依赖的是OrderMapper自己的更新操作。

应对方案主要有三种:

  1. 只对低频变更的单表数据开启二级缓存,关联查询、JOIN查询一律不开。
  2. 设置合理的flushInterval,即使别的Mapper更新了数据,也不会长期命中脏数据。
  3. 干脆不用内置二级缓存,换成你自己的外部缓存(如Redis),再配合SQL级缓存key设计来控制失效。

另外,MyBatis-Plus对二级缓存的支持也不是无缝的。用MyBatis-Plus内置的selectById、selectList接口会走二级缓存,但如果你在XML里自定义了SQL,namespace还是同一个,缓存依然共享。最麻烦的是批量操作,比如saveBatch内部Mapper每次insert都会触发缓存清空,批量插入几千条数据会把二级缓存频繁清空,导致有效命中率直线下降。

4. 与Spring整合后缓存变化的原理与陷阱

4.1 SqlSessionTemplate的代理逻辑

很多人在SpringBoot项目里测缓存,发现一级缓存"好像失效了"——同一个Mapper接口连续调用两次selectById,SQL日志打印了两遍。这个问题解释起来要深入到 mybatis-spring 的SqlSessionTemplate。

SqlSessionTemplate实现了SqlSession接口,但它的所有方法都是通过动态代理转发给一个内部代理对象来执行的。这个代理会从Spring容器中获取SqlSession,核心逻辑可以简化成:

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 从当前事务上下文或连接池获取SqlSession SqlSession sqlSession = getSqlSession(); return method.invoke(sqlSession, args); }

关键在于getSqlSession()的规则:如果当前没有Spring事务,每次调用Mapper的方法都会通过SqlSessionUtils创建新的SqlSession,用完立即关闭。不同SqlSession之间的一级缓存是完全隔离的,所以你就会看到"连续两次查询都查了数据库"的现象。

4.2 事务与一级缓存生命周期

反过来,如果给Service方法加上@Transactional,Spring会让整个方法共享同一个数据库连接和同一个SqlSession。mybatis-spring 会把SqlSession绑定到当前事务线程的ThreadLocal上,后续所有Mapper调用都复用这个会话,于是一级缓存立即生效。

我是用下面的方式验证的:

@Transactional public void testWithTransaction() { User u1 = userMapper.selectById(1); // 打印SQL User u2 = userMapper.selectById(1); // 不打印SQL,命中一级缓存 }

这个现象很多人第一次看到会吓一跳:"同一事务里查两次,怎么第二次不打印SQL了?"其实不是SQL没发,而是MyBatis在会话内部做了缓存。这里有一个特别的坑:如果没有在事务方法里开启批量操作,而是手动调用了多个update,一级缓存会在每个update之后被清空。所以你如果一边循环update一边查询,查询始终不会命中缓存,性能问题会被放大。

另外需要注意,Spring整合下每次请求几乎都会绑定到新的SqlSession,所以很多成熟团队的约定是:不要指望MyBatis内置一级缓存帮你扛QPS,它的作用更多是避免一个方法里重复查询。真要扛并发,要么上Redis,要么优化数据库本身。

4.3 集群环境下的缓存对策

MyBatis内置的二级缓存是JVM进程级别的。单机部署时问题不大,但一上多节点,节点A更新了数据,节点B的缓存还是旧数据,就会出现数据不一致。这种现象我在一次灰度发布后遇到过:部分请求打到新节点,部分打到老节点,用户看到的接口数据一会新一会旧,排查了很长时间才定位到缓存不一致。

分布式环境下的常规做法是把二级缓存换成外部缓存中间件。MyBatis的Cache接口是开放的,你可以实现自定义Cache,把数据放到Redis里:

public interface Cache { String getId(); void putObject(Object key, Object value); Object getObject(Object key); Object removeObject(Object key); void clear(); int getSize(); ReadWriteLock getReadWriteLock(); }

实现一个RedisCache并不复杂,核心逻辑就是putObject时序列化并写入Redis,getObject时从Redis读取并反序列化。但有几个细节要想清楚:

  • key如何序列化?建议用CacheKey.toString(),因为它已经包含了statementId、SQL、参数哈希,天然不冲突。
  • value序列化方案选JDK序列化还是JSON?性能、可读性、版本兼容性都要权衡。
  • 缓存刷新策略怎么定?MyBatis的flushCache只会调用cache.clear(),你需要决定是删除整个Redis key前缀,还是只删除当前namespace相关的key。

我的实测经验是:如果不想引入太多额外代码,优先考虑在Service层做Redis缓存,而不是去替换MyBatis的Cache实现。因为MyBatis缓存的key规则在分布式环境下很难精确控制,而Service层缓存能结合业务key(用户ID、商品ID)精确失效,心智负担小得多。

5. 常见问题与面试必备速查

5.1 高频面试题与回答要点

这部分我整理了近期后台私信里问得最多的面试题,直接附上回答要点。

问题1:一级缓存和二级缓存的区别?

回答要点:一级缓存是SqlSession级别,默认开启,生命周期短,增删改或会话关闭即失效;二级缓存是mapper级别,默认关闭,需要显式配置,跨会话共享。查询顺序是二级缓存优先。

问题2:为什么在SpringBoot项目里同一Mapper查询两次,SQL执行了两次?

回答要点:因为mybatis-spring的SqlSessionTemplate在无事务场景下每次方法调用都会创建并关闭新的SqlSession;一级缓存隔离在各自会话里,自然无法命中。加上@Transactional后同一线程复用SqlSession,一级缓存才生效。

问题3:二级缓存出现脏数据是什么原因?

回答要点:缓存刷新粒度是namespace,不同Mapper操作同一张表或多表JOIN时,缓存无法感知其他namespace的数据变更;解决方法是限制缓存范围、设置合理刷新间隔或使用外部缓存。

问题4:readOnly=true和readOnly=false的区别?

回答要点:readOnly=true直接返回缓存对象引用,性能高但有并发修改风险;默认readOnly=false会通过序列化拷贝返回新对象,要求实体实现Serializable接口。

问题5:如何实现基于Redis的MyBatis二级缓存?

回答要点:实现org.apache.ibatis.cache.Cache接口,覆盖putObject/getObject/clear等方法;在<cache type="...">里指定自定义实现类;要注意序列化方案、key规则和刷新策略。

5.2 排查实用技巧实录

排查MyBatis缓存问题,我常用的手段主要有这么几招,都是实打实踩过坑后积累的。

第一招:打开SQL日志。在配置里设置:

logging.level.com.example.mapper=DEBUG

或者修改MyBatis配置,在日志中打印SQL和参数。通过"SQL是否打印"可以准确判断查询是否走了缓存。如果打印了SQL说明没命中,如果没打印但返回了正确数据,说明命中了一层缓存。

第二招:查看命中率日志。启用了二级缓存后,日志里会出现类似:

Cache Hit Ratio [com.example.mapper.UserMapper]: 0.85

命中率低于0.5,说明要么缓存key不稳定,要么频繁被清空。这时候优先检查CacheKey生成的参数对象是否正确,再看更新操作是否过于频繁。

第三招:使用flushCache和useCache做对比实验。在SQL语句上加useCache="false"可以强制绕过二级缓存,加flushCache="true"可以强制清空缓存。通过对比这两种情况下的SQL执行次数,可以快速判断问题出在缓存命中还是缓存刷新。

第四招:自定义Configuration观察内部状态。如果有特殊排查需求,可以在MyBatis配置里指定自定义Configuration,重写必要的钩子方法,把CacheKey和命中结果打印出来。这个办法比较重,但能精确定位到某条SQL没命中的原因。

第五招:清理缓存。开发和测试环境需要手动刷新时,可以通过SqlSession的clearCache()清空一级缓存,通过Mapper namespace对应的缓存清空二级缓存。实际生产中如果想强制刷新,可以重启应用,或者实现一个隐藏的运维接口专门调cache.clear()。

写在最后的一点个人的体会

说实话,MyBatis缓存这套机制我从入门到真正吃透,经历了三次"重新学习":第一次是看缓存配置文档,以为自己全懂了;第二次是遇到脏数据事故,才发现缓存刷新和事务的耦合比想象中复杂;第三次是分布式场景下被迫彻底放弃内置二级缓存,才真正理解了MyBatis对缓存的克制态度。如果你正在面试或者准备接手一个性能敏感的项目,我的建议是先别急着开二级缓存,而是把一级缓存生命周期、Spring事务对其的影响、CacheKey生成规则这三个问题彻底搞清楚。等你能闭着眼说出"一次查询在开启二级缓存、处于事务中、执行了一条update后,缓存到底经历了什么",你才算真正掌握了MyBatis缓存。带缓存的代码一定要小步验证,我最喜欢的方式就是先写一个跑通完整链路的测试,再慢慢把事务、分页、批量操作加进去,这样踩过的坑永远能留下记录。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 20:39:14

Spring Boot文件上传cleanup失败原因与解决方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 20:36:36

RTC实时时钟驱动开发实战:从初始化到低功耗唤醒与校准

简介&#xff1a;面向嵌入式驱动开发者的RTC&#xff08;实时时钟&#xff09;驱动开发参考包&#xff0c;围绕实时时钟芯片的驱动实现展开&#xff0c;覆盖初始化、时间读取与设置、中断处理、电源管理、闰年与月份天数更新等关键环节&#xff0c;适合需要基于嵌入式平台实现或…

作者头像 李华
网站建设 2026/10/1 20:35:36

STM32 C++开发工具链全解析:从交叉编译到烧录调试

1. 四个软件到底在干嘛&#xff1a;先把工具链的账算清楚很多人第一次配STM32的C开发环境&#xff0c;都是照着教程一路“下一步”装完四个软件&#xff0c;然后打开工程发现能编译、能下载&#xff0c;但脑子里完全是一团浆糊&#xff1a;这四个东西谁管谁&#xff1f;为什么少…

作者头像 李华