☰
Mybatis二级缓存源码深度解析:CacheKey、CachingExecutor与线程安全机制
2026/10/14 17:37:33 网站建设 项目流程

1. 项目概述:为什么一个“缓存开关”值得花三天读源码?

Mybatis二级缓存,听起来像一个配置文件里加个<cache/>标签就能跑起来的“默认功能”。但我在某跨平台系统重构中连续踩了三次坑:第一次是缓存没生效,查了半天发现命名空间写错了;第二次是缓存数据错乱,用户A看到用户B刚提交的订单;第三次更隐蔽——缓存穿透导致数据库连接池被打满,凌晨三点被告警电话叫醒。这让我彻底意识到:二级缓存不是“开/关”按钮,而是一套需要精确理解其生命周期、作用域边界和线程安全机制的内存协同系统。它涉及SqlSession、Executor、CacheKey、装饰器链、序列化协议、失效策略等至少7个核心组件的联动,任何一个环节理解偏差,都会在高并发场景下放大成雪崩式故障。

这个分析不讲“怎么用”,而是带你钻进Mybatis 3.4.6(当前稳定生产版主流基线)的源码深处,看清楚cache=true背后到底发生了什么。你会看到:

  • select语句执行时,CacheKey是如何由MappedStatement ID + SQL + 参数三者哈希生成的;
  • 为什么update操作会清空整个namespace下的缓存,而不是只清对应key;
  • PerpetualCache和SynchronizedCache之间那层薄薄的synchronized,在QPS 2000+时如何成为性能瓶颈;
  • SerializedCache序列化过程里,ObjectOutputStream写入字节数组时,为什么必须要求实体类实现Serializable——不是为了“能存”,而是为了防止反序列化时因JVM类加载器差异导致ClassCastException。

适合谁看?如果你正在排查缓存命中率低、脏读、OOM或GC频繁问题;如果你要基于Mybatis二次封装统一缓存治理能力;或者你只是想搞懂ORM框架里“缓存一致性”这个经典难题在现实工程中是怎么妥协落地的——这篇就是为你写的。它不假设你熟悉Spring AOP或CGLIB,所有关键路径都从SqlSession.selectOne()第一行代码开始推演。


2. 整体设计与思路拆解:缓存不是插件,而是执行器的“影子”

2.1 二级缓存的本质:Executor的装饰器模式

很多人误以为二级缓存是Mybatis的“独立模块”,其实它根本不存在于Configuration顶层结构中,而是深度耦合在Executor执行链路里的装饰器(Decorator)。打开org.apache.ibatis.session.Configuration源码,你会发现cacheEnabled只是一个全局开关,真正起作用的是new Executor()时传入的Cache实例:

// Configuration.java 第758行 public Executor newExecutor(Transaction transaction, ExecutorType executorType) { executorType = executorType == null ? defaultExecutorType : executorType; executorType = executorType == null ? ExecutorType.SIMPLE : executorType; Executor executor; if (ExecutorType.BATCH == executorType) { executor = new BatchExecutor(this, transaction); } else if (ExecutorType.REUSE == executorType) { executor = new ReuseExecutor(this, transaction); } else { executor = new SimpleExecutor(this, transaction); } // 关键:这里把缓存包装进Executor if (cacheEnabled) { executor = new CachingExecutor(executor); } // ... 其他装饰器(如插件拦截器) return executor; }

提示:CachingExecutor不是继承关系,而是组合关系。它持有一个底层Executor(Simple/Reuse/Batch)和一个Cache对象,所有查询先走缓存,缓存未命中才委托给底层Executor执行SQL并回填缓存。

这种设计决定了二级缓存的作用域天然绑定到Mapper接口的namespace。因为Cache实例是在MapperBuilderAssistant.useNewCache()中为每个<mapper>创建的,并通过MapperRegistry注册到Configuration中。当你调用userMapper.selectById(1),Mybatis会根据UserMapper.class.getName()找到对应的Cache实例——这就是为什么不同Mapper的缓存互不影响,而同一Mapper内所有方法共享缓存空间。

2.2 为什么不用一级缓存替代二级缓存?

一级缓存(Local Cache)作用域是SqlSession级别,生命周期随SqlSession创建/关闭而生灭。它解决的是单次请求内重复SQL去重问题,比如在同一个Service方法里两次调用userMapper.selectById(1),第二次直接从PerpetualCache(一级缓存底层实现)返回结果,不走DB。

但二级缓存要解决的是跨SqlSession、跨线程的数据复用。想象一个电商详情页:用户A用SqlSession1查商品ID=1001,缓存写入;用户B用SqlSession2再查同一商品,如果没二级缓存,就得重新查库。而二级缓存让这两个独立的SqlSession能共享同一份数据副本。

注意:一级缓存默认开启且无法关闭;二级缓存默认关闭,必须显式配置。这是Mybatis对一致性的保守设计——一级缓存风险可控(仅限单次请求),二级缓存则需开发者承担跨会话一致性责任。

2.3 缓存失效策略的设计哲学:粗粒度清理 vs 精确驱逐

Mybatis二级缓存没有LRU、LFU等复杂淘汰算法,它的失效策略极其简单:任何对某个namespace的增删改操作(insert/update/delete),都会清空该namespace下全部缓存项。

看CachingExecutor.update()源码:

// CachingExecutor.java 第152行 public int update(MappedStatement ms, Object parameterObject) throws SQLException { flushCacheIfRequired(ms); // 关键:强制刷新缓存 return delegate.update(ms, parameterObject); } private void flushCacheIfRequired(MappedStatement ms) { Cache cache = ms.getCache(); if (cache != null && ms.isFlushCacheRequired()) { // insert/update/delete默认返回true tcm.clear(cache); // 清空整个Cache实例 } }

这种“全量清空”看似粗暴,实则是权衡结果:

  • 优势:实现极简,无状态,避免因缓存key生成逻辑复杂导致的漏删(比如动态SQL中<if>条件变化导致key不同,但数据实际关联);
  • 代价:缓存命中率下降,尤其在读多写少但写操作频繁的场景(如用户积分实时更新)。

我曾在一个积分系统中观察到:每秒30次update user_score导致userMapper缓存平均每2秒就被清空一次,有效缓存时间趋近于零。最终方案是绕过Mybatis二级缓存,改用Redis做业务层缓存,将score字段单独缓存,更新时精准DEL对应key——这恰恰印证了Mybatis二级缓存的定位:它不是通用缓存解决方案,而是为简单CRUD场景设计的轻量级加速器。

2.4 序列化机制的隐藏陷阱:不只是Serializable接口

当启用二级缓存时,Mybatis默认使用SerializedCache包装原始Cache,这意味着所有存入缓存的对象必须可序列化。但问题远不止implements Serializable这么简单:

// SerializedCache.java 第59行 public void putObject(Object key, Object object) { if (object == null || object instanceof Serializable) { cache.putObject(key, serialize(object)); // 关键:serialize()方法 } else { throw new CacheException("SharedCache failed to serialize object. " + "Object must implement Serializable interface."); } }

serialize()方法内部使用ObjectOutputStream将对象转为字节数组。这里埋着两个深坑:

  1. 静态字段丢失:序列化只保存对象实例状态,static修饰的字段不会被写入,反序列化后为初始值(如null或0);
  2. transient字段跳过:被transient修饰的字段同样不参与序列化,若业务逻辑依赖这些字段,反序列化后会出现NPE。

我在某报表系统中遇到过典型问题:实体类有个transient List<Detail> details用于临时组装,但缓存后details始终为null。解决方案不是去掉transient(可能破坏原有设计),而是重写writeObject/readObject方法,手动控制序列化行为:

private void writeObject(ObjectOutputStream out) throws IOException { out.defaultWriteObject(); // 序列化非transient字段 out.writeInt(details.size()); // 手动序列化details大小 for (Detail d : details) { out.writeObject(d); } } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); // 反序列化非transient字段 int size = in.readInt(); details = new ArrayList<>(size); for (int i = 0; i < size; i++) { details.add((Detail) in.readObject()); } }

这说明:二级缓存的序列化不是透明的,它强制要求你以序列化友好方式设计实体类。这也是为什么很多团队在启用二级缓存前,会先做一轮Serializable兼容性扫描。


3. 核心细节解析与实操要点:从CacheKey生成到缓存穿透防护

3.1 CacheKey:缓存命中的唯一身份证,比SQL本身更关键

缓存是否命中的判断依据不是SQL字符串,而是CacheKey对象。它的生成逻辑藏在BaseExecutor.createCacheKey()中,由4个核心要素哈希计算得出:

要素来源说明
mappedStatementIdMappedStatement.getId()如com.example.mapper.UserMapper.selectById,确保不同Mapper隔离
sqlBoundSql.getSql()预编译后的SQL,已替换#{}为?,但保留<if>等动态标签结果
parameterObject方法传入参数若为POJO,取其toString();若为Map,取所有entry的key-value拼接
rowBoundsRowBounds.offset+RowBounds.limit分页参数,影响结果集范围

看一个真实案例:

<select id="selectByStatus" resultType="User"> SELECT * FROM user WHERE status = #{status} <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> </select>

当调用selectByStatus(status=1, name="zhang")时,CacheKey包含:

  • mappedStatementId:com.example.mapper.UserMapper.selectByStatus
  • sql:SELECT * FROM user WHERE status = ? AND name LIKE CONCAT('%', ?, '%')
  • parameterObject:"status=1&name=zhang"(Map.toString()结果)
  • rowBounds:offset=0, limit=10

注意:CacheKey的hashCode()和equals()被重写,比较的是这4个要素的组合哈希值,而非对象地址。因此即使两次调用创建不同的CacheKey实例,只要4要素相同,就视为同一缓存项。

实操心得:动态SQL中<where>、<set>等标签的空格处理会影响sql字符串内容,进而导致缓存key不同。例如<where>标签自动去除前置AND,但如果手写AND未被正确包裹,会导致SQL字符串差异。建议用Mybatis日志开启TRACE级别,观察CacheKey的toString()输出,确认key生成是否符合预期。

3.2 缓存装饰器链:SynchronizedCache为何是性能瓶颈?

Mybatis的缓存实现采用装饰器模式,一层层包装增强功能。典型链路如下(以PerpetualCache为底):

PerpetualCache → LoggingCache(记录命中/未命中日志) → SerializedCache(序列化支持) → SynchronizedCache(线程安全) → BlockingCache(阻塞式获取,防缓存击穿)

其中SynchronizedCache是最常被忽略的性能点。它的getObject()方法是synchronized块:

// SynchronizedCache.java 第52行 public Object getObject(Object key) { synchronized (lock) { // 锁住整个Cache实例! return delegate.getObject(key); } }

这意味着:同一namespace下所有缓存读操作,无论key是否相同,都会串行执行。在QPS 1000+的场景下,这个锁会成为明显瓶颈。我们曾用Arthas监控到SynchronizedCache.getObject方法平均耗时从0.2ms飙升至15ms,CPU等待线程数超200。

解决方案有二:

  • 降级:若业务能接受短暂不一致,可禁用SynchronizedCache,改用ScheduledCache(带定时刷新)或自定义无锁缓存;
  • 升级:用BlockingCache替代,它在缓存未命中时阻塞其他线程,直到第一个线程加载完成并写入缓存,避免重复DB查询——这本质是“缓存击穿”防护,但会增加首次访问延迟。

提示:BlockingCache的阻塞锁是按key粒度的(ReentrantLock数组),比SynchronizedCache的全局锁精细得多。启用方式是在<cache>标签中添加blocking="true"属性。

3.3 缓存穿透防护:Mybatis原生不支持,必须自己补

缓存穿透指查询一个数据库中根本不存在的数据(如id=-1、随机字符串),导致每次请求都穿透到DB。Mybatis二级缓存对此完全无感——它只管“有key就返回,没key就查库”,不会区分这个key对应的数据是否存在。

后果很严重:恶意请求构造大量不存在id,DB压力陡增。我们在压测中模拟1000QPS查询user?id=uuid(),MySQL CPU瞬间拉满。

标准防护方案是“布隆过滤器(Bloom Filter)”:

  1. 在应用启动时,将所有合法user_id加载到布隆过滤器(内存或Redis);
  2. 查询前先bloom.contains(id),若返回false,直接返回空,不走Mybatis;
  3. 若返回true,再执行userMapper.selectById(id),并按需缓存结果。

布隆过滤器有误判率(约1%),但绝不会漏判。这意味着:

  • 不存在的id:99%概率被拦截,1%概率穿透到DB(可接受);
  • 存在的id:100%放行,不影响正常流程。

实现上,可用Google Guava的BloomFilter,或Redis的bf.add/bf.exists命令(RedisBloom模块)。关键是要保证布隆过滤器与DB数据强一致——我们采用监听binlog的方式,当user表有INSERT/UPDATE时,异步更新布隆过滤器。

3.4 缓存雪崩应对:TTL不是万能解药

缓存雪崩指大量缓存key在同一时间过期,导致瞬时流量全部打到DB。Mybatis二级缓存本身不支持TTL(Time-To-Live),但可通过eviction策略间接实现。例如使用FifoCache(先进先出)配合定时任务清理:

<cache eviction="FIFO" flushInterval="60000" <!-- 每60秒强制清空一次 --> size="1000" />

但flushInterval是“强制刷新”,不是“自然过期”,它会清空所有缓存,无论是否过期。更合理的做法是在业务层控制:

  • 对热点数据(如首页banner),设置随机TTL(如300±60秒),避免集中过期;
  • 对冷数据(如历史订单),用LruCache策略,容量满时自动淘汰最近最少用的项;
  • 对关键数据(如用户权限),禁用缓存或设极长TTL(如24小时),用消息队列监听变更事件主动刷新。

我们在用户中心服务中采用混合策略:基础信息(用户名、头像)用Redis TTL 2小时;权限数据用本地Caffeine缓存+MQ监听,变更时invalidate对应key——这样既避免雪崩,又保证最终一致性。


4. 实操过程与核心环节实现:手把手调试CacheKey与CachingExecutor

4.1 调试环境搭建:让Mybatis源码“开口说话”

要真正理解二级缓存,必须能断点跟踪CachingExecutor的执行流。以下是经过验证的调试配置(IntelliJ IDEA):

  1. 引入Mybatis源码:

    • 下载Mybatis 3.4.6源码包( mybatis-3.4.6.zip );
    • 解压后用IDEA打开,File → Project Structure → Modules → + → Import Module,选择pom.xml;
    • 编译成功后,在你的项目pom.xml中排除原生依赖,改为<scope>provided</scope>引用本地模块。
  2. 开启Mybatis日志:
    在application.yml中添加:

    logging: level: org.apache.ibatis.cache: DEBUG org.apache.ibatis.executor: TRACE # 关键:看到CacheKey生成细节
  3. 关键断点位置:

    • CachingExecutor.query():入口,看是否走缓存分支;
    • BaseExecutor.createCacheKey():深入看4要素如何组装;
    • PerpetualCache.getObject():确认缓存是否命中;
    • CachingExecutor.flushCacheIfRequired():验证update时是否清空。

实测心得:TRACE日志会打印CacheKey.toString(),格式为CacheKey [updateMsId=xxx, sql=xxx, param=xxx, rowBounds=xxx]。对比两次查询的日志,能快速定位key不一致原因(如参数类型不同导致toString()结果差异)。

4.2 CacheKey生成全过程:从Mapper接口到哈希值

以userMapper.selectById(123L)为例,逐步跟踪createCacheKey():

  1. 获取MappedStatement:
    Configuration.getMappedStatement("com.example.mapper.UserMapper.selectById"),得到MappedStatement对象,其getId()返回完整命名空间。

  2. 获取BoundSql:
    MappedStatement.getBoundSql(parameterObject),parameterObject是Long类型123L。BoundSql.getSql()返回SELECT * FROM user WHERE id = ?。

  3. 生成CacheKey:

    CacheKey cacheKey = new CacheKey(); cacheKey.update(mappedStatement.getId()); // 字符串 cacheKey.update(boundSql.getSql()); // 字符串 cacheKey.update(parameterObject); // Long对象,调用toString() -> "123" cacheKey.update(rowBounds); // RowBounds.DEFAULT -> "offset=0,limit=2147483647"
  4. 计算哈希值:
    CacheKey内部维护List<Object> updateList,每次update()追加元素。hashCode()遍历updateList,对每个元素调用Objects.hashCode(),再累加乘积:

    public int hashCode() { int h = 17; // 初始值 for (Object object : updateList) { h = h * 31 + Objects.hashCode(object); // 经典字符串哈希算法 } return h; }

因此,selectById(123L)和selectById(new Long(123))生成的key完全相同(Long.toString()结果一致),但selectById("123")(String类型)会生成不同key("123".toString()还是"123",但String和Long的hashCode()算法不同)。

注意:Mybatis 3.4+对parameterObject的处理更智能,会尝试BeanWrapper解析POJO,但基本类型仍走toString()。建议统一用DTO包装参数,避免类型混乱。

4.3 CachingExecutor.query()执行流:缓存命中的完整路径

现在看一次成功的缓存命中流程(selectById(123)第二次调用):

// CachingExecutor.java public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException { // 1. 创建CacheKey CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql); // 2. 从Cache中获取结果 Cache cache = ms.getCache(); if (cache != null) { // 3. 检查是否需要刷新缓存(如flushInterval到期) flushCacheIfRequired(ms); // 4. 尝试从缓存获取 @SuppressWarnings("unchecked") List<E> list = (List<E>) tcm.getObject(cache, key); if (list == null) { // 5. 缓存未命中:委托给底层Executor查DB list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler); // 6. 将结果写入缓存 tcm.putObject(cache, key, list); } return list; // 7. 返回缓存结果 } else { // 无缓存,直接查DB return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler); } }

关键点在于tcm.getObject()——TransactionalCacheManager是缓存事务管理器,它确保在SqlSession提交前,缓存修改暂不生效(防脏读)。tcm.getObject()内部会检查TransactionalCache的entriesToAddOnCommit(待提交条目),若key在此集合中,直接返回;否则才查底层Cache。

实操技巧:在CachingExecutor.query()第4步后加断点,观察list是否为null。若为null,说明未命中,继续走DB;若非null,说明命中,此时可查看list内容是否与DB一致(验证序列化是否正确)。

4.4 自定义缓存实现:用Caffeine替代PerpetualCache

Mybatis允许自定义Cache实现,只需实现org.apache.ibatis.cache.Cache接口。我们用Caffeine(高性能Java本地缓存)替代默认PerpetualCache,获得LRU淘汰和自动过期能力:

public class CaffeineCache implements Cache { private final String id; private final com.github.benmanes.caffeine.cache.Cache<Object, Object> cache; public CaffeineCache(String id) { this.id = id; this.cache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) // 写入10分钟后过期 .recordStats() // 开启统计 .build(); } @Override public String getId() { return id; } @Override public void putObject(Object key, Object value) { cache.put(key, value); } @Override public Object getObject(Object key) { return cache.getIfPresent(key); } @Override public Object removeObject(Object key) { Object value = cache.getIfPresent(key); cache.invalidate(key); return value; } @Override public void clear() { cache.invalidateAll(); } @Override public int getSize() { return (int) cache.estimatedSize(); } @Override public ReadWriteLock getReadWriteLock() { return new DummyReadWriteLock(); } }

在Mapper XML中使用:

<cache type="com.example.cache.CaffeineCache"/>

优势:Caffeine的expireAfterWrite可精确控制单个key过期时间,避免flushInterval的全局清空;recordStats()提供hitRate()、evictionCount()等指标,便于监控缓存健康度。我们上线后,缓存命中率从65%提升至92%,GC次数减少40%。


5. 常见问题与排查技巧实录:那些文档里不会写的坑

5.1 问题速查表:缓存不生效的7种可能原因

现象可能原因排查命令/方法解决方案
缓存完全不写入cacheEnabled=false(全局关闭)查Configuration构造后isCacheEnabled()返回值在mybatis-config.xml中确认<settings><setting name="cacheEnabled" value="true"/></settings>
缓存写入但不命中Mapper XML未配置<cache/>查MappedStatement.getCache()是否为null在Mapper接口同名XML中添加<cache/>标签
缓存命中率低CacheKey生成不一致(如参数类型不同)开启TRACE日志,对比两次查询的CacheKey.toString()统一参数类型,或重写ParameterHandler定制key生成逻辑
缓存数据错乱多个Mapper共用同一namespace(如@MapperScan扫描错误)查MappedStatement.getId()是否重复检查@MapperScan的basePackages,确保Mapper接口路径唯一
update后缓存未清空flushCache="false"在update语句上查MappedStatement.isFlushCacheRequired()返回false移除update标签的flushCache="false"属性,或显式设为true
序列化失败实体类含不可序列化字段(如ThreadLocal)查CacheException堆栈,定位serialize()调用点为实体类添加transient修饰,或实现writeObject/readObject
缓存穿透查询不存在id,无防护机制监控DB慢查询日志,看WHERE id=?条件是否高频出现非法值引入布隆过滤器,在DAO层前置校验

注意:CacheKey不一致是最常见原因。曾有个项目因Integer和Long混用(selectById(int)vsselectById(Long)),导致同一id生成两个key,缓存命中率不足30%。解决方案是统一用Long,并在Mybatis配置中添加typeHandlers强制转换。

5.2 线程安全实测:SynchronizedCache在高并发下的表现

我们用JMeter对selectById接口做压测(100线程,循环1000次),对比SynchronizedCache和BlockingCache:

指标SynchronizedCacheBlockingCache差异分析
平均RT(ms)42.318.7BlockingCache减少56%延迟,因锁粒度更细
缓存命中率89.2%89.5%基本一致,证明BlockingCache未影响命中逻辑
线程等待数1278BlockingCache显著降低线程竞争
GC次数(1分钟)14289SynchronizedCache因锁竞争导致更多临时对象创建

结论:在QPS > 500的场景,必须用BlockingCache替代SynchronizedCache。启用方式很简单,在<cache>中添加blocking="true":

<cache eviction="LRU" flushInterval="60000" size="1000" blocking="true" <!-- 关键 --> />

实测心得:BlockingCache的阻塞锁是ReentrantLock,它比synchronized更轻量,且支持公平锁(new ReentrantLock(true))。但在Mybatis中,BlockingCache默认使用非公平锁,已足够满足大多数场景。

5.3 缓存一致性终极方案:双写+消息队列

Mybatis二级缓存无法解决分布式环境下的一致性问题(如多节点部署时,节点A更新DB后,节点B的缓存仍是旧数据)。我们的生产方案是**“双写+最终一致性”**:

  1. 写操作流程:

    • Service层先调用userMapper.update(user)更新DB;
    • 更新成功后,发送MQ消息(如RocketMQ){topic: "user_cache_update", key: "user_123", data: user};
    • 所有节点订阅该topic,收到消息后执行cacheManager.evict("user_123")清除本地缓存。
  2. 读操作流程:

    • 先查Mybatis二级缓存;
    • 未命中则查DB,并写入缓存;
    • 若缓存中存在但MQ消息已到达(通过版本号或时间戳判断),则强制刷新。

这样做的好处:

  • 避免强一致性带来的性能损耗(如分布式锁);
  • MQ保证消息至少投递一次,即使节点宕机,重启后也能消费积压消息;
  • 本地缓存+MQ的组合,比纯Redis缓存延迟更低(本地内存访问微秒级)。

我们在用户中心服务中实施后,缓存不一致窗口从“分钟级”缩短至“秒级”(MQ平均延迟<200ms),DB压力下降70%。

5.4 性能监控:用Metrics暴露缓存健康度

Mybatis本身不提供监控指标,但我们可以通过AOP切面注入Cache的getObject()和putObject()方法,上报到Prometheus:

@Aspect @Component public class CacheMetricsAspect { private final Counter cacheHitCounter = Counter.build() .name("mybatis_cache_hit_total").help("Cache hit count.").register(); private final Counter cacheMissCounter = Counter.build() .name("mybatis_cache_miss_total").help("Cache miss count.").register(); @Around("execution(* org.apache.ibatis.cache.Cache.getObject(..))") public Object trackCacheHit(ProceedingJoinPoint joinPoint) throws Throwable { Object result = joinPoint.proceed(); if (result != null) { cacheHitCounter.inc(); } else { cacheMissCounter.inc(); } return result; } }

在Grafana中配置看板,关键指标包括:

  • mybatis_cache_hit_total / (mybatis_cache_hit_total + mybatis_cache_miss_total):缓存命中率(健康值>85%);
  • mybatis_cache_miss_total:每分钟未命中次数(突增预示缓存穿透或key生成异常);
  • jvm_memory_used_bytes{area="heap"}:堆内存使用量(缓存过大导致OOM的早期信号)。

我们曾通过该看板发现:某天凌晨缓存命中率从92%骤降至45%,排查发现是定时任务批量更新用户状态,触发了flushInterval全局清空。解决方案是将该任务的Mapper单独配置<cache flushInterval="0"/>,禁用自动刷新。


6. 经验总结:二级缓存不是银弹,而是需要精心调校的引擎

我在多个中大型项目里落地Mybatis二级缓存,最深刻的体会是:它不像Redis那样开箱即用,而像一台需要手动调校的精密引擎。你得知道每个螺丝的位置、每根油管的走向,否则稍有不慎就会过热甚至爆缸。

比如flushInterval这个参数,文档说“毫秒为单位”,但没人告诉你:设得太小(如1000ms),会导致缓存刚写入就被清空,形同虚设;设得太大(如3600000ms),又会让脏数据驻留太久。我们的经验值是:读多写少的业务(如配置表)设为300000ms(5分钟),读写均衡的业务(如订单)设为60000ms(1分钟),并通过监控看板动态调整。

再比如size参数,它不是内存大小,而是缓存项数量上限。一个User对象序列化后约2KB,size=1000意味着最多占用2MB内存。但若User含List<Order>,序列化后达50KB,size=1000就会吃掉50MB——这解释了为什么有些团队抱怨“开了缓存反而OOM”。解决方案是:用CaffeineCache替代,它按内存大小(maximumWeight)而非数量限制。

最后说个容易被忽视的点:二级缓存和Spring事务的协作。当@Transactional方法内调用userMapper.selectById(),若事务未提交,CachingExecutor会将结果暂存在TransactionalCache.entriesToAddOnCommit中,事务提交后才真正写入底层Cache。这意味着:事务内多次查询同一key,第一次查DB,后续查缓存(但数据是事务内的最新值);事务外查询,则只能看到提交后的缓存。这种设计保证了事务隔离性,但也增加了理解成本。

所以,别再把二级缓存当成一个开关。把它当作一个需要你亲手调试、监控、调优的子系统。当你能说出CacheKey的4个构成要素、能画出CachingExecutor的装饰器链、能用Arthas定位SynchronizedCache的锁竞争——你就真正掌握了Mybatis二级缓存。而这,正是从使用者进阶为架构师的关键一步。

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

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

立即咨询