1. 从一次线上故障说起缓存优化到底解的是什么问题半年前我们团队接手了一个订单查询系统的性能治理现象很典型数据库CPU持续高位高峰期查询接口的平均响应时间在800ms以上部分复杂报表查询直接能把连接池打满。当时的第一反应是加Redis但加完Redis之后发现改动量巨大而且跟业务代码的耦合很重很多查询根本没法用简单的key-value缓存表达。后来仔细梳理之后发现真正的问题出在MyBatis缓存的使用方式上——我们既没有搞清楚一级缓存和二级缓存的边界也没有对大量重复查询做任何拦截导致一堆可避免的数据库压力全部落在了MySQL上。所谓MyBatis缓存优化本质上不是在讲怎么把缓存开起来那么简单而是要在搞清楚缓存机制的前提下找到什么数据适合缓存、什么数据绝对不能缓存、缓存失效了怎么办这一整条链路的答案。这篇文章我结合半年多的实践把我们从问题定位、源码分析、缓存策略设计到最终落地调优的完整过程记录下来希望对正在做Java后端性能治理的同学有参考价值。先说结论MyBatis自带的一级缓存和二级缓存并不适合所有业务场景盲目开启二级缓存甚至可能带来比不缓存更严重的坑。真正有效的优化往往依赖于对缓存机制的深度理解以及在此之上的自定义缓存方案。顺便说一下这个项目的技术栈就是很常见的Spring Boot MyBatis MySQLJDK版本是17MyBatis用的是mybatis-spring-boot-starter 3.0版本底层对应MyBatis 3.5.x。这个组合在中小团队里相当普遍所以踩的坑和整理出来的方案都有一定的普适性。2. 从SqlSession到MapperMyBatis缓存设计的两级结构在说优化之前必须先把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 evictionLRU flushInterval60000 size512 readOnlytrue/刚才参数解释一下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里被更新了但由于查询入口是OrderMapperOrderMapper namespace下的二级缓存根本不知道商品数据发生了变化依然返回旧数据而且6个小时内都不会过期。这就是MyBatis二级缓存最出名的一个限制它只感知当前namespace下的增删改操作。只要涉及多表查询一个Mapper缓存的数据就可能被另一个Mapper的更新弄脏。源码里缓存失效的逻辑是通过CacheKey和flushCache机制实现的每个增删改语句执行时会清空当前namespace的缓存但跨namespace的级联失效没有任何机制。我们的解决方案很简单粗暴所有涉及多表关联的查询Mapper一律关闭二级缓存只有单表单查询且数据变更频率低的场景才启用。这个决策牺牲了一点查询性能但换来了数据一致性。3.2 序列化和反序列化的性能黑洞另一个坑是关于readOnly属性的。某个基础数据字典表数据量不大但是查询极其频繁我们开启了二级缓存当时为了安全起见把readOnly设成了false。MyBatis会通过Java序列化对对象做深拷贝每次从缓存取数据都要进行完整序列化反序列化。问题来了这个字典对象的层级很深里面嵌套了枚举、List、Map每次反序列化的开销比去MySQL查一次还要大。用JFR抓了一下发现缓存命中时方法耗时不仅没有下降反而上升了接近30%。后来把这个字典对象改成了readOnlytrue通过禁止修改返回对象的方式来避免拷贝开销性能才恢复正常。这里面有个经验如果确定返回对象不会被业务代码修改用readOnlytrue如果不能确定优先考虑用自己的深拷贝方案别用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((RedisCallbackObject) 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((RedisCallbackbyte[]) connection - connection.get(rawKey)); if (rawValue null) return null; return deserialize(rawValue); } Override public void clear() { Setbyte[] keys RedisTemplateHolder.getRedisTemplate().execute((RedisCallbackSetbyte[]) connection - { try (var cursor connection.scan(ScanOptions.scanOptions().match(new String(redisKeyPrefix) *).count(1000).build())) { Setbyte[] result new HashSet(); cursor.forEachRemaining(result::add); return result; } }); if (keys ! null !keys.isEmpty()) { RedisTemplateHolder.getRedisTemplate().execute((RedisCallbackObject) 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执行结果的一个快照存储设计得当它就是性能利器设计不当它就是一致性隐患。把底层机制吃透结合自身业务特性做取舍比盲目追逐各种缓存中间件要实在得多。
