news 2026/9/26 19:38:48

从MyBatis缓存到Redis二级缓存:数据库性能优化实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从MyBatis缓存到Redis二级缓存:数据库性能优化实践

1. 从一次线上故障说起:缓存优化到底解的是什么问题

半年前我们团队接手了一个订单查询系统的性能治理,现象很典型:数据库CPU持续高位,高峰期查询接口的平均响应时间在800ms以上,部分复杂报表查询直接能把连接池打满。当时的第一反应是"加Redis",但加完Redis之后发现改动量巨大,而且跟业务代码的耦合很重,很多查询根本没法用简单的key-value缓存表达。后来仔细梳理之后发现,真正的问题出在MyBatis缓存的使用方式上——我们既没有搞清楚一级缓存和二级缓存的边界,也没有对大量重复查询做任何拦截,导致一堆可避免的数据库压力全部落在了MySQL上。

所谓MyBatis缓存优化,本质上不是在讲怎么把缓存开起来那么简单,而是要在搞清楚缓存机制的前提下,找到"什么数据适合缓存、什么数据绝对不能缓存、缓存失效了怎么办"这一整条链路的答案。这篇文章我结合半年多的实践,把我们从问题定位、源码分析、缓存策略设计到最终落地调优的完整过程记录下来,希望对正在做Java后端性能治理的同学有参考价值。

先说结论:MyBatis自带的一级缓存和二级缓存并不适合所有业务场景,盲目开启二级缓存甚至可能带来比不缓存更严重的坑。真正有效的优化往往依赖于对缓存机制的深度理解,以及在此之上的自定义缓存方案。

顺便说一下,这个项目的技术栈就是很常见的Spring Boot + MyBatis + MySQL,JDK版本是17,MyBatis用的是mybatis-spring-boot-starter 3.0版本,底层对应MyBatis 3.5.x。这个组合在中小团队里相当普遍,所以踩的坑和整理出来的方案都有一定的普适性。

2. 从SqlSession到Mapper:MyBatis缓存设计的两级结构

在说优化之前,必须先把MyBatis缓存的基本结构梳理一遍。这不是为了背八股文,而是因为后面优化时踩的坑几乎全部源于对这两级缓存边界理解不透。

2.1 一级缓存:SqlSession级别的本地缓存

一级缓存是MyBatis默认开启的,它的作用域是一个SqlSession实例。你在同一个SqlSession里连续执行两次完全相同的查询,第二次不会真的去查数据库,而是直接返回缓存结果。

这里有个很多人都忽略的细节:一级缓存的key不是简单的SQL字符串,而是由statementId、SQL语句、参数值、参数类型、RowBounds等共同构成的一个CacheKey对象。也就是说,即使SQL文本一样,只要参数不同,缓存也不会命中。这是MyBatis在设计层面的一个取舍——用完整的查询条件作为key的粒度,避免数据错乱。

但一级缓存的局限性也很明显:在Spring集成环境下,SqlSession是每次请求通过SqlSessionTemplate动态生成的,一个事务一个SqlSession,事务结束SqlSession就关闭了,一级缓存就没了。所以大多数基于Spring的Web应用中,一级缓存的实际命中率非常低,它存在的意义更多是在一个事务内避免重复查询同一行数据。

2.2 二级缓存:namespace级别的共享缓存

二级缓存是跨SqlSession的,它以Mapper的namespace为单位,同一个Mapper下的所有查询结果都会放到一个缓存区域中。开启方式也简单,在Mapper.xml里加一行:

<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>

刚才参数解释一下:eviction是淘汰策略,MyBatis内置了LRU、FIFO、SOFT、WEAK这几种;flushInterval是刷新间隔,单位是毫秒;size是缓存条目的最大数量;readOnly为true时返回的是缓存对象的同一引用,为false时会做序列化拷贝。

从配置上看一切都很简单,但问题恰恰出在这个"看起来很简单"上。

2.3 两级缓存的执行顺序和写入时机

MyBatis查询缓存的执行顺序是:二级缓存 -> 一级缓存 -> 数据库。也就是说,每次查询会先查二级缓存(如果有),未命中再去查一级缓存,再未命中才走数据库。查询结果会先写入一级缓存,当SqlSession提交或关闭时,一级缓存中的数据会被写入二级缓存。

这里有一个关键点:只有执行了commit,一级缓存才会把数据同步到二级缓存。如果你在一个未提交事务中查询了数据,然后该事务回滚了,这部分数据是不会出现在二级缓存里的,这个设计是为了保证缓存的原子性。

理解了这两级结构,再去看网上那些"为什么MyBatis二级缓存不生效"的问题,基本都能迎刃而解:要么是namespace配置问题,要么是查询没有被包在显式事务里,要么是缓存的POJO没有序列化。

3. 项目中的缓存失效现场:我们踩过的三个大坑

单纯讲配置和原理有点纸上谈兵,真正让我决定深入做缓存优化的,是那段时间线上连续出现的数据错乱和性能毛刺。在这一个一个复现和排查的过程中,也逐渐摸清了MyBatis缓存最脆弱的几个环节。

3.1 多表关联查询下的缓存脏读问题

我们有一个订单列表查询,SQL里关联了订单表、订单明细表、商品表和商户表,Mapper写在OrderMapper.xml里。上线时为了图省事,直接在OrderMapper上开启了二级缓存。结果就出现了一个经典的脏读场景:商品名称在ProductMapper里被更新了,但由于查询入口是OrderMapper,OrderMapper namespace下的二级缓存根本不知道商品数据发生了变化,依然返回旧数据,而且6个小时内都不会过期。

这就是MyBatis二级缓存最出名的一个限制:它只感知当前namespace下的增删改操作。只要涉及多表查询,一个Mapper缓存的数据就可能被另一个Mapper的更新弄脏。源码里缓存失效的逻辑是通过CacheKey和flushCache机制实现的,每个增删改语句执行时会清空当前namespace的缓存,但跨namespace的级联失效没有任何机制。

我们的解决方案很简单粗暴:所有涉及多表关联的查询Mapper,一律关闭二级缓存,只有单表单查询且数据变更频率低的场景才启用。这个决策牺牲了一点查询性能,但换来了数据一致性。

3.2 序列化和反序列化的性能黑洞

另一个坑是关于readOnly属性的。某个基础数据字典表,数据量不大但是查询极其频繁,我们开启了二级缓存,当时为了安全起见把readOnly设成了false。MyBatis会通过Java序列化对对象做深拷贝,每次从缓存取数据都要进行完整序列化+反序列化。

问题来了:这个字典对象的层级很深,里面嵌套了枚举、List、Map,每次反序列化的开销比去MySQL查一次还要大。用JFR抓了一下,发现缓存命中时方法耗时不仅没有下降,反而上升了接近30%。后来把这个字典对象改成了readOnly=true,通过禁止修改返回对象的方式来避免拷贝开销,性能才恢复正常。

这里面有个经验:如果确定返回对象不会被业务代码修改,用readOnly=true;如果不能确定,优先考虑用自己的深拷贝方案,别用Java原生的序列化。

3.3 分布式多节点下的缓存一致性问题

我们的应用是双节点部署,Nginx轮询。一开始本地测试怎么都正常,但线上就偶尔会出现某个节点查到的是旧数据。定位了好久,才发现是二级缓存是本地JVM内存,两个节点各自维护各自的缓存,没有任何同步机制。

这个问题本质上不是MyBatis的设计缺陷——二级缓存本来就是为单机应用设计的。但放在微服务架构和水平扩展的背景下,如果直接依赖它,就会面临缓存不一致、数据滞后等一系列问题。

这一步让我们彻底认识到:MyBatis自带的本地二级缓存只能作为单机场景的优化手段,真正要做分布式缓存,必须把缓存实现替换为Redis这类集中式存储。

4. 基于Redis的二级缓存改造:架构设计与落地过程

既然本地缓存靠不住,我们就着手将二级缓存实现替换为Redis。这一步如果做好,用户无感知,扩展能力直接上一个台阶。

4.1 为什么最终选择了替换Cache接口而非引入独立缓存层

在设计方案时,我们其实考虑过两条路:一是在Service层引入独立的Redis缓存做业务级缓存;二是基于MyBatis的Cache接口,将二级缓存落地到Redis。

第一条路表面看更好控制,但有个问题:我们系统里查询逻辑几乎都堆在Mapper SQL上,很多是复杂的动态SQL,在Service层做缓存需要针对每个查询方法写缓存逻辑,工作量巨大。而MyBatis的Cache接口实际上是一个很轻的SPI,实现自定义Cache并配置到Mapper上,所有的查询和更新会自动经过缓存逻辑,对业务代码完全透明。

4.2 实现一个基于Redis的Cache接口

MyBatis的Cache接口一共就几个方法:

public interface Cache { String getId(); void putObject(Object key, Object value); Object getObject(Object key); Object removeObject(Object key); void clear(); int getSize(); ReadWriteLock getReadWriteLock(); }

我们实现了一个RedisCache,核心思路是:所有key统一拼接业务前缀和namespace标识,value用fastjson2序列化为字符串存储,key过期时间做了可配置化。

public class RedisCache implements Cache { private final String id; private byte[] redisKeyPrefix; private long expireSeconds = 3600L; public RedisCache(String id) { this.id = id; this.redisKeyPrefix = ("mybatis:cache:" + id + ":").getBytes(StandardCharsets.UTF_8); } @Override public void putObject(Object key, Object value) { try { byte[] rawKey = mergeKey(key); byte[] rawValue = serialize(value); RedisTemplateHolder.getRedisTemplate().execute((RedisCallback<Object>) connection -> { connection.setEx(rawKey, expireSeconds, rawValue); return null; }); } catch (Exception e) { // 缓存失败不能影响主流程 log.error("RedisCache put error, key={}", key, e); } } @Override public Object getObject(Object key) { byte[] rawKey = mergeKey(key); byte[] rawValue = RedisTemplateHolder.getRedisTemplate().execute((RedisCallback<byte[]>) connection -> connection.get(rawKey)); if (rawValue == null) return null; return deserialize(rawValue); } @Override public void clear() { Set<byte[]> keys = RedisTemplateHolder.getRedisTemplate().execute((RedisCallback<Set<byte[]>>) connection -> { try (var cursor = connection.scan(ScanOptions.scanOptions().match(new String(redisKeyPrefix) + "*").count(1000).build())) { Set<byte[]> result = new HashSet<>(); cursor.forEachRemaining(result::add); return result; } }); if (keys != null && !keys.isEmpty()) { RedisTemplateHolder.getRedisTemplate().execute((RedisCallback<Object>) connection -> { connection.del(keys.toArray(new byte[0][])); return null; }); } } private byte[] mergeKey(Object key) { // 将MyBatis的CacheKey拼接到Redis key中 ByteArrayOutputStream bos = new ByteArrayOutputStream(); bos.write(redisKeyPrefix, 0, redisKeyPrefix.length); if (key instanceof CacheKey) { CacheKey cacheKey = (CacheKey) key; bos.write(cacheKey.toString().getBytes(StandardCharsets.UTF_8), 0, cacheKey.toString().getBytes(StandardCharsets.UTF_8).length); } else { bos.write(key.toString().getBytes(StandardCharsets.UTF_8), 0, key.toString().getBytes(StandardCharsets.UTF_8).length); } return bos.toByteArray(); } }

这里有几个设计要点:

  • RedisTemplate不能直接注入到Cache实现类里,因为Cache实例是MyBatis在启动时创建的,还没经过Spring的依赖注入。我们用一个静态Holder来持有RedisTemplate,在Spring容器初始化完成后赋值。
  • 所有缓存操作必须catch异常,Redis抖动不能拖垮数据库查询主链路。
  • 序列化方案选型上,fastjson2的性能在Java生态里属于第一梯队,而且对泛型嵌套的支持也比原生的Java序列化好得多。

4.3 Mapper中只对合适的查询开启二级缓存

改造完缓存实现之后,并不是所有Mapper都适合开启二级缓存。我们梳理了几个原则:

  • 纯单表查询且数据更新频率低:比如数据字典、系统配置、行政区划这类,可以开。
  • 多表关联查询:不开,宁可让MySQL抗住,也不冒脏读的风险。
  • 高频写入的表对应的查询:不开,因为每次写入都要清缓存,开了等于白开,还会增加Redis读写开销。
  • 数据量大且查询条件极多:建议用业务缓存,而不是MyBatis这种基于对象粒度的缓存。

具体到我们的系统,最终只有三个基础信息Mapper开启了Redis二级缓存,其他的保持默认关闭。

5. 优化后的性能对比:数据不会说谎

改造完成之后,我们对三个核心场景做了一轮压测和线上数据对比。这里列一组有代表性的数据。

场景优化前P99耗时优化后P99耗时数据库QPS下降比例
订单查询(未开缓存)820ms780ms(基本无变化)0%
数据字典查询(Redis缓存)45ms6ms78%
商品基础信息查询(Redis缓存)120ms12ms85%

订单查询我们没做缓存,因为它的表关系链路太复杂,一致性要求也高。但对字典类和基础信息类查询,效果立竿见影。

值得注意的一个细节:数据库QPS下降并不等于接口RT一定下降。因为接口RT还包括网络、业务逻辑、序列化等开销,如果本来查询就是1ms,缓存带来的收益就很有限。缓存的价值主要体现在两个场景:一是数据量大的复杂查询,二是高频重复查询。

另外,通过Redis二级缓存,我们还获得了一个额外收益:应用重启之后缓存依然是热的。之前用本地缓存,每次发版都要经历一轮缓存预热和数据库压力高峰,现在完全不存在这个问题了。

6. 缓存设计之外的思考:线程池阻塞和连接池耗尽问题

在优化过程中,我们还发现了一个和缓存关联不大但影响很大的问题:如果Redis操作写得不好,比如同步调用超时时间设置过长,就会在缓存不可用时把线程池全部拖住,导致应用程序整体雪崩。

我们用了一个很简单的方案来规避:在RedisCache的getObject和putObject外面套了一层快速失败的保护机制,Redis命令的超时时间设置为50ms,并且用Semaphore限流。

private static final Semaphore REDIS_CACHE_SEMAPHORE = new Semaphore(32); @Override public Object getObject(Object key) { if (!REDIS_CACHE_SEMAPHORE.tryAcquire()) { return null; // 信号量没拿到,直接走数据库 } try { // ...原有Redis逻辑 } finally { REDIS_CACHE_SEMAPHORE.release(); } }

这么做的逻辑是:缓存是可选的加速层,绝不允许它变成系统的单点瓶颈。缓存挂了或者变慢了,最坏的结果是数据库压力回来,但系统不能挂。

如果你想在项目里直接参考这套设计,我建议把超时、信号量这些参数都做成可配置的,方便在不同环境里调优。

7. 是时候重新审视MyBatis的一级缓存了

最后说一个容易被人忽视但很重要的话题:一级缓存的利用。

我们经常说一级缓存命中率低,但在某些场景下一级缓存其实能发挥很大作用。比如在一个事务里,你需要先查订单判断状态,再根据订单状态查关联数据,同一个订单行会被访问多次。这时候一级缓存能省掉好几次重复查询。

但一级缓存也有一个坑需要注意:在Spring事务中,如果只在事务里做查询不做更新,一级缓存可能会持有旧数据,导致同一事务中对同一记录的多次查询结果不一致。比如先查了一个订单状态是已支付,然后另一个线程更新了订单状态,同一事务内再次查询还是已支付。这在某些强一致性要求的场景下是个隐患。

我们项目里没有特别去干预一级缓存,因为它默认就是开启的,而且事务边界比较清晰。但如果你遇到"同一个事务内查询结果不变"的诡异问题,可以往这个方向排查一下。

8. 小结性的经验清单

很多人对MyBatis缓存的认知停留在"有一级二级缓存,默认开启一级,二级要手动配置"这个层面。但这半年的实践让我深刻体会到,真正的缓存优化工作,在搞清楚机制之后才刚开始。

给你一份我们沉淀下来的检查清单,照着逐项排查,基本能覆盖大多数MyBatis缓存相关的性能问题:

  • 先确认哪些Mapper查询是真正的高频重复查询,用慢SQL日志和监控数据说话,不要凭感觉。
  • 多表关联查询的Mapper不要开二级缓存,这是底线。
  • 缓存POJO的序列化方案要提前设计,DNF对象不要交给Java原生序列化。
  • 分布式环境下不要用本地缓存,直接替换Cache实现为Redis。
  • 缓存访问必须有超时和降级机制,绝不能反向拖垮主链路。
  • 开启二级缓存之后,每个执行增删改的语句都会flush缓存,要评估flush的频率是否值得缓存。

我在实际项目里的体会是:不要神化缓存,也不要妖魔化缓存。MyBatis缓存机制本质上是SQL执行结果的一个快照存储,设计得当它就是性能利器,设计不当它就是一致性隐患。把底层机制吃透,结合自身业务特性做取舍,比盲目追逐各种缓存中间件要实在得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 19:37:12

实战 ToonCrafter:3 步把两张卡通静帧变成 16 帧流畅动画

实战 ToonCrafter&#xff1a;3 步把两张卡通静帧变成 16 帧流畅动画 【免费下载链接】ToonCrafter [SIGGRAPH Asia 2024, Journal Track] ToonCrafter: Generative Cartoon Interpolation 项目地址: https://gitcode.com/GitHub_Trending/to/ToonCrafter ToonCrafter 是…

作者头像 李华
网站建设 2026/9/26 19:35:21

从AI助手到Agent操作系统:WorkBuddy的工程化实践与落地指南

我最早把 WorkBuddy 当 AI 助手用的时候&#xff0c;它在我眼里就是一个能聊天、能写代码、能整理资料的聊天框&#xff1b;半年后再回头看&#xff0c;我发现它已经变成了我工作环境里最接近“Agent 操作系统”的东西。不是概念包装&#xff0c;而是任务调度、工具调用、上下文…

作者头像 李华
网站建设 2026/9/26 19:34:35

多相机同步精度怎么测:从曝光时刻到抖动统计的工程方法

多相机系统的同步精度&#xff0c;指的是同一个物理事件在各路数据中出现时刻之间的差值。工程上真正被测的东西经常被搞错&#xff1a;很多人测的是"数据到达主机的时刻差"&#xff0c;而不是"曝光发生的时刻差"。前者包含读出、封装、传输与驱动排队&…

作者头像 李华
网站建设 2026/9/26 19:33:26

C语言指针入门:字符传送核心原理与字符串操作实战

很多人学《C语言程序设计》第四版何钦铭、颜晖版的时候&#xff0c;对第八章指针最深的印象就是一个字&#xff1a;绕。尤其“字符传送”这一节&#xff0c;教材里讲的是用指针去处理字符串复制、传递&#xff0c;从char *p到p[i]再到*p&#xff0c;代码很短&#xff0c;概念很…

作者头像 李华
网站建设 2026/9/26 19:27:15

RabbitMQ TTL+死信队列实现延迟队列的实战指南

做后端这几年&#xff0c;凡是跟订单超时、支付回调、定时提醒沾边的需求&#xff0c;几乎都会遇到同一个问题&#xff1a;怎么让一条消息在N秒之后才被消费&#xff1f;轮询数据库最笨&#xff0c;装个延时线程池又不敢停机&#xff0c;聊到最后大家几乎都会落到同一个方案上—…

作者头像 李华