MyBatis缓存深度解析:从一级缓存到二级缓存,原理、配置与避坑指南
1. 从一次线上慢查询说起:为什么MyBatis缓存值得深究
那天下午,监控系统突然告警,一个核心接口的响应时间从平时的50ms飙升到了2秒。我立刻登录服务器,查看数据库慢查询日志,发现一条非常简单的SELECT * FROM user WHERE id = ?语句,在短短一分钟内被重复执行了上千次,而id参数的值就那么几个。这太反常了,这个查询理应被MyBatis缓存起来才对。带着疑惑,我检查了代码,发现这个查询位于一个Service方法中,而该方法被一个循环调用,每次循环都创建了新的SqlSession。问题瞬间清晰:我错误地认为一级缓存是“全局”的,而实际上它只在单个SqlSession生命周期内有效。这次事故让我损失了半小时的排查时间,但也让我下定决心,必须把MyBatis缓存这个看似基础、实则暗藏玄机的机制彻底吃透。
如果你也曾在使用MyBatis时,对缓存的效果感到困惑——比如明明配置了缓存,查询却依然频繁访问数据库;或者更新了数据,但查询到的还是旧结果——那么这篇文章就是为你准备的。我将结合自己踩过的坑和大量测试验证,带你一次性搞懂MyBatis的一级缓存、二级缓存,包括它们的工作原理、配置方式、失效场景以及那些官方文档里不会写的“坑”。无论你是正在面试准备,还是想在项目中正确、高效地使用缓存来提升性能,这篇超过5000字的深度解析都能给你带来实实在在的收获。
2. 一级缓存:SqlSession级别的“私人备忘录”
一级缓存是MyBatis默认开启的,无需任何配置。你可以把它理解为每个SqlSession独享的一份“私人备忘录”。在同一个SqlSession中,执行两次完全相同的查询(相同的SQL语句和参数),第二次就会直接从这份备忘录里取结果,而不会再次访问数据库。
2.1 一级缓存的工作原理与生命周期
它的核心实现很简单:一个HashMap,key是CacheKey对象。这个CacheKey由MappedStatement的id(即命名空间+方法名)、SQL语句、参数值、分页参数等共同决定。只要这些元素完全相同,CacheKey就相同,就能命中缓存。
一级缓存的生命周期与SqlSession绑定:
- 开启:当调用
SqlSessionFactory.openSession()方法时,一个新的SqlSession被创建,其内部的一级缓存(一个PerpetualCache对象)也随之初始化。 - 使用:在SqlSession执行查询(
selectOne,selectList等)时,会先根据上述规则生成CacheKey,然后去一级缓存这个HashMap里查找。找到则直接返回,找不到才查库,并将结果存入缓存。 - 失效:当执行了增删改操作(
insert,update,delete),或手动调用了SqlSession.clearCache()方法,或关闭SqlSession时,这个“私人备忘录”就会被清空。
注意:这里有个关键点,也是我开头踩坑的原因。一级缓存的作用域是SqlSession,而不是整个应用。在常见的Spring集成场景中,如果你没有正确配置事务,或者在不同的方法调用中使用了不同的SqlSession(例如,在非事务方法中,每次数据库操作都可能打开和关闭一个SqlSession),那么一级缓存就形同虚设。
2.2 一级缓存失效的四大场景实测
理论说再多不如实测。我写了一段测试代码来验证一级缓存失效的场景:
// 场景一:同一SqlSession,相同查询,缓存命中 try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user1 = mapper.selectById(1L); // 第一次查询,访问数据库 User user2 = mapper.selectById(1L); // 第二次查询,命中一级缓存,不访问数据库 System.out.println(user1 == user2); // 输出 true,是同一个对象(默认情况下) } // 场景二:执行增删改操作,缓存清空 try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user1 = mapper.selectById(1L); // 第一次查询,访问数据库 mapper.updateName(1L, "NewName"); // 执行UPDATE操作 User user2 = mapper.selectById(1L); // 第二次查询,因为缓存被清空,再次访问数据库 } // 场景三:手动清空缓存 try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user1 = mapper.selectById(1L); // 访问数据库 session.clearCache(); // 手动清空一级缓存 User user2 = mapper.selectById(1L); // 再次访问数据库 } // 场景四:关闭或提交SqlSession(在非自动提交模式下) try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user1 = mapper.selectById(1L); // 访问数据库 session.commit(); // 提交事务,在MyBatis中,commit()会清空一级缓存 User user2 = mapper.selectById(1L); // 再次访问数据库 }实操心得:在Spring管理的事务中,一个事务通常对应一个SqlSession。因此,在@Transactional注解的方法内部,多次相同查询可以享受一级缓存。但一旦方法结束,事务提交或回滚,SqlSession关闭,缓存也就没了。所以,一级缓存更适合在单个复杂业务方法内,避免重复查询短时间不变的数据。
2.3 一级缓存的“坑”:对象共享与序列化
默认情况下,一级缓存返回的是同一个对象引用。这在上面的测试中user1 == user2返回true可以证明。这带来了一个潜在问题:如果你在业务代码中修改了user1对象的属性,那么user2对象看到的也是修改后的值,因为它们根本就是同一个对象!这可能会引发意想不到的副作用。
为了解决这个问题,可以在<select>标签中设置flushCache="false"(默认)和useCache="true"(默认)的同时,考虑业务逻辑的隔离性。对于需要返回不同对象实例的场景,一个常见的做法是让返回的实体类实现Cloneable接口,或者在Service层进行深拷贝。但更根本的解决方案是,不要依赖修改MyBatis返回的实体对象来传递状态,它们最好被视为只读的DTO。
3. 二级缓存:Mapper级别的“团队共享白板”
如果说一级缓存是私人的,那二级缓存就是团队的。它是Mapper(Namespace)级别的缓存,多个SqlSession可以共享。这意味着,SqlSession A查询了用户1的数据后,只要二级缓存生效,SqlSession B再查询用户1,就可以直接从缓存中获取。
3.1 开启与配置二级缓存
二级缓存默认是关闭的,需要显式开启。
第一步:在MyBatis核心配置文件中开启全局缓存(这是总开关)
<configuration> <settings> <!-- 默认为true,通常显式写出以示明确 --> <setting name="cacheEnabled" value="true"/> </settings> </configuration>第二步:在需要缓存的Mapper XML文件中,添加<cache/>标签
<mapper namespace="com.example.mapper.UserMapper"> <!-- 最简单的声明,启用二级缓存 --> <cache/> <select id="selectById" resultType="User"> select * from user where id = #{id} </select> </mapper>一个<cache/>标签就开启了该Mapper的二级缓存。但它的默认行为可能不符合生产要求,所以我们通常需要配置它。
第三步(推荐):详细配置<cache>标签
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>- eviction(清除策略):当缓存满时,如何淘汰对象。常用
LRU(最近最少使用)和FIFO(先进先出)。LRU在大多数场景下更优。 - flushInterval(刷新间隔):缓存自动刷新的毫秒数。设置为
60000代表每分钟清空一次缓存。不设置则代表不清空,直到有增删改操作触发清空。对于更新不频繁的数据,可以设置一个较大的值;对于实时性要求高的,可以设置较小值或依赖更新操作清空。 - size(引用数目):缓存最多可以存储多少对象。这个数量基于缓存项(Cache Entries),而不是物理内存。需要根据业务数据量评估。
- readOnly(只读):默认为
false。如果设置为true,MyBatis会将返回对象的副本返回给调用者,这更安全,但会因序列化/反序列化带来轻微性能开销。如果设置为false,MyBatis会返回缓存对象的引用,性能更高,但有上述对象共享的问题。对于简单的、不可变的实体,readOnly="false"是安全的;对于可能被修改的复杂对象,建议readOnly="true"。
3.2 二级缓存的工作模式与序列化要求
二级缓存的数据是在多个SqlSession间共享的,因此缓存的对象必须是可序列化的。你的实体类必须实现java.io.Serializable接口。
public class User implements Serializable { private static final long serialVersionUID = 1L; private Long id; private String name; // ... getters and setters }二级缓存的工作模式比一级缓存复杂。它并不是一个简单的HashMap。当某个SqlSession执行查询后,它并不是直接把结果对象扔进一个全局Map,而是先将查询结果对象序列化,存储序列化后的字节数组。当另一个SqlSession执行相同查询时,再从缓存中取出字节数组进行反序列化,得到一个新的对象实例。这也是为什么配置readOnly="true"时返回的是副本的原因。
3.3 二级缓存失效与更新的核心机制
这是二级缓存最容易出错的地方。它的失效逻辑如下:
- 某个SqlSession对表T执行了
INSERT/UPDATE/DELETE操作并提交了事务(commit())。 - MyBatis会清空整个
Mapper命名空间对应的二级缓存。 - 所有后续的查询,都需要重新访问数据库。
这里有一个极其重要的细节:事务提交是触发二级缓存清空的关键。如果你在Spring中使用了@Transactional,那么只有在方法成功执行完毕、事务提交时,缓存才会被清空。如果方法执行过程中发生了异常并回滚,则缓存不会被清空。
坑点警示:二级缓存是Mapper级别的。假设你有UserMapper和OrderMapper,Order对象里关联了一个User对象。你在UserMapper.xml中配置了缓存,在OrderMapper.xml中也配置了缓存。当你通过OrderMapper查询一个订单连带用户信息时,User数据会被缓存到OrderMapper的缓存区域。但是,如果另一个操作直接通过UserMapper更新了这个用户的信息,它只会清空UserMapper的缓存,而OrderMapper缓存里那个旧的User数据依然存在!这就导致了数据不一致。
解决方案:对于有关联关系的实体,要么谨慎使用二级缓存,要么使用更高级的缓存引用(
cache-ref)。cache-ref可以让多个Mapper共享同一个缓存空间。例如,在OrderMapper.xml中配置<cache-ref namespace="com.example.mapper.UserMapper"/>,这样OrderMapper的缓存操作就会使用UserMapper的缓存区域,更新用户信息时,通过OrderMapper查询到的关联用户缓存也会被清空。但这增加了耦合度,需要仔细设计。
4. 缓存的配置陷阱与高级工作模式
仅仅知道如何开启缓存是不够的,生产环境中的配置更为精细和复杂。
4.1 细粒度缓存控制:每个语句的缓存行为
你可以在具体的<select>、<insert>、<update>、<delete>标签上,通过属性来控制其与缓存的交互。
useCache:用于<select>语句。设置为false可以禁用该语句的二级缓存(一级缓存不受影响)。适用于实时性要求极高的查询。<select id="selectRealTimeData" resultType="Data" useCache="false"> SELECT * FROM sensor_data ORDER BY time DESC LIMIT 1 </select>flushCache:- 在
<select>上:默认为false。如果设置为true,则执行该查询前会清空一级和二级缓存(非常激进,很少用)。 - 在
<insert>、<update>、<delete>上:默认为true。这意味着执行这些写操作后,会自动清空一级和二级缓存。如果你有特殊理由不希望清空缓存(风险极高!),可以设置为false。
- 在
4.2 集成第三方缓存:Ehcache与Redis
MyBatis自带的二级缓存实现(PerpetualCache)是内存缓存,单机可用,但在分布式环境下会有一致性问题。因此,我们常集成第三方缓存库。
集成Ehcache(本地内存缓存,功能强大):
- 添加依赖:
mybatis-ehcache和ehcache。 - 在Mapper XML中指定缓存实现:
<cache type="org.mybatis.caches.ehcache.EhcacheCache"/> - 在类路径下添加
ehcache.xml配置文件,可以精细配置内存/磁盘存储、过期时间、集群等。
集成Redis(分布式缓存):
- 添加依赖,如
mybatis-redis(非官方)或使用Spring Cache + Redis,后者更主流。 - 通过Spring配置,将MyBatis的缓存实现指向一个Redis模板。这通常需要自定义一个
Cache实现类,实现org.apache.ibatis.cache.Cache接口,内部使用RedisTemplate进行操作。 - 这样做的好处是所有应用实例共享同一份缓存,解决了分布式一致性问题,但引入了网络开销和Redis的运维成本。
选型建议:对于简单的单体应用,使用MyBatis自带缓存或Ehcache足矣。对于微服务或分布式应用,强烈建议使用Spring Cache抽象层,并搭配Redis作为缓存后端,这样不仅能统一缓存技术栈,还能利用Spring Cache更丰富的注解(如@Cacheable,@CacheEvict)进行声明式缓存管理,比在MyBatis XML中配置更灵活、更强大。
4.3 缓存工作模式深度解析:读写与只读
前文提到的readOnly属性,底层对应着不同的序列化策略。
readOnly="true":MyBatis使用只读装饰器。从缓存中反序列化得到对象后,直接返回。性能稍差,但线程安全。readOnly="false":MyBatis使用序列化拷贝装饰器。每次从缓存取出时,会对序列化的字节数组进行反序列化,创建一个全新的对象。这实际上也是一种“拷贝”,但比只读装饰器更彻底。注意:即使readOnly="false",由于二级缓存本身需要序列化存储,返回的也是新对象,所以不会有一级缓存那种对象共享的问题。这里的“读写”指的是缓存条目本身可被替换,而非返回的对象可被修改。
5. 设计一个全面的MyBatis缓存测试方案
理论需要实践验证。我设计了一套测试方案,可以系统地验证各种缓存行为。
测试环境搭建:
- 使用H2或MySQL测试数据库。
- 使用Spring Boot + MyBatis-Plus(或原生MyBatis)框架。
- 开启SQL日志打印(配置
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl),便于观察是否真的访问了数据库。
测试用例集:
- 一级缓存基础测试:同一SqlSession内两次相同查询,验证日志只打印一次SQL。
- 一级缓存失效测试:
- 在两次查询间执行
update,验证缓存清空。 - 在两次查询间调用
sqlSession.clearCache(),验证缓存清空。 - 使用两个不同的SqlSession执行相同查询,验证缓存不共享。
- 在两次查询间执行
- 二级缓存基础测试:
- 在两个不同的SqlSession中执行相同查询,验证第二个SqlSession不打印SQL(命中二级缓存)。
- 验证返回的对象不是同一个实例(
==为false,但equals为true)。
- 二级缓存失效测试:
- SqlSession A查询数据 -> SqlSession B更新同一条数据并
commit-> SqlSession A再次查询,验证SQL再次打印(缓存被清空)。 - 测试在未
commit的更新操作后,缓存是否被清空(应该不会)。
- SqlSession A查询数据 -> SqlSession B更新同一条数据并
- 缓存配置测试:
- 在
<select>上设置useCache="false",验证二级缓存不生效。 - 在
<update>上设置flushCache="false",验证执行更新后,后续查询是否还能命中缓存(危险操作,仅测试)。
- 在
- 关联查询缓存测试:
- 创建
User和Order实体及Mapper。 OrderMapper中配置关联查询<association>。- 测试更新
User后,通过OrderMapper查询到的关联User信息是否过期(会过期,除非使用cache-ref)。
- 创建
通过这样的测试,你不仅能巩固对缓存机制的理解,还能在项目初期就发现潜在的配置错误或理解偏差,避免将它们带到线上环境。
6. 生产环境中的缓存实践与避坑指南
结合多年的经验,我总结出以下几点在生产中使用MyBatis缓存的核心建议:
一级缓存:理解并接受其局限性。它适用于短生命周期、重复查询的场景。在Spring事务管理中,合理利用。不要试图用它做跨请求的数据共享。
二级缓存:谨慎开启,明确边界。
- 对读远多于写、数据一致性要求不苛刻的配置表、字典表,可以开启二级缓存,并设置合理的
flushInterval和size。 - 对核心业务数据、更新频繁、一致性要求高的表(如订单、账户),不建议开启MyBatis二级缓存。数据不一致的风险远大于缓存带来的性能收益。这类场景应使用更可控的缓存策略,如业务层缓存(Spring Cache + Redis),并设计完善的缓存更新和失效逻辑。
- 对读远多于写、数据一致性要求不苛刻的配置表、字典表,可以开启二级缓存,并设置合理的
关联查询是缓存杀手。如前所述,多表关联查询时,缓存失效会变得非常复杂。如果一定要用,考虑使用
cache-ref,或者放弃关联查询,改为在业务层进行多次单表查询并手动组装(这反而更容易控制缓存)。序列化是必须的。只要用到二级缓存,实体类必须实现
Serializable。记得生成serialVersionUID,避免反序列化失败。监控与度量。使用监控工具(如Micrometer + Prometheus/Grafana)观察缓存命中率。如果命中率极低,说明缓存配置可能不合理,或者数据更新太频繁,此时应该考虑关闭缓存。
MyBatis-Plus的特别说明。如果你使用MyBatis-Plus,它默认关闭了二级缓存。因为MP的作者认为二级缓存容易引起问题,更推荐使用独立的缓存服务。如果你需要在MP中开启,除了全局配置,还需要在Mapper接口上添加
@CacheNamespace注解或在XML中配置<cache>。
缓存是一把双刃剑。用得好,它能极大提升系统性能,尤其是应对高并发读场景;用不好,它会导致令人头疼的数据不一致问题,且调试困难。我的建议是,对于MyBatis自带的缓存,尤其是二级缓存,采取保守策略:除非有非常明确的收益和充分的测试,否则默认关闭。将缓存的重心转移到业务层,使用像Spring Cache这样更成熟、更灵活的解决方案,会让你对缓存有更强的控制力。毕竟,在软件架构中,清晰和可控往往比一点点的性能提升更重要。
