1. 项目概述:MyBatis三级缓存机制深度剖析
如果你用过MyBatis,肯定对“缓存”这个词不陌生。面试的时候,面试官也总爱问:“聊聊MyBatis的一级缓存和二级缓存?” 但很多人可能不知道,或者只是模糊地听说过,MyBatis其实存在一个“三级缓存”的概念。这并不是官方文档里明确划分的一个独立层级,而是我们开发者根据其缓存的作用域和生命周期,结合Spring等框架的集成,在实践中总结出来的一套完整的缓存视图。今天,我们就来彻底拆解这个“三级缓存”体系,搞明白每一级缓存是什么、怎么工作、以及最关键的——它们在实际项目中是怎么相互配合,又是怎么给我们“挖坑”的。
简单来说,MyBatis的三级缓存可以理解为:
- 一级缓存(SqlSession级别):默认开启,作用域最小,生命周期与一次数据库会话绑定。
- 二级缓存(Mapper/Namespace级别):需要手动配置开启,作用域跨SqlSession,可以被多个会话共享。
- 三级缓存(应用/框架集成级别):这通常指的是集成如Spring Cache、Redis等外部缓存框架后形成的应用级缓存,其作用域最大,可以跨应用实例共享。
理解这套机制,不仅能帮你解决“为什么我查的数据是旧的?”、“开了事务怎么查不到最新数据?”这类诡异问题,更是优化应用性能、设计高可用数据访问层的必备知识。无论你是正在被MyBatis缓存问题困扰的开发者,还是准备面试想深入原理的求职者,这篇文章都将带你从使用到源码,从配置到避坑,完整地走一遍。
2. 三级缓存整体架构与核心设计思想
在深入每一级缓存之前,我们得先站在高处,看看MyBatis设计这套缓存机制的初衷和整体蓝图。MyBatis作为一个半自动化的ORM框架,其核心价值之一就是在对象和关系数据库之间提供灵活、高效的映射。缓存,就是为了减少对数据库的直接访问,这个目标服务的。
2.1 为什么需要多级缓存?
想象一下图书馆借书。一级缓存就像你手边正在看的这本书,取用最快,但离开座位(关闭SqlSession)就得还回去。二级缓存像是这个阅览室的书架,同一个房间(同一个Mapper)的人都能看,但别的阅览室(别的Mapper)的人拿不到。三级缓存则像是图书馆的中央书库,所有读者(甚至其他分馆的应用)在权限内都可以借阅。
MyBatis采用这种分层设计,核心思想是在数据一致性、性能与资源消耗之间取得平衡。
- 性能与速度:一级缓存速度最快,因为数据就在内存中的SqlSession对象里。二级缓存次之,需要序列化/反序列化和跨会话查找。三级缓存(如Redis)可能涉及网络IO,速度最慢,但共享能力最强。
- 数据一致性:缓存层级越高,数据共享范围越广,保持一致性就越复杂。一级缓存只影响自己,很容易通过关闭会话来清空。二级缓存需要处理多个会话的并发更新。三级缓存则要面对分布式环境下的数据同步难题。
- 作用域与生命周期:这是分级的关键。从一次请求(SqlSession)、到一个业务模块(Mapper)、再到整个应用乃至集群,缓存的生命周期和作用域逐级扩大,应对不同的场景需求。
2.2 各级缓存的核心职责与交互关系
三级缓存并非完全独立,它们在执行一次查询时,遵循着一个清晰的查询链,这通常被称为缓存查询顺序。
当执行一条查询语句时(例如select * from user where id = #{id}),MyBatis的Executor执行器会按以下顺序查找数据:
- 首先查询一级缓存:检查当前
SqlSession中是否存在该查询的缓存结果。如果命中,直接返回,不会执行后续步骤。这是最快的一条路径。 - 然后查询二级缓存:如果一级缓存未命中,且当前
Mapper配置启用了二级缓存,则查询二级缓存。二级缓存底层是一个PerpetualCache对象,但被各种装饰器(如序列化、LRU淘汰、同步锁等)包装,存储的是序列化后的数据。如果命中,将数据反序列化后返回,同时放入当前SqlSession的一级缓存中(注意这点,很重要)。 - 最后查询数据库:如果一、二级缓存均未命中,则执行JDBC操作,访问数据库获取数据。拿到数据后:
- 将结果放入当前SqlSession的一级缓存。
- 如果启用了二级缓存,同时会将结果放入二级缓存(同样需要序列化)。
至于三级缓存,它通常不在MyBatis这个默认查询链里。它更像是一个“旁路缓存”,由开发者通过Spring的@Cacheable注解或手动操作Redis客户端来实现。它的优先级和集成方式由应用架构决定,可能在一、二级缓存之前拦截,也可能作为二级缓存的分布式存储后端。
注意:这个顺序解释了为什么有时你开启了二级缓存,感觉效果却不明显。因为如果你的操作都在同一个SqlSession内(比如一个事务方法中多次查询同一数据),请求会被一级缓存拦截,根本走不到二级缓存那一步。
3. 一级缓存:SqlSession级别的“私人工作区”
一级缓存是MyBatis中最基础、最直接的缓存,理解它是理解所有缓存问题的起点。
3.1 工作原理与生命周期
一级缓存本质上是一个HashMap,它的键是CacheKey。这个CacheKey由多个要素共同决定,确保查询的唯一性:
- Mapper的Id(即命名空间+方法名)
- 查询的偏移量(分页参数)
- 查询的SQL语句本身
- 传递给SQL的实际参数值
- 环境Id(比如你配置的多数据源)
只要这些要素完全相同,MyBatis就认为这是同一次查询,会尝试从一级缓存中直接返回结果。
它的生命周期与SqlSession绑定:
- 创建:当调用
SqlSessionFactory.openSession()方法时,一个新的SqlSession被创建,同时一个全新的一级缓存(一个PerpetualCache对象)也随之创建。 - 存活:在该
SqlSession存活期间,所有查询的结果(除非配置了flushCache=true)都会被存入这个HashMap。 - 销毁:调用
SqlSession.close()方法,或者这个SqlSession对象被垃圾回收时,一级缓存随之销毁。这也是为什么我们常说“一级缓存默认是开启的”,因为它就是SqlSession的一个固有属性。
3.2 经典问题:一级缓存与事务的“爱恨纠缠”
这是面试高频题,也是实际开发中最容易踩的坑。问题通常表现为:在同一个事务方法中,我先更新了一条数据,然后立刻查询它,却发现查询到的还是旧数据。
@Transactional public void updateAndQuery(User user) { // 1. 执行更新操作 userMapper.updateById(user); // 2. 执行查询操作 User queriedUser = userMapper.selectById(user.getId()); // 此时,queriedUser 可能不是更新后的数据! }为什么会出现这种情况?根本原因在于SqlSession的复用和一级缓存的失效机制。
- 在Spring管理的事务中,一个事务方法通常会使用同一个
SqlSession(通过SqlSessionTemplate管理)。 - 当你执行
updateById时,MyBatis不仅会执行UPDATE语句,还会清空当前SqlSession的一级缓存(执行了clearLocalCache())。这是为了确保后续查询能拿到最新数据。 - 但是,关键点来了:清空缓存发生在
update操作执行之后、提交之前。而你的selectById查询,发生在同一个事务、同一个SqlSession内。 - 如果
selectById在update清空缓存之后执行,它会去数据库查,拿到新数据。这看起来没问题。 - 坑点在于:如果数据库隔离级别是“可重复读”(MySQL默认级别)或以上,在一个事务内,多次读取同一行数据会看到相同的快照。虽然
update清空了MyBatis的一级缓存,但select查询数据库时,数据库引擎返回的仍然是事务开始时的快照数据(除非你使用了FOR UPDATE这样的锁)。更常见的情况是,update操作更新了数据库,但事务尚未提交,此时select查询可能因为数据库的隔离级别而读不到未提交的数据。
如何避免和解决?
- 最直接的方法:在查询语句上配置
flushCache=true。这会使该查询在执行前强制清空一级缓存(和二级缓存),迫使它去数据库查询。但这样会失去缓存的意义,需谨慎使用。<select id="selectById" resultType="User" flushCache="true"> select * from user where id = #{id} </select> - 理解事务边界:考虑将更新和查询拆分成两个独立的事务(使用
@Transactional(propagation = Propagation.REQUIRES_NEW)),但这会带来事务管理的复杂性。 - 使用二级缓存:二级缓存的生命周期不依赖于
SqlSession,更新操作会失效二级缓存,另一个SqlSession的查询会直接命中已失效的缓存从而访问数据库。但这引入了数据一致性的新问题。 - 实战心得:对于这类“先改后查”的场景,最简单的做法是从业务逻辑上避免。如果业务允许,可以先查询出最新数据再操作;或者,直接使用更新后返回的实体对象(有些ORM或MyBatis插件支持),而不是重新查询。
4. 二级缓存:Mapper级别的“共享会议室”
二级缓存将缓存的作用域从会话提升到了命名空间(Mapper),允许多个SqlSession共享缓存数据,适用于查询远多于修改、且数据对实时性要求不高的场景。
4.1 配置与启用详解
启用二级缓存需要两步:
第一步:在MyBatis全局配置文件中声明开启缓存(默认就是true,通常不用改)
<settings> <setting name="cacheEnabled" value="true"/> </settings>第二步:在需要使用二级缓存的Mapper XML文件中,添加<cache/>标签
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.mapper.UserMapper"> <!-- 声明启用本Mapper的二级缓存 --> <cache/> <!-- 或者使用更详细的配置 --> <!-- <cache eviction="LRU" flushInterval="60000" size="1024" readOnly="true"/> --> <select id="selectById" resultType="User"> select * from user where id = #{id} </select> </mapper>eviction:缓存回收策略,常用LRU(最近最少使用)。flushInterval:缓存刷新间隔(毫秒),不设置则不清刷。size:最多缓存的对象数。readOnly:是否为只读。如果为true,则返回相同的缓存对象实例,性能好但不安全;如果为false,则会返回缓存对象的拷贝(通过序列化/反序列化),安全但性能有损耗。
还有一个至关重要的点:所有在同一个Mapper中的操作,默认会共享同一个缓存区域。这意味着,如果你在UserMapper.xml中配置了<cache/>,那么selectById、selectAll等所有查询的结果,都会放入名为com.example.mapper.UserMapper的缓存空间中。同时,任何insert、update、delete操作执行时,都会清空整个UserMapper命名空间下的二级缓存。这是保证数据一致性的最基本手段,但也意味着更新操作频繁时,二级缓存命中率会很低。
4.2 序列化陷阱与跨会话共享机制
二级缓存之所以能跨SqlSession共享,是因为缓存的对象需要被序列化和反序列化。MyBatis默认使用JVM序列化。这就带来了一个经典问题:
问题:查询返回了一个User对象,其中包含一个List<Order>属性。这个Order对象可能来自另一个Mapper(比如OrderMapper)。如果OrderMapper也开启了二级缓存,并且User被序列化缓存了,那么反序列化时,Order对象是从UserMapper的缓存里反序列化出来的,还是重新去OrderMapper的缓存里找?
答案:这取决于User对象里的Order属性是“引用”还是“嵌套查询结果”。
- 如果是通过
<association>或<collection>进行的嵌套查询(即执行了另一条SQL),那么默认情况下,Order数据会作为User对象的一部分被整体序列化到UserMapper的缓存中。反序列化时直接还原,不会再去触发OrderMapper的查询和缓存。 - 如果你想利用
OrderMapper自己的二级缓存,可以考虑使用cache-ref来让UserMapper引用OrderMapper的缓存空间,但这会将两个Mapper的缓存耦合,清空一个会导致另一个也被清空,需谨慎设计。
实操心得:
- 实体类必须实现
Serializable接口:这是最基本的要求,否则启用二级缓存时会报错。 - 小心“深拷贝”开销:如果配置
readOnly="false",每次从缓存取数据都会反序列化一个新对象。对于大对象或集合,这会有性能开销。 - 测试序列化兼容性:如果你修改了实体类的字段(增删改),之前序列化到缓存里的数据可能无法正确反序列化,导致奇怪的错误。这时需要手动清空缓存(或等待过期)。
4.3 失效策略与脏读风险
二级缓存最大的挑战是数据一致性。因为它是跨会话共享的,一个会话更新了数据,必须让其他会话的缓存失效。
MyBatis的机制是:任何 insert、update、delete 语句执行后,都会清空其所属Mapper命名空间下的整个二级缓存。这个机制简单粗暴,能保证强一致性,但代价是缓存粒度太粗。比如你只更新了ID为1的用户,但缓存里所有用户的查询结果都被清空了。
脏读风险场景: 假设有两个服务A和B,都连接同一个数据库,并且都使用了MyBatis二级缓存。
- 服务A更新了用户1的信息,并提交了事务。服务A的MyBatis清空了
UserMapper的二级缓存。 - 但是,服务B的MyBatis实例并不知道这个更新,它本地
UserMapper的二级缓存里还有旧数据。 - 服务B接下来查询用户1,命中了本地旧的二级缓存,读到了脏数据。
解决方案: 这就是为什么单纯的MyBatis二级缓存不适合分布式场景。通常的解决方案是引入三级缓存(分布式缓存),例如Redis。
- 放弃使用MyBatis自带的二级缓存,或者仅将其用作只读的本地缓存(如
Ehcache配置为本地内存)。 - 在Service层,使用Spring Cache抽象,并配置其缓存管理器(CacheManager)为Redis。这样,所有应用实例都共享同一个Redis缓存,一个实例的更新操作会清除或更新Redis中的缓存条目,其他实例在下一次查询时就能从Redis获取到最新数据(或发现缓存失效去查库)。
5. 三级缓存:应用级别的“分布式缓存中心”
如前所述,三级缓存并非MyBatis原生概念,而是我们整合外部分布式缓存解决方案(如Redis、Memcached)或更强大的本地缓存(如Caffeine、Ehcache集群)后形成的架构层。它的目标是解决二级缓存无法跨JVM共享的问题,实现真正意义上的应用级甚至服务级缓存。
5.1 与Spring Cache的集成实践
Spring Cache提供了一套优雅的缓存抽象,让我们可以用注解的方式管理缓存,而无需关心底层是Guava、Ehcache还是Redis。与MyBatis整合,通常是在Service层使用。
第一步:引入依赖与配置
<!-- Spring Boot Redis Starter --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency># application.yml spring: redis: host: localhost port: 6379 # password: your-password database: 0第二步:配置CacheManager
@Configuration @EnableCaching // 启用缓存注解 public class RedisCacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) // 默认过期时间10分钟 .disableCachingNullValues() // 不缓存null值 .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); // 使用JSON序列化 return RedisCacheManager.builder(connectionFactory) .cacheDefaults(config) .build(); } }第三步:在Service层使用缓存注解
@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override @Cacheable(value = "user", key = "#id") // 缓存键为 `user::123` public User getUserById(Long id) { // 这个方法内部会调用 userMapper.selectById(id) // 只有当缓存中没有 key=`user::#{id}` 的数据时,才会执行方法体 return userMapper.selectById(id); } @Override @CachePut(value = "user", key = "#user.id") // 更新缓存 public User updateUser(User user) { userMapper.updateById(user); return user; // 注意:返回的对象会被更新到缓存 } @Override @CacheEvict(value = "user", key = "#id") // 删除指定键缓存 public void deleteUserById(Long id) { userMapper.deleteById(id); } @Override @CacheEvict(value = "user", allEntries = true) // 清空user缓存空间所有条目 public void clearAllUserCache() { // 可能不需要执行具体的数据库操作,仅用于触发缓存清除 } }5.2 三级缓存下的数据一致性挑战
引入Redis后,一致性问题的范围从单个JVM扩大到了整个分布式系统。挑战主要来自两方面:
- 缓存与数据库的一致性:这是经典问题。当更新数据库后,是“先更新数据库,再删除缓存”(Cache-Aside Pattern),还是“先删除缓存,再更新数据库”?每种策略在并发下都可能产生脏数据。通常推荐“先更新数据库,再删除缓存”,虽然也不是百分百安全,但概率较低。对于强一致性要求极高的场景,可能需要引入分布式锁或使用数据库binlog监听(如Canal)来同步失效缓存。
- 多级缓存之间的一致性:如果你的系统同时使用了一级缓存(MyBatis)、二级缓存(MyBatis)和三级缓存(Redis),情况会非常复杂。一个更新操作,需要同时失效或更新这三层缓存,顺序和原子性很难保证。
实操建议: 对于大多数Web应用,一个务实且清晰的架构是:
- 禁用或谨慎使用MyBatis二级缓存:因为其粗粒度的清空策略和分布式环境下的不一致问题,很多团队选择直接关闭它。可以通过在MyBatis配置文件中设置
<setting name="cacheEnabled" value="false"/>全局关闭,或者在Mapper的<cache/>标签上设置readOnly="true"和极短的flushInterval,将其退化为一个“会话间只读副本”,减轻数据库压力。 - 将Redis作为主要的共享缓存层:在Service层使用Spring Cache,所有缓存逻辑集中在这里管理。这样,MyBatis的一级缓存仍然工作,提供最快的会话内响应;而跨会话、跨实例的数据共享,则由Redis负责。更新操作通过
@CacheEvict或@CachePut来保证Redis缓存的一致性。 - 考虑使用Caffeine等本地缓存作为Redis的前置缓存:对于极热的数据,可以在应用内使用Caffeine做一层本地缓存,并设置很短的过期时间(如1秒),进一步降低Redis的访问压力和响应延迟。这需要处理本地缓存与Redis之间的一致性,可以通过发布订阅机制,在数据更新时广播消息让所有实例失效本地缓存。
6. 性能调优与实战避坑指南
了解了原理,最终还是要落到实战。下面是一些关键的调优参数和避坑经验。
6.1 关键配置参数解析
一级缓存相关:
- 本质上无法配置。但可以通过
localCacheScope设置来改变其行为。<settings> <setting name="localCacheScope" value="STATEMENT"/> </settings>SESSION(默认):缓存对整个SqlSession有效。STATEMENT:缓存仅对当前执行的语句有效,语句执行完毕即清空。这个设置可以彻底解决一级缓存带来的数据一致性问题,但会完全失去一级缓存的收益,通常用于调试或对一致性要求极高的只读场景。
二级缓存相关(在<cache>标签中配置):
eviction:回收算法。LRU(最近最少使用)是最常用的。还有FIFO(先进先出)、SOFT(软引用)、WEAK(弱引用)。flushInterval:自动刷新间隔(毫秒)。例如设置为600000(10分钟),则缓存每10分钟清空一次,不管有没有更新操作。适用于可以接受一定时间数据延迟的场景。size:缓存引用的对象数量。不是字节数。根据业务数据量设置。readOnly:如前所述,true性能好,false安全。如果缓存对象是安全的(不可变),或者你愿意承担并发修改的风险,可以设为true。
6.2 监控与诊断:你的缓存真的生效了吗?
怀疑缓存没起作用?可以通过以下方式验证:
开启MyBatis日志:在配置文件中设置日志级别为DEBUG。
logging: level: com.example.mapper: debug # 你的Mapper包路径观察日志输出。如果看到
Cache Hit Ratio字样,后面跟着一个比例(如[com.example.mapper.UserMapper]:0.5),这表示二级缓存的命中率。如果一直是0,说明缓存没命中。使用“MyBatis Log Free”这类插件:这类插件可以很好地格式化控制台输出的SQL,并能直观地显示某条SQL是否从缓存中获取了结果。
检查Redis:如果使用了Redis,通过
redis-cli使用keys user:*命令(生产环境慎用)或scan命令查看缓存键是否存在,以及其TTL。
6.3 常见陷阱与解决方案速查表
| 陷阱现象 | 可能原因 | 解决方案 |
|---|---|---|
| 同一事务内更新后查询不到最新数据 | 1. 一级缓存未失效(极少见)。 2. 数据库隔离级别(如RR)导致事务内读快照。 | 1. 在查询语句配置flushCache=true。2. 将查询方法置于新事务中。 3. 调整数据库隔离级别(需评估影响)。 4.推荐:从业务逻辑上规避,使用更新返回的对象。 |
| 开启了二级缓存但命中率始终为0 | 1. 实体类未实现Serializable。2. 查询语句设置了 useCache="false"。3. 在同一个 SqlSession内,请求被一级缓存拦截。4. 更新操作频繁,缓存被不断清空。 | 1. 检查实体类。 2. 检查Mapper XML配置。 3. 这是正常现象,二级缓存旨在跨会话共享。 4. 评估业务场景是否适合二级缓存,或调整 flushInterval。 |
| 分布式环境下,不同实例读到不同数据 | MyBatis二级缓存是JVM级别的,无法跨实例同步。 | 弃用或改造二级缓存,使用Redis等分布式缓存作为共享缓存层。 |
| 使用Redis后,缓存数据序列化错误 | Java对象与Redis中存储的序列化格式不匹配。 | 确保所有服务使用相同的序列化方式(如Jackson JSON)。在CacheManager中统一配置GenericJackson2JsonRedisSerializer。 |
| 缓存击穿/雪崩 | 热点Key失效瞬间大量请求直达数据库。 | 1. 永不过期Key+后台更新。 2. 互斥锁(Redis SETNX)。3. 缓存预热。 4. 设置不同的过期时间。 |
6.4 版本升级与框架整合注意事项
从热词中看到有关于MyBatis 3.5.3.1升级到3.7.x的问题。版本升级通常是为了获取性能提升、新特性或安全修复。在升级涉及缓存的版本时,需要注意:
- API变更:检查
Cache接口及其实现类是否有不兼容的改动。如果你有自定义的Cache实现,需要适配。 - 行为变化:仔细阅读官方发布说明,看是否有关于缓存失效逻辑、序列化方式等方面的行为调整。
- 与MyBatis-Plus整合:MyBatis-Plus对其缓存有自己的增强和支持。升级MyBatis核心库时,务必对应升级MyBatis-Plus到兼容的版本,并测试其内置的缓存功能(如
@CacheNamespace注解)是否正常工作。 - 与Spring Boot整合:Spring Boot的
mybatis-spring-boot-starter会自动管理版本依赖。通常只需升级Boot版本或覆盖mybatis.version属性即可,但升级后务必进行全面的功能测试,特别是涉及缓存和事务的部分。
最后,关于“若依框架不分离版4.8.3版本想将mybatis改为mybatis-plus”,这不仅仅是改个依赖。MyBatis-Plus提供了更强大的CRUD封装、条件构造器、分页插件等。在迁移时,除了处理实体类注解(如@TableName,@TableField)、Mapper接口继承BaseMapper外,需要特别注意缓存配置。MyBatis-Plus默认可能使用自己的缓存实现或配置,你需要明确是继续使用原MyBatis的缓存配置,还是采用MyBatis-Plus推荐的方案,并重新测试所有涉及数据查询和更新的场景。