SpringBoot中MyBatis-Plus二级缓存实战:原理、问题与最佳实践
2026/8/7 3:12:59 网站建设 项目流程

1. 项目概述:为什么要在SpringBoot中开启MyBatis-Plus二级缓存?

在任何一个有一定用户量的Web应用中,数据库查询往往是性能瓶颈最集中的地方。我们经常遇到这样的场景:一个首页的渲染,需要关联查询用户信息、文章列表、推荐内容等,这些查询在每次请求时都重复执行,即使数据在短时间内根本没有变化。对于读多写少的业务,这种重复的、无意义的数据库I/O操作,不仅消耗了宝贵的数据库连接资源,也直接拉长了接口的响应时间。

MyBatis-Plus作为MyBatis的增强工具,其内置的二级缓存功能,就是为了解决这类“重复查询”问题而生的。它允许我们将查询结果缓存到应用进程的内存(或集成的第三方缓存如Redis)中,当后续完全相同的查询命中时,直接返回缓存的结果,完全绕过数据库。这听起来像是一个“银弹”,尤其是在使用SpringBoot快速构建应用时,开启它似乎不费吹灰之力。

然而,在实际的生产环境中,我见过太多团队因为盲目或不当使用二级缓存而踩坑。数据不一致、内存溢出、缓存雪崩等问题层出不穷,轻则导致功能异常,重则引发线上事故。所以,今天我们不只谈“如何开启”,更要深入探讨“开启后带来的问题”,以及“如何安全、有效地使用它”。这篇文章将基于我处理过的多个中大型项目的缓存实践,为你拆解MyBatis-Plus二级缓存的机制、配置细节以及那些你必须提前知晓的“坑”。

2. MyBatis-Plus二级缓存的核心机制与配置详解

要驾驭一个工具,必须先理解它的工作原理。MyBatis-Plus的二级缓存并非其独创,它继承并增强了MyBatis原生的二级缓存机制。理解以下几个核心概念,是后续一切操作和问题排查的基础。

2.1 缓存的作用域与生命周期

首先,我们需要区分一级缓存和二级缓存。

  • 一级缓存(SqlSession级别):默认开启。它的作用域是一个数据库会话(SqlSession)。在同一个SqlSession中,执行两次相同的SQL查询,第二次会直接使用缓存。一旦执行了增、删、改操作,或者调用了sqlSession.clearCache(),或者关闭了SqlSession,这个缓存就会失效。它的生命周期太短,对于Web应用(通常每个请求一个SqlSession)来说,意义不大。
  • 二级缓存(Mapper级别/Namespace级别):我们需要手动开启。它的作用域是一个Mapper命名空间(Namespace)。所有在这个Mapper中执行的查询,只要缓存条件匹配,都可以共享结果。它的生命周期与整个应用进程绑定(如果使用进程内缓存),或者与配置的缓存服务器(如Redis)的生命周期绑定。这才是我们提升性能所关注的重点。

MyBatis-Plus二级缓存的核心思想是:以Mapper为单位,将查询结果对象序列化后存储起来。当同一个Mapper内执行完全相同的SQL(包括SQL语句和参数)时,优先从缓存中获取结果。

2.2 在SpringBoot中开启二级缓存的完整步骤

假设我们有一个SpringBoot 2.x + MyBatis-Plus 3.x的项目。以下是开启并配置二级缓存的详细流程,我会解释每一步的意图。

第一步:在application.yml中开启全局缓存配置

mybatis-plus: configuration: # 开启二级缓存,这是总开关 cache-enabled: true

这个配置对应MyBatis原生配置中的cacheEnabled,设置为true仅仅表示“允许”每个Mapper使用二级缓存,但具体哪个Mapper用,还需要在Mapper接口上声明。

第二步:在目标Mapper接口上添加@CacheNamespace注解这是最关键的一步。你需要在希望启用二级缓存的Mapper接口上打上这个注解。

import org.apache.ibatis.annotations.CacheNamespace; import com.baomidou.mybatisplus.core.mapper.BaseMapper; @CacheNamespace // 关键注解,表明此Mapper启用二级缓存 public interface UserMapper extends BaseMapper<User> { // 你的自定义方法 }

@CacheNamespace注解告诉MyBatis-Plus,这个Mapper的所有查询操作(除非被单独设置不使用缓存)的结果都应该被缓存。这里有一个常见的误解:以为在application.yml里开了就行,忘了加这个注解,结果缓存根本没生效,排查半天。

第三步(可选但推荐):配置缓存实现MyBatis默认使用一个简单的PerpetualCache(永久缓存)实现,它就是一个HashMap,存在于JVM堆内存中。在生产环境,这通常不够用。我们可以集成更专业的缓存,比如Ehcache、Redis等。

以集成Redis为例,首先需要引入依赖(这里以Spring Boot Data Redis为例):

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

然后,你需要实现MyBatis的Cache接口,创建一个RedisCache类。这个过程稍显复杂,需要处理序列化、过期策略、键的生成规则等。不过,MyBatis-Plus社区或一些开源项目通常有现成的实现可供参考。集成后,在@CacheNamespace注解中指定实现类:

@CacheNamespace(implementation = com.yourpackage.RedisCache.class) public interface UserMapper extends BaseMapper<User> { }

使用Redis作为缓存后端,好处是解决了应用重启缓存丢失、多实例应用缓存共享的问题,但引入了网络开销和Redis的运维复杂度。

第四步:理解并配置序列化二级缓存存储的是查询结果映射后的Java对象。这些对象需要被序列化才能存储(无论是内存还是Redis)。MyBatis默认使用JDK序列化,效率低且兼容性可能有问题。如果你使用默认缓存,确保你的实体类实现了Serializable接口。如果使用其他缓存(如Redis),你通常需要配置更高效的序列化器,如Jackson2JsonRedisSerializer。

完成以上步骤,二级缓存就基本开启了。你可以写一个单元测试,连续调用两次同一个查询方法,在日志中设置mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,观察第二次查询是否没有打印SQL日志,来验证缓存是否生效。

3. 二级缓存带来的四大核心问题与深度剖析

开启了缓存,性能测试时可能看到惊人的提升,但千万别高兴太早。以下是我在实践中总结的四个最具代表性的问题,每一个都可能让你在深夜接到报警电话。

3.1 数据一致性问题:脏读的幽灵

这是二级缓存最致命、也最常见的问题。缓存的核心矛盾在于:它存储的是某个时间点的数据快照,而数据库中的数据是随时可能变化的。

问题场景

  1. 服务A通过UserMapper查询id=1的用户,结果被缓存。
  2. 服务B(甚至是同一个服务的另一个线程)通过UserMapper更新了id=1的用户信息,并成功提交事务。
  3. 服务A再次查询id=1的用户,由于缓存未失效,它读到的是旧的、脏的数据。

根因分析: MyBatis的二级缓存失效机制,默认是基于Mapper命名空间的。也就是说,当在UserMapper上执行了一个updateById操作,MyBatis会使整个UserMapper的缓存全部清空。这听起来很粗暴,但至少能保证一致性,对吧?问题出在“事务”上。

在Spring管理的事务中,缓存的清空动作发生在事务提交之后。考虑这个场景:

@Transactional public void updateUser() { // 1. 查询用户,结果放入缓存 User user = userMapper.selectById(1L); // 2. 修改用户 user.setName("NewName"); // 3. 更新数据库(此时事务未提交!) userMapper.updateById(user); // 4. 在同一个方法内,再次查询 User cachedUser = userMapper.selectById(1L); // 问题:cachedUser.getName() 很可能还是旧值! }

为什么?因为第3步的update操作虽然执行了,但事务还没提交,数据库里的数据还没变,MyBatis可能还不会立即清缓存。更关键的是,第4步的查询,如果命中了一级缓存(SqlSession级别),它根本不会走到二级缓存那一步,直接返回了旧对象。即使没有一级缓存,在事务提交前,二级缓存也可能未被清除。

解决方案与心得

  1. 设置@CacheNamespace(flushInterval=时间):为缓存设置一个自动刷新间隔(毫秒),例如flushInterval=60000(1分钟)。这属于妥协方案,数据会有最多1分钟的不一致,适用于对实时性要求不高的数据。
  2. 在涉及更新的业务方法上,手动清除缓存:在updateUser方法最后,显式调用SqlSessionclearCache()方法,或者如果你使用了自定义的Cache实现,直接调用其清除方法。这要求你对缓存有更强的控制力。
  3. 使用更细粒度的缓存策略:放弃Mapper级别的缓存,使用诸如Spring Cache(@Cacheable,@CacheEvict)这样的注解,在方法级别上控制缓存的读和写。你可以更精确地指定,当更新用户时,只清除“user::1”这个键的缓存,而不是所有用户缓存。这是目前更主流、更推荐的做法。MyBatis-Plus的二级缓存更像是一个“基础设施开关”,而Spring Cache是更上层的“业务缓存抽象”。
  4. 心理建设:对于强一致性要求极高的数据(如账户余额、库存),直接放弃查询缓存,老老实实读数据库。或者采用“先更新数据库,再删除缓存”的Cache-Aside模式,并处理好缓存删除失败的重试机制。

3.2 缓存序列化与对象关联的陷阱

当你使用默认的进程内缓存时,一切似乎风平浪静。一旦你开始使用分布式缓存(如Redis),或者你的实体对象存在复杂的关联关系,坑就来了。

问题场景: 你的User对象里有一个List<Order>属性,通过@TableField(exist = false)标注,并在服务层手动查询填充。当你将User对象缓存到Redis后,下次反序列化出来时,这个List<Order>可能会丢失(如果未正确序列化),或者更糟糕地,你缓存了一个巨大的、不断增长的关联对象集合,导致缓存体积爆炸。

根因分析

  1. 序列化兼容性:JDK序列化对类版本(serialVersionUID)极其敏感。如果你修改了实体类结构而没有更新UID,反序列化会失败。Jackson等JSON序列化器可能不处理transient字段以外的循环引用,导致栈溢出。
  2. “胖对象”缓存:你无意中缓存了一个包含大量懒加载代理(如Hibernate Proxy)或巨大集合的对象。当这个对象被序列化时,可能会触发整个对象图的加载,性能灾难就此发生。

解决方案与心得

  1. 缓存“瘦”对象,最好是DTO:坚决不要缓存带有@TableField(exist = false)的关联属性。最佳实践是,为缓存专门设计一个UserCacheDTO,只包含需要缓存的核心字段(id, name, avatar等)。在查询后,将User对象转换为UserCacheDTO再进行缓存。这保证了缓存内容的精简和稳定。
  2. 选择高效的序列化方案:放弃JDK序列化。使用Kryo、FST或Jackson JSON。在Redis中,Jackson2JsonRedisSerializer是不错的选择,但要注意配置ObjectMapper忽略循环引用和transient字段。
  3. 仔细检查实体类:确保所有不需要或不能序列化的字段(如HttpSession、数据库连接等)标记为transient,或者使用@JsonIgnore注解(如果你用JSON序列化)。

3.3 缓存穿透、雪崩与击穿

这三个是分布式缓存的经典问题,在使用MyBatis-Plus二级缓存并搭配Redis时同样会遇到。

  • 缓存穿透:查询一个数据库中根本不存在的数据。请求会穿过缓存,直接访问数据库。如果被恶意攻击,大量请求查询不存在的ID,数据库可能被压垮。
    • 应对:将“空结果”也进行缓存,但设置一个较短的过期时间(如30秒)。可以使用一个特殊的标记值(如“##NULL##”)来表示。
  • 缓存雪崩:设置缓存时采用了相同的过期时间,导致在某一时刻,大量缓存同时失效,所有请求涌向数据库。
    • 应对:为缓存数据设置一个随机的过期时间偏移量,例如基础过期时间+(-5~5分钟的随机数),让缓存失效时间点分散开。
  • 缓存击穿:某个热点key(如首页头条新闻)在失效的瞬间,有大量并发请求同时到来,未命中缓存,全部去查询数据库。
    • 应对:使用互斥锁(Mutex Lock)。在缓存失效时,不是所有线程都去查库,而是让一个线程去查,其他线程等待,查完后写入缓存,其他线程再从缓存读取。在Java中,可以用synchronized关键字或ReentrantLock在应用层实现,更优雅的方式是使用Redis的SETNX命令实现分布式锁。

注意:MyBatis-Plus原生的二级缓存开箱即用功能,并没有内置这些高级防护机制。如果你直接使用其默认实现,就需要自己在业务代码或自定义的Cache实现类中加入这些逻辑。这再次说明了,对于复杂的生产环境,直接使用Spring Cache等更高级的抽象,或者直接操作Redis Template,往往比使用MyBatis-Plus的二级缓存更可控。

3.4 多表关联查询与缓存作用域混淆

这是一个非常隐蔽的问题。假设你有UserMapperOrderMapper,并且有一个查询需要关联用户和订单。

问题场景: 你在UserMapper.xml中写了一个复杂的<select>,通过join语句同时查询出了UserOrder的数据。这个查询结果被缓存在UserMapper的命名空间下。后来,你通过OrderMapper更新了某个订单的状态。但是,OrderMapper的更新操作,只会清空OrderMapper命名空间下的缓存,而不会触碰到UserMapper的缓存。导致UserMapper中那个关联查询的结果,仍然包含着旧的订单状态。

根因分析: MyBatis的二级缓存是命名空间隔离的,它没有跨命名空间的缓存依赖感知能力。一个Mapper无法知道自己的数据被其他Mapper的查询所引用。

解决方案与心得

  1. 避免在查询层做多表关联:这是最根本的解决方案。遵循“领域驱动”或“简洁架构”的思想,在Service层分别调用UserMapperOrderMapper进行单表查询,然后在内存中进行数据组装(俗称“拼装”)。这样,缓存是单表的,更新订单只会清除订单缓存,用户缓存不受影响,虽然可能有一致性延迟,但边界清晰,问题更容易追踪。
  2. 使用<cache-ref>:MyBatis提供了<cache-ref namespace="..."/>标签,可以让一个Mapper引用另一个Mapper的缓存。这样,UserMapperOrderMapper就共享了同一个缓存实例,对任何一个Mapper的更新都会清空共享缓存。但这相当于回到了粗粒度的缓存清除,可能误伤过多。
  3. 放弃使用二级缓存处理复杂关联:明确二级缓存的定位——它最适合缓存变化不频繁的、单表的主键查询或简单条件查询。对于复杂的、涉及多表关联的业务查询,应该使用专门的缓存策略(如Spring Cache),或者不缓存。

4. 生产环境下的最佳实践与决策指南

经过以上问题的剖析,你可能会觉得二级缓存“危如累卵”。别担心,任何技术都有其适用场景。下面是我的经验总结,告诉你什么时候该用,该怎么用。

4.1 何时应该考虑开启二级缓存?

  • 数据字典/配置类数据:例如国家城市列表、系统参数配置等,几乎从不更新,但被频繁查询。
  • 用户基础信息:如用户头像、昵称等,更新频率较低(一天几次),但读取频率极高。
  • 热点文章/商品详情:在活动期间,某些热点内容被海量读取,且内容在活动期间固定。
  • 复杂的统计报表:查询耗时极长(如几分钟),且数据允许有一定的延迟(如T+1的报表)。

核心判断原则读远大于写,且对数据一致性要求不是实时强一致。

4.2 更推荐的替代方案:Spring Cache + 自定义缓存逻辑

对于大多数SpringBoot项目,我个人的建议是:谨慎使用MyBatis-Plus自带的二级缓存,优先考虑使用Spring Cache。

为什么?

  1. 关注点分离:MyBatis-Plus的职责是数据访问层(DAO)的增强。而缓存,更多是一种业务层或服务层的优化策略。使用Spring Cache(@Cacheable,@CacheEvict,@CachePut),你可以将缓存规则声明在Service方法上,代码更清晰,职责更明确。
  2. 更精细的控制:你可以轻松指定缓存的key(支持SpEL表达式),可以按条件缓存(conditionunless),可以在更新时只清除特定的key(@CacheEvict(key = “‘user::’ + #id”)),而不是清空整个Mapper的缓存。
  3. 更好的集成:Spring Cache抽象了缓存提供商,可以无缝在Ehcache、Caffeine、Redis等之间切换,配置更统一。

示例:

@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override @Cacheable(value = “userCache”, key = “‘user::’ + #id”) public User getUserById(Long id) { // 这里调用的是你的Mapper方法 return userMapper.selectById(id); } @Override @CacheEvict(value = “userCache”, key = “‘user::’ + #id”) public void updateUser(User user) { userMapper.updateById(user); } }

这样,缓存的控制权完全在你手中,避免了MyBatis二级缓存那些隐晦的、基于命名空间的行为。

4.3 如果决定使用,必须做的监控与保障

如果你因为历史原因或特定场景必须使用MyBatis-Plus二级缓存,请务必做好以下监控:

  1. 监控缓存命中率:在自定义的Cache实现中,加入计数逻辑,统计getObject的调用次数和命中次数。过低的命中率(如低于70%)意味着缓存策略可能有问题,或者数据变化太频繁不适合缓存。
  2. 监控缓存大小:如果是本地缓存,定期通过JMX或监控工具查看缓存占用的堆内存大小,防止内存泄漏或OOM。如果是Redis,监控其内存使用量。
  3. 建立缓存的降级开关:在应用配置中心(如Nacos、Apollo)配置一个开关,可以在出现缓存问题(如数据大面积不一致)时,一键关闭所有缓存,让系统回退到直接访问数据库的状态。这是一个非常重要的运维保障手段。
  4. 关键业务的数据一致性校验:对于特别重要的数据,可以定期运行一个离线任务,对比缓存中的数据与数据库中最新的数据,并报告差异。这能帮你提前发现缓存同步机制的问题。

开启MyBatis-Plus二级缓存,就像给数据库查询加装了一个涡轮增压器,能在特定路况下爆发出强劲动力。但它也需要更精密的调校和更谨慎的驾驶习惯。理解其内部机制,预见其潜在问题,并准备好应对方案,你才能稳稳地享受它带来的性能红利,而不是被它拖入故障的泥潭。在架构选型上,多想一想“我们真的需要这个级别的缓存吗?”、“有没有更简单可控的方案?”,往往比盲目追求技术特性更为重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询