开了 MyBatis 二级缓存,命中率居然是 0%。
你肯定遇过这种场景:业务代码明明没改,flushCache=false 也设了,但 SQL 一执行就打到数据库。查了一圈没找到原因,最后怀疑是 MyBatis 缓存本身有问题。
其实不是缓存有问题,是装饰器模式套娃的代价你还没理解透。
MyBatis 的 Cache 接口是怎么设计出来的?
org.apache.ibatis.cache.Cache 是个很干净的接口:
public interface Cache {
String getId();
void putObject(Object key, Object value);
Object getObject(Object key);
Object removeObject(Object key);
void clear();
int getSize();
}
但 PerpetualCache 只是最基础的实现——一个 HashMap。真正的 MyBatis 缓存是装饰器一层层套出来的:
SynchronizeCache
└── LoggingCache
└── SerializingCache
└── LruCache
└── PerpetualCache
每一层都是装饰器模式的典型应用。读 CacheBuilder.build() 源码就会发现,它通过反射按顺序把不同的装饰器嵌套起来:
public Cache build() {
setDefaultImplementations();
Cache cache = newBaseCacheInstance(implementation);
// ... 按顺序装饰
if (useCache) {
cache = new SynchronizedCache(cache);
}
// ...
return cache;
}
每一层加一种能力:
SynchronizedCache:线程安全(装饰)LoggingCache:命中率统计(装饰)SerializedCache:序列化存储(装饰)LruCache:LRU 淘汰(装饰)PerpetualCache:底层 HashMap(被装饰者)
为什么"装饰器套娃"会导致缓存失效看起来很奇怪?
装饰器模式的本质是"对扩展开放、对修改封闭"。每一层装饰器都可以独立决定自己的行为。但这里有个隐藏的坑:每一层装饰器都有它自己的"清空"语义。
来看 LoggingCache.clear() 的实现:
public void clear() {
delegate.clear(); // 清底层
requests = 0; // 重置计数
hits = 0;
}
看起来没问题。但如果中间某层(SerializedCache)的 clear() 没正确实现,底层被清了但上层状态没重置,下次 getObject() 时上层可能误判为命中,直接返回 null,绕过底层。
更糟的是 LRU 装饰器:
public class LruCache implements Cache {
private final Cache delegate;
private Map<Object, Object> keyMap;
private Object eldestKey;
public Object getObject(Object key) {
keyMap.get(key); // 更新访问顺序
return delegate.getObject(key);
}
}
注意 keyMap 和 delegate 的数据可能不一致。如果 delegate.clear() 被调用了但 keyMap 没清,下次 getObject() 还会更新 keyMap 的访问顺序——但 delegate 里其实没这个 key。结果是缓存的"逻辑大小"和"实际大小"对不上。
你开了二级缓存为什么反而更慢?
很多人以为开了 <cache/> 二级缓存查询就快。但实际上有几个性能陷阱:
1. 装饰器嵌套太深,每次 getObject 都要过 5 层
// 装饰链:LoggingCache → SerializedCache → LruCache → SynchronizedCache → PerpetualCache
cache.getObject(key);
// 实际执行路径:
// 1. LoggingCache 记录请求
// 2. SerializedCache 反序列化
// 3. LruCache 更新访问顺序
// 4. SynchronizedCache 加锁
// 5. PerpetualCache 查 HashMap
每一层都是方法调用,都有栈帧开销。如果是高频查询(每秒几千次),装饰器套娃的开销比直接查数据库还慢。
2. 序列化开销
SerializedCache 默认开启。意味着每个缓存值都要序列化 + 反序列化。如果对象结构复杂(比如包含 List<Map>),一次缓存查询的开销可能比 SQL 还大。
3. 事务边界导致全部失效
MyBatis 二级缓存在事务 commit 时才生效:
// SqlSessionInterceptor
public Object invoke(...) {
// ... 执行 SQL
if (executor.isCached(executor.getCacheKey(ms, parameter, ...))) {
// 命中缓存
} else {
// 查数据库 → 写入缓存(但只在 commit 时才真写)
}
}
如果事务回滚,缓存不写入。这意味着对一致性要求高的业务,二级缓存反而是负担——写完不 commit,下次查询还是查库。
实战:怎么调优 MyBatis 缓存?
方案 1:关闭二级缓存,依赖 Redis
<setting name="cacheEnabled" value="false"/>
然后用 RedisCache 替代。但要注意 Spring 事务边界——Redis 缓存的数据一致性问题比 MyBatis 二级缓存更难控制。
方案 2:自定义装饰器,跳过 LoggingCache
public class FastCache implements Cache {
private final Cache delegate;
public FastCache(Cache delegate) {
this.delegate = delegate;
}
@Override
public Object getObject(Object key) {
// 跳过 LoggingCache 的统计
return delegate.getObject(key);
}
// ...
}
适合对性能敏感、但对监控要求低的场景。
3. 用 Caffeine 替代
<dependency>
<groupId>org.mybatis.caches</groupId>
<artifactId>mybatis-caffeine-cache</artifactId>
<version>1.0.0</version>
</dependency>
Caffeine 本身的 W-TinyLFU 算法比 MyBatis 自带的 LRU 强几个数量级,而且没有装饰器套娃开销。
一句话总结
MyBatis 二级缓存不可靠的根因不是缓存机制有问题,是装饰器模式嵌套得越深,每一层的失效语义就越难保证一致。配置层面是"开了缓存",代码层面却是 5 层装饰器协同工作。任何一层写错了,整体就崩。
业务上能用 Redis 就别用 MyBatis 自带二级缓存,需要本地缓存就用 Caffeine,自己写装饰器。MyBatis 自带的那套,留着看源码学习就好。
——
顺便说一句,我在做一个用卡皮巴拉讲设计模式的微信小程序「爪爪代码冒险记」,23 个设计模式用漫画 + 答题的方式讲,目前还在肝。你如果觉得这类内容有意思,可以微信搜一下「爪爪代码冒险记」先占个坑,每篇文章我都会同步对应的小程序内容。