1. 项目概述:为什么我们需要关注MybatisPlus的二级缓存?
在基于SpringBoot和MybatisPlus的后端项目里,数据库查询性能是个绕不开的话题。当你的应用日活上来,或者某个复杂报表查询频繁被调用时,你可能会发现数据库的压力直线上升,响应时间也开始变得不那么友好。这时候,除了优化SQL、加索引,缓存往往是工程师们最先想到的利器。MybatisPlus作为Mybatis的增强工具,其一级缓存(SqlSession级别)默认开启,但它生命周期短,作用范围有限,对于提升整体应用性能,尤其是应对重复查询,显得有些力不从心。
于是,二级缓存(Mapper级别/Namespace级别)就进入了我们的视野。它允许跨SqlSession共享缓存数据,理论上能显著减少数据库的访问次数。在SpringBoot项目中集成MybatisPlus并开启二级缓存,听起来就像给数据库引擎加装了一个涡轮增压器,操作似乎也不复杂——加个注解、改个配置就行。但做过的人都知道,这潭水其实挺深。二级缓存带来的不仅仅是性能提升的喜悦,更伴随着数据一致性、序列化、以及分布式环境下的诸多“坑”。很多团队在引入后,反而被一些诡异的数据不同步问题搞得焦头烂额,最后不得不回滚。
所以,今天我们不只聊怎么“开启”这个开关,更要深入拆解开启之后,那些你必须面对的“问题”。我会结合自己趟过的坑,把配置的细节、背后的原理、常见的陷阱以及应对策略,掰开揉碎了讲清楚。无论你是正在考虑引入二级缓存,还是已经用了但被问题困扰,这篇文章都能给你提供一份从理论到实战的参考地图。
2. 二级缓存核心原理与MybatisPlus的集成机制
要玩转一个功能,先得知道它到底是怎么工作的。Mybatis的二级缓存,其核心思想是在同一个Mapper的命名空间(Namespace)下,所有操作共享一个缓存实例。
2.1 二级缓存的工作流程
当你执行一条查询语句时,Mybatis会先检查该Mapper是否开启了二级缓存。如果开启了,它会以一个特定的算法(通常基于Mapper的id、查询参数、分页参数等)生成一个CacheKey。然后拿着这个Key去二级缓存仓库里找,如果命中,直接返回缓存的结果,完全跳过数据库执行和结果映射;如果未命中,才去查数据库,并将查询结果放入二级缓存中,以备后续使用。
而更新操作(insert,update,delete)执行时,Mybatis会清空该Mapper命名空间下的整个二级缓存。这是保证数据一致性的最基本机制,但也正是很多问题的根源,因为它是一种粗粒度的清除策略。
2.2 MybatisPlus对二级缓存的增强与默认行为
MybatisPlus本身并没有重新发明二级缓存,它完全兼容并使用Mybatis原生的二级缓存机制。但是,MybatisPlus通过其强大的BaseMapper和条件构造器,引入了一些需要特别注意的点。
首先,MybatisPlus默认关闭了二级缓存。这是非常合理且谨慎的默认设置。因为缓存是一把双刃剑,在未充分理解其影响前就全局开启,风险很高。你需要显式地、有选择地去开启它。
其次,MybatisPlus的很多便捷方法,如selectList(Wrapper queryWrapper)、update(entity, updateWrapper),其底层生成的SQL和对应的Mapper语句id是动态的。这会影响CacheKey的生成。例如,两个逻辑上完全相同的QueryWrapper,如果创建时机不同,其toString()结果可能包含内存地址信息,导致生成的CacheKey不同,从而造成缓存无法命中,失去了缓存的意义。因此,使用MybatisPlus开启二级缓存,对Wrapper的使用有更严格的要求。
2.3 缓存实现与序列化要求
Mybatis的二级缓存默认使用内存中的PerpetualCache实现。这意味着缓存对象是存储在JVM堆内存里的。这里就引出了一个关键问题:缓存的对象必须是可序列化的。
为什么?因为Mybatis的缓存机制允许你配置使用第三方缓存中间件,比如Redis、Ehcache等。即使你暂时只用内存缓存,Mybatis内部在处理缓存时,也可能为了线程安全或其它原因,对对象进行序列化/反序列化的拷贝。如果你的实体类没有实现Serializable接口,在开启二级缓存后,很可能会遇到NotSerializableException异常。
实操心得:这是一个非常容易忽略但一踩就炸的坑。我的习惯是,项目中的所有实体类(Entity)、以及可能被放入缓存的结果集封装类,都无脑实现
Serializable接口,并显式声明一个serialVersionUID。这不仅是缓存的要求,也为未来可能的远程调用(RPC)、对象持久化到磁盘等场景扫清了障碍。@Data @TableName("user") public class User implements Serializable { private static final long serialVersionUID = 1L; @TableId(type = IdType.AUTO) private Long id; private String name; private Integer age; }
3. 在SpringBoot中逐步开启与配置二级缓存
理论清楚了,我们来看实战。在SpringBoot项目中开启MybatisPlus的二级缓存,需要三步走:全局配置、Mapper接口声明、实体类准备。
3.1 第一步:全局配置开启
在application.yml或application.properties中,需要明确告诉MybatisPlus我们要使用缓存。这里主要配置的是Mybatis本身的设置。
# application.yml mybatis-plus: configuration: # 开启二级缓存,这是总开关 cache-enabled: true # 通常建议同时关闭本地缓存(一级缓存),避免两级缓存交互带来复杂度 local-cache-scope: statementcache-enabled: true是核心开关。local-cache-scope: statement将一级缓存的作用域设置为语句级别(即每次查询后清空),这可以防止同一个SqlSession内,先查询后更新再查询时,一级缓存返回旧数据,而二级缓存已被清空导致的复杂情况。让二级缓存作为唯一的主动缓存层,逻辑更清晰。
3.2 第二步:在具体的Mapper接口上添加注解
二级缓存是按Mapper启用的,你需要在需要缓存的Mapper接口上添加@CacheNamespace注解。
import org.apache.ibatis.annotations.CacheNamespace; import com.baomidou.mybatisplus.core.mapper.BaseMapper; @CacheNamespace // 关键注解,声明此Mapper使用二级缓存 public interface UserMapper extends BaseMapper<User> { // 你的自定义方法... }这个注解会为该Mapper的所有查询操作启用二级缓存。更新操作会自动触发缓存清除。
3.3 第三步:验证与基础测试
完成上述两步后,你可以写一个简单的测试来验证缓存是否生效。
@SpringBootTest public class CacheTest { @Autowired private UserMapper userMapper; @Test public void testSecondLevelCache() { // 第一次查询,应该访问数据库 System.out.println("第一次查询"); List<User> list1 = userMapper.selectList(Wrappers.<User>lambdaQuery().eq(User::getName, "张三")); list1.forEach(System.out::println); // 第二次查询相同条件,应该命中缓存,不打印SQL System.out.println("第二次查询(预期命中缓存)"); List<User> list2 = userMapper.selectList(Wrappers.<User>lambdaQuery().eq(User::getName, "张三")); list2.forEach(System.out::println); // 执行一个更新操作 System.out.println("执行更新操作"); User user = list1.get(0); user.setAge(30); userMapper.updateById(user); // 第三次查询,更新后缓存应被清空,重新访问数据库 System.out.println("第三次查询(更新后,预期重新查库)"); List<User> list3 = userMapper.selectList(Wrappers.<User>lambdaQuery().eq(User::getName, "张三")); list3.forEach(System.out::println); } }运行测试时,打开Mybatis的SQL日志(mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl),观察控制台。理想情况下,你应该只看到第一次和第三次查询打印了SQL语句,第二次查询没有SQL日志,说明缓存命中成功。
4. 二级缓存带来的典型问题与深度解析
如果只是按照上面的步骤走一遍,你可能会觉得二级缓存不过如此。但当你把应用部署到测试环境,甚至生产环境,让多个用户、多个服务节点跑起来时,问题才会真正浮现。下面我们来逐一拆解这些“坑”。
4.1 数据一致性问题:最核心的挑战
这是二级缓存最棘手的问题。Mybatis的缓存清除策略是以Mapper的Namespace为维度的。这意味着:
跨
Mapper更新导致脏读:假设你有UserMapper和UserDetailMapper,它们操作不同的表,但业务逻辑上User和UserDetail是关联的。你在UserDetailMapper里更新了某个用户的详情,这个操作只会清空UserDetailMapper的二级缓存,而UserMapper的缓存纹丝不动。如果某个查询是从UserMapper缓存中获取的数据,它包含的详情信息就是过时的。多表关联查询的缓存失效:如果你写了一个复杂的
select语句,通过join关联了多张表,这个查询结果会被缓存在发起查询的Mapper命名空间下。但是,当任何一张被关联的表发生更新时(无论通过哪个Mapper),这个关联查询的缓存都不会被自动清除,因为它只属于查询Mapper的缓存空间。这会导致极其隐蔽的脏数据问题。
避坑技巧:对于强一致性的业务场景(如账户余额、库存数量),慎用甚至禁用二级缓存。可以考虑使用更可控的、细粒度的缓存方案,如直接在Service层用
Redis,并设计好缓存的Key和过期策略。如果一定要用,必须严格规划Mapper的职责,尽可能让一个聚合根下的所有操作集中在同一个Mapper中,或者使用下文提到的@CacheNamespaceRef来建立缓存引用关系。
4.2 缓存序列化与对象相等性问题
如前所述,缓存对象需要序列化。但这里还有更深的问题:
反序列化生成新对象:从缓存中取出的对象,是反序列化后生成的一个新对象。这意味着
list1 == list2会是false,即使它们内容相同。这通常不影响业务逻辑,但如果你在某些地方依赖对象地址相等性做判断,就会出错。CacheKey的生成依赖Wrapper的toString:这是MybatisPlus下特有的高频坑点。看下面这个例子:
LambdaQueryWrapper<User> wrapper1 = new LambdaQueryWrapper<User>().eq(User::getName, “张三”); LambdaQueryWrapper<User> wrapper2 = new LambdaQueryWrapper<User>().eq(User::getName, “张三”); List<User> list1 = userMapper.selectList(wrapper1); List<User> list2 = userMapper.selectList(wrapper2);你可能会期望list2命中缓存,但结果很可能没有。因为wrapper1和wrapper2是两个不同的对象,它们的toString()结果可能包含类似LambdaQueryWrapper@5a4aa2f3这样的哈希码,导致生成的CacheKey不同。
解决方案是,对于需要缓存的查询,尽量使用静态的、可复用的查询条件,或者直接使用@Select注解编写SQL语句,这样CacheKey由SQL语句本身和参数决定,更稳定。
4.3 分布式环境下的缓存共享问题
默认的基于内存的PerpetualCache是单机缓存。在微服务或集群部署环境下,你的应用有多个实例(Pod/容器)。实例A的缓存更新,无法通知到实例B和实例C。这会导致:
- 用户在实例A上更新了数据,然后请求被负载均衡到实例B,用户查到的还是旧数据。
- 同一个数据,在不同实例的缓存中,可能处于不同的状态。
这就是经典的分布式缓存一致性问题。Mybatis的二级缓存架构允许你替换缓存实现,常见的做法是集成Redis。通过配置MybatisRedisCache这样的自定义缓存类,可以将所有实例的二级缓存都存储到中央Redis服务器,解决数据共享问题。
@CacheNamespace(implementation = MybatisRedisCache.class) // 指定使用Redis缓存实现 public interface UserMapper extends BaseMapper<User> { }但这也引入了新的复杂度:Redis的连接与性能、缓存对象的序列化方式(JDK、Jackson、Kryo等)、Redis本身的网络故障处理等。
4.4 缓存穿透、雪崩与击穿
二级缓存同样面临经典的缓存三大问题:
- 穿透:查询一个数据库中根本不存在的数据。这个查询会每次都绕过缓存击穿到数据库。解决方案可以在缓存层存储一个空值(
null)并设置短过期时间,或者使用布隆过滤器。 - 雪崩:缓存中大量数据在同一时间过期,导致所有请求瞬间涌向数据库。解决方案是给缓存过期时间加上一个随机值,避免同时过期。
- 击穿:某个热点
Key过期瞬间,大量并发请求同时发现缓存失效,同时去数据库查询。解决方案是使用互斥锁(Mutex Key),只让一个请求去查库,其他请求等待。
Mybatis原生二级缓存对这些问题没有防护。如果你使用的是Redis等外部缓存,可以利用其setnx命令实现简单的锁机制,或者在Service层增加相应的防护逻辑。
5. 高级配置与最佳实践建议
面对上述问题,我们不能因噎废食,而是要通过合理的配置和规范来扬长避短。
5.1 精细化缓存控制:@CacheNamespace与@CacheNamespaceRef
@CacheNamespace(blocking=true):开启阻塞模式。当缓存未命中时,其他线程对于同一个Key的查询会被阻塞,直到第一个线程查询数据库并填充缓存。这可以有效防止缓存击穿,但会降低并发吞吐量,需根据场景权衡。@CacheNamespaceRef:用于解决跨Mapper缓存一致性问题。你可以让Mapper B引用Mapper A的缓存空间。
@CacheNamespace public interface AMapper { ... } @CacheNamespaceRef(AMapper.class) // 与AMapper共享同一个缓存实例 public interface BMapper { ... }这样,在BMapper上的操作也会清空AMapper的缓存,一定程度上缓解了跨Mapper更新的脏读问题。但这需要你非常清楚Mapper之间的依赖关系。
5.2 使用第三方缓存中间件(以Redis为例)
集成Redis作为二级缓存存储是最常见的生产级做法。你需要:
- 引入依赖:
spring-boot-starter-data-redis。 - 编写一个自定义的
Cache实现类,继承自org.apache.ibatis.cache.Cache,在内部使用RedisTemplate进行操作。 - 在
@CacheNamespace(implementation = MyCustomRedisCache.class)中指定它。
这样做的好处是统一了缓存存储,解决了分布式一致性问题,还能利用Redis的持久化、集群等高可用特性。但代价是增加了系统复杂度,且网络I/O会比本地内存慢。
5.3 明确的缓存使用规范
在团队内制定规范至关重要:
- 按需开启:不是所有
Mapper都需要缓存。只为查询远多于更新、且对数据实时性要求不高的Mapper开启。核心业务Mapper默认关闭。 - 结果集对象必须实现
Serializable:将其作为代码规范强制执行。 - 避免在
Wrapper中拼接动态变量:动态条件(如时间范围)尽量通过@Param传递参数,保证SQL语句id稳定。 - 关联查询慎用二级缓存:对于多表
join的复杂查询,建议在Service层使用自定义Redis缓存,或者直接禁用二级缓存。 - 设立缓存监控:监控缓存的命中率、内存占用。如果某个缓存命中率极低,说明它可能没必要存在,或者
CacheKey设计有问题。
6. 常见问题排查与调试技巧
当遇到缓存行为不符合预期时,可以按以下步骤排查:
- 确认开关与注解:检查
application.yml中的cache-enabled: true,以及目标Mapper接口上的@CacheNamespace注解是否添加正确。 - 开启Mybatis完整日志:在配置中设置
logging.level.com.yourpackage.mapper=TRACE,可以查看更详细的缓存操作日志,包括CacheKey的生成、命中/未命中情况。 - 检查
CacheKey:在TRACE日志下,你会看到类似Cache Hit Ratio [com.xxx.mapper]: 0.5的信息,以及具体的CacheKey值。对比两次你认为应该相同的查询,其CacheKey是否完全一致。不一致的原因往往是SQLid不同(动态SQL导致)或参数不同。 - 验证序列化:尝试将缓存实现临时换为一个会序列化对象的版本(如
Redis),或者直接让实体类不实现Serializable,看是否会立刻抛出异常,以验证序列化路径是否通畅。 - 检查更新操作:确认执行了
insert/update/delete后,缓存是否被清除。可以在TRACE日志中看到Clearing cache for namespace com.xxx.mapper这样的信息。 - 分布式环境:如果是多实例问题,先确认请求是否真的打到了不同实例。然后检查是否使用了共享缓存(如
Redis)。如果没有,那数据不一致是必然的。
二级缓存是一个强大的功能,但它要求开发者对数据一致性有深刻的理解。在SpringBoot + MybatisPlus的项目中,它更像是一个需要精心调校的精密仪器,而不是一个开了就一劳永逸的“性能加速按钮”。我的经验是,对于大多数业务场景,在Service层使用Redis进行针对性的、细粒度的缓存设计,可控性和可维护性往往更好。二级缓存更适合用于那些业务逻辑简单、数据模型稳定、以读为主的场景,比如配置信息、静态数据列表等。在引入之前,务必在预发环境进行充分的数据一致性测试,模拟各种并发和异常场景,确保它带来的收益远大于其维护成本。