MyBatis缓存深度解析:从一级缓存到二级缓存,原理、配置与避坑指南

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 一级缓存的工作原理与生命周期

它的核心实现很简单:一个HashMapkeyCacheKey对象。这个CacheKeyMappedStatement的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级别的“团队共享白板”

如果说一级缓存是私人的,那二级缓存就是团队的。它是MapperNamespace)级别的缓存,多个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 二级缓存失效与更新的核心机制

这是二级缓存最容易出错的地方。它的失效逻辑如下:

  1. 某个SqlSession对表T执行了INSERT/UPDATE/DELETE操作并提交了事务(commit())。
  2. MyBatis会清空整个Mapper命名空间对应的二级缓存
  3. 所有后续的查询,都需要重新访问数据库。

这里有一个极其重要的细节:事务提交是触发二级缓存清空的关键。如果你在Spring中使用了@Transactional,那么只有在方法成功执行完毕、事务提交时,缓存才会被清空。如果方法执行过程中发生了异常并回滚,则缓存不会被清空。

坑点警示:二级缓存是Mapper级别的。假设你有UserMapperOrderMapperOrder对象里关联了一个User对象。你在UserMapper.xml中配置了缓存,在OrderMapper.xml中也配置了缓存。当你通过OrderMapper查询一个订单连带用户信息时,User数据会被缓存到OrderMapper的缓存区域。但是,如果另一个操作直接通过UserMapper更新了这个用户的信息,它只会清空UserMapper的缓存,而OrderMapper缓存里那个旧的User数据依然存在!这就导致了数据不一致

解决方案:对于有关联关系的实体,要么谨慎使用二级缓存,要么使用更高级的缓存引用(cache-refcache-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(本地内存缓存,功能强大)

  1. 添加依赖:mybatis-ehcacheehcache
  2. 在Mapper XML中指定缓存实现:
    <cache type="org.mybatis.caches.ehcache.EhcacheCache"/>
  3. 在类路径下添加ehcache.xml配置文件,可以精细配置内存/磁盘存储、过期时间、集群等。

集成Redis(分布式缓存)

  1. 添加依赖,如mybatis-redis(非官方)或使用Spring Cache + Redis,后者更主流。
  2. 通过Spring配置,将MyBatis的缓存实现指向一个Redis模板。这通常需要自定义一个Cache实现类,实现org.apache.ibatis.cache.Cache接口,内部使用RedisTemplate进行操作。
  3. 这样做的好处是所有应用实例共享同一份缓存,解决了分布式一致性问题,但引入了网络开销和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),便于观察是否真的访问了数据库。

测试用例集

  1. 一级缓存基础测试:同一SqlSession内两次相同查询,验证日志只打印一次SQL。
  2. 一级缓存失效测试
    • 在两次查询间执行update,验证缓存清空。
    • 在两次查询间调用sqlSession.clearCache(),验证缓存清空。
    • 使用两个不同的SqlSession执行相同查询,验证缓存不共享。
  3. 二级缓存基础测试
    • 在两个不同的SqlSession中执行相同查询,验证第二个SqlSession不打印SQL(命中二级缓存)。
    • 验证返回的对象不是同一个实例(==false,但equalstrue)。
  4. 二级缓存失效测试
    • SqlSession A查询数据 -> SqlSession B更新同一条数据并commit-> SqlSession A再次查询,验证SQL再次打印(缓存被清空)。
    • 测试在未commit的更新操作后,缓存是否被清空(应该不会)。
  5. 缓存配置测试
    • <select>上设置useCache="false",验证二级缓存不生效。
    • <update>上设置flushCache="false",验证执行更新后,后续查询是否还能命中缓存(危险操作,仅测试)。
  6. 关联查询缓存测试
    • 创建UserOrder实体及Mapper。
    • OrderMapper中配置关联查询<association>
    • 测试更新User后,通过OrderMapper查询到的关联User信息是否过期(会过期,除非使用cache-ref)。

通过这样的测试,你不仅能巩固对缓存机制的理解,还能在项目初期就发现潜在的配置错误或理解偏差,避免将它们带到线上环境。

6. 生产环境中的缓存实践与避坑指南

结合多年的经验,我总结出以下几点在生产中使用MyBatis缓存的核心建议:

  1. 一级缓存:理解并接受其局限性。它适用于短生命周期、重复查询的场景。在Spring事务管理中,合理利用。不要试图用它做跨请求的数据共享。

  2. 二级缓存:谨慎开启,明确边界

    • 对读远多于写、数据一致性要求不苛刻的配置表、字典表,可以开启二级缓存,并设置合理的flushIntervalsize
    • 对核心业务数据、更新频繁、一致性要求高的表(如订单、账户)不建议开启MyBatis二级缓存。数据不一致的风险远大于缓存带来的性能收益。这类场景应使用更可控的缓存策略,如业务层缓存(Spring Cache + Redis),并设计完善的缓存更新和失效逻辑。
  3. 关联查询是缓存杀手。如前所述,多表关联查询时,缓存失效会变得非常复杂。如果一定要用,考虑使用cache-ref,或者放弃关联查询,改为在业务层进行多次单表查询并手动组装(这反而更容易控制缓存)。

  4. 序列化是必须的。只要用到二级缓存,实体类必须实现Serializable。记得生成serialVersionUID,避免反序列化失败。

  5. 监控与度量。使用监控工具(如Micrometer + Prometheus/Grafana)观察缓存命中率。如果命中率极低,说明缓存配置可能不合理,或者数据更新太频繁,此时应该考虑关闭缓存。

  6. MyBatis-Plus的特别说明。如果你使用MyBatis-Plus,它默认关闭了二级缓存。因为MP的作者认为二级缓存容易引起问题,更推荐使用独立的缓存服务。如果你需要在MP中开启,除了全局配置,还需要在Mapper接口上添加@CacheNamespace注解或在XML中配置<cache>

缓存是一把双刃剑。用得好,它能极大提升系统性能,尤其是应对高并发读场景;用不好,它会导致令人头疼的数据不一致问题,且调试困难。我的建议是,对于MyBatis自带的缓存,尤其是二级缓存,采取保守策略:除非有非常明确的收益和充分的测试,否则默认关闭。将缓存的重心转移到业务层,使用像Spring Cache这样更成熟、更灵活的解决方案,会让你对缓存有更强的控制力。毕竟,在软件架构中,清晰和可控往往比一点点的性能提升更重要。