ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot中MyBatis-Plus二级缓存实战:原理、问题与最佳实践

SpringBoot中MyBatis-Plus二级缓存实战:原理、问题与最佳实践 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 BaseMapperUser { // 你的自定义方法 }CacheNamespace注解告诉MyBatis-Plus这个Mapper的所有查询操作除非被单独设置不使用缓存的结果都应该被缓存。这里有一个常见的误解以为在application.yml里开了就行忘了加这个注解结果缓存根本没生效排查半天。第三步可选但推荐配置缓存实现MyBatis默认使用一个简单的PerpetualCache永久缓存实现它就是一个HashMap存在于JVM堆内存中。在生产环境这通常不够用。我们可以集成更专业的缓存比如Ehcache、Redis等。以集成Redis为例首先需要引入依赖这里以Spring Boot Data Redis为例dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency然后你需要实现MyBatis的Cache接口创建一个RedisCache类。这个过程稍显复杂需要处理序列化、过期策略、键的生成规则等。不过MyBatis-Plus社区或一些开源项目通常有现成的实现可供参考。集成后在CacheNamespace注解中指定实现类CacheNamespace(implementation com.yourpackage.RedisCache.class) public interface UserMapper extends BaseMapperUser { }使用Redis作为缓存后端好处是解决了应用重启缓存丢失、多实例应用缓存共享的问题但引入了网络开销和Redis的运维复杂度。第四步理解并配置序列化二级缓存存储的是查询结果映射后的Java对象。这些对象需要被序列化才能存储无论是内存还是Redis。MyBatis默认使用JDK序列化效率低且兼容性可能有问题。如果你使用默认缓存确保你的实体类实现了Serializable接口。如果使用其他缓存如Redis你通常需要配置更高效的序列化器如Jackson2JsonRedisSerializer。完成以上步骤二级缓存就基本开启了。你可以写一个单元测试连续调用两次同一个查询方法在日志中设置mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl观察第二次查询是否没有打印SQL日志来验证缓存是否生效。3. 二级缓存带来的四大核心问题与深度剖析开启了缓存性能测试时可能看到惊人的提升但千万别高兴太早。以下是我在实践中总结的四个最具代表性的问题每一个都可能让你在深夜接到报警电话。3.1 数据一致性问题脏读的幽灵这是二级缓存最致命、也最常见的问题。缓存的核心矛盾在于它存储的是某个时间点的数据快照而数据库中的数据是随时可能变化的。问题场景服务A通过UserMapper查询id1的用户结果被缓存。服务B甚至是同一个服务的另一个线程通过UserMapper更新了id1的用户信息并成功提交事务。服务A再次查询id1的用户由于缓存未失效它读到的是旧的、脏的数据。根因分析 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级别它根本不会走到二级缓存那一步直接返回了旧对象。即使没有一级缓存在事务提交前二级缓存也可能未被清除。解决方案与心得设置CacheNamespace(flushInterval时间)为缓存设置一个自动刷新间隔毫秒例如flushInterval600001分钟。这属于妥协方案数据会有最多1分钟的不一致适用于对实时性要求不高的数据。在涉及更新的业务方法上手动清除缓存在updateUser方法最后显式调用SqlSession的clearCache()方法或者如果你使用了自定义的Cache实现直接调用其清除方法。这要求你对缓存有更强的控制力。使用更细粒度的缓存策略放弃Mapper级别的缓存使用诸如Spring CacheCacheable,CacheEvict这样的注解在方法级别上控制缓存的读和写。你可以更精确地指定当更新用户时只清除“user::1”这个键的缓存而不是所有用户缓存。这是目前更主流、更推荐的做法。MyBatis-Plus的二级缓存更像是一个“基础设施开关”而Spring Cache是更上层的“业务缓存抽象”。心理建设对于强一致性要求极高的数据如账户余额、库存直接放弃查询缓存老老实实读数据库。或者采用“先更新数据库再删除缓存”的Cache-Aside模式并处理好缓存删除失败的重试机制。3.2 缓存序列化与对象关联的陷阱当你使用默认的进程内缓存时一切似乎风平浪静。一旦你开始使用分布式缓存如Redis或者你的实体对象存在复杂的关联关系坑就来了。问题场景 你的User对象里有一个ListOrder属性通过TableField(exist false)标注并在服务层手动查询填充。当你将User对象缓存到Redis后下次反序列化出来时这个ListOrder可能会丢失如果未正确序列化或者更糟糕地你缓存了一个巨大的、不断增长的关联对象集合导致缓存体积爆炸。根因分析序列化兼容性JDK序列化对类版本serialVersionUID极其敏感。如果你修改了实体类结构而没有更新UID反序列化会失败。Jackson等JSON序列化器可能不处理transient字段以外的循环引用导致栈溢出。“胖对象”缓存你无意中缓存了一个包含大量懒加载代理如Hibernate Proxy或巨大集合的对象。当这个对象被序列化时可能会触发整个对象图的加载性能灾难就此发生。解决方案与心得缓存“瘦”对象最好是DTO坚决不要缓存带有TableField(exist false)的关联属性。最佳实践是为缓存专门设计一个UserCacheDTO只包含需要缓存的核心字段id, name, avatar等。在查询后将User对象转换为UserCacheDTO再进行缓存。这保证了缓存内容的精简和稳定。选择高效的序列化方案放弃JDK序列化。使用Kryo、FST或Jackson JSON。在Redis中Jackson2JsonRedisSerializer是不错的选择但要注意配置ObjectMapper忽略循环引用和transient字段。仔细检查实体类确保所有不需要或不能序列化的字段如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 多表关联查询与缓存作用域混淆这是一个非常隐蔽的问题。假设你有UserMapper和OrderMapper并且有一个查询需要关联用户和订单。问题场景 你在UserMapper.xml中写了一个复杂的select通过join语句同时查询出了User和Order的数据。这个查询结果被缓存在UserMapper的命名空间下。后来你通过OrderMapper更新了某个订单的状态。但是OrderMapper的更新操作只会清空OrderMapper命名空间下的缓存而不会触碰到UserMapper的缓存。导致UserMapper中那个关联查询的结果仍然包含着旧的订单状态。根因分析 MyBatis的二级缓存是命名空间隔离的它没有跨命名空间的缓存依赖感知能力。一个Mapper无法知道自己的数据被其他Mapper的查询所引用。解决方案与心得避免在查询层做多表关联这是最根本的解决方案。遵循“领域驱动”或“简洁架构”的思想在Service层分别调用UserMapper和OrderMapper进行单表查询然后在内存中进行数据组装俗称“拼装”。这样缓存是单表的更新订单只会清除订单缓存用户缓存不受影响虽然可能有一致性延迟但边界清晰问题更容易追踪。使用cache-refMyBatis提供了cache-ref namespace.../标签可以让一个Mapper引用另一个Mapper的缓存。这样UserMapper和OrderMapper就共享了同一个缓存实例对任何一个Mapper的更新都会清空共享缓存。但这相当于回到了粗粒度的缓存清除可能误伤过多。放弃使用二级缓存处理复杂关联明确二级缓存的定位——它最适合缓存变化不频繁的、单表的主键查询或简单条件查询。对于复杂的、涉及多表关联的业务查询应该使用专门的缓存策略如Spring Cache或者不缓存。4. 生产环境下的最佳实践与决策指南经过以上问题的剖析你可能会觉得二级缓存“危如累卵”。别担心任何技术都有其适用场景。下面是我的经验总结告诉你什么时候该用该怎么用。4.1 何时应该考虑开启二级缓存数据字典/配置类数据例如国家城市列表、系统参数配置等几乎从不更新但被频繁查询。用户基础信息如用户头像、昵称等更新频率较低一天几次但读取频率极高。热点文章/商品详情在活动期间某些热点内容被海量读取且内容在活动期间固定。复杂的统计报表查询耗时极长如几分钟且数据允许有一定的延迟如T1的报表。核心判断原则读远大于写且对数据一致性要求不是实时强一致。4.2 更推荐的替代方案Spring Cache 自定义缓存逻辑对于大多数SpringBoot项目我个人的建议是谨慎使用MyBatis-Plus自带的二级缓存优先考虑使用Spring Cache。为什么关注点分离MyBatis-Plus的职责是数据访问层DAO的增强。而缓存更多是一种业务层或服务层的优化策略。使用Spring CacheCacheable,CacheEvict,CachePut你可以将缓存规则声明在Service方法上代码更清晰职责更明确。更精细的控制你可以轻松指定缓存的key支持SpEL表达式可以按条件缓存conditionunless可以在更新时只清除特定的keyCacheEvict(key “‘user::’ #id”)而不是清空整个Mapper的缓存。更好的集成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二级缓存请务必做好以下监控监控缓存命中率在自定义的Cache实现中加入计数逻辑统计getObject的调用次数和命中次数。过低的命中率如低于70%意味着缓存策略可能有问题或者数据变化太频繁不适合缓存。监控缓存大小如果是本地缓存定期通过JMX或监控工具查看缓存占用的堆内存大小防止内存泄漏或OOM。如果是Redis监控其内存使用量。建立缓存的降级开关在应用配置中心如Nacos、Apollo配置一个开关可以在出现缓存问题如数据大面积不一致时一键关闭所有缓存让系统回退到直接访问数据库的状态。这是一个非常重要的运维保障手段。关键业务的数据一致性校验对于特别重要的数据可以定期运行一个离线任务对比缓存中的数据与数据库中最新的数据并报告差异。这能帮你提前发现缓存同步机制的问题。开启MyBatis-Plus二级缓存就像给数据库查询加装了一个涡轮增压器能在特定路况下爆发出强劲动力。但它也需要更精密的调校和更谨慎的驾驶习惯。理解其内部机制预见其潜在问题并准备好应对方案你才能稳稳地享受它带来的性能红利而不是被它拖入故障的泥潭。在架构选型上多想一想“我们真的需要这个级别的缓存吗”、“有没有更简单可控的方案”往往比盲目追求技术特性更为重要。
返回列表