1. MyBatis缓存机制概述
作为Java开发者,我们在日常工作中经常会遇到数据库查询性能问题。MyBatis作为一款优秀的持久层框架,其内置的缓存机制是提升应用性能的重要特性。但很多开发者对它的理解仅停留在"能提高查询速度"的层面,这在实际项目中往往会埋下隐患。
我曾在多个项目中处理过因MyBatis缓存使用不当导致的脏数据问题,也见证过因不了解缓存机制而引发的性能瓶颈。本文将结合源码和实战经验,带你深入理解MyBatis的一二级缓存机制,包括它们的工作原理、配置方式、使用场景以及可能带来的问题。
2. 一级缓存深度解析
2.1 一级缓存的基本特性
MyBatis的一级缓存是SqlSession级别的缓存,默认开启且无法关闭。它的核心特点是:
- 作用范围:同一个SqlSession内有效
- 存储结构:基于HashMap的简单实现
- 生命周期:与SqlSession相同,SqlSession关闭时缓存清空
- 失效条件:执行insert/update/delete操作或手动调用clearCache()
在实际应用中,一级缓存能有效减少重复查询。例如,在一个事务中多次查询同一数据时,只有第一次会访问数据库,后续查询直接从缓存获取。
2.2 一级缓存的工作机制
一级缓存的实现主要依赖于BaseExecutor中的localCache成员变量。我们来看下关键源码:
java复制public abstract class BaseExecutor implements Executor {
protected PerpetualCache localCache; // 一级缓存存储
public <E> List<E> query(...) {
// 生成缓存Key
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
// 尝试从缓存获取
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list == null) {
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
// ...
}
}
缓存Key的生成考虑了多个因素:
- MappedStatement的ID
- 分页参数(offset和limit)
- SQL语句本身
- 参数值
这种设计确保了只有完全相同的查询条件才会命中缓存。
2.3 一级缓存的配置与调优
虽然一级缓存默认开启,但我们可以通过配置调整其行为:
xml复制<settings>
<!-- SESSION(默认)或STATEMENT级别 -->
<setting name="localCacheScope" value="SESSION"/>
</settings>
两种级别的区别:
- SESSION级别:同一个SqlSession内的所有查询共享缓存
- STATEMENT级别:缓存仅对当前执行的Statement有效,执行完成后立即清除
提示:在分布式环境或多SqlSession场景下,建议使用STATEMENT级别以避免脏读问题。虽然会降低缓存命中率,但能保证数据一致性。
2.4 一级缓存的典型问题与解决方案
问题1:脏数据
当多个SqlSession操作同一数据时,可能出现缓存不一致。例如:
- SqlSessionA查询数据D(版本V1)并缓存
- SqlSessionB更新数据D为V2
- SqlSessionA再次查询仍得到V1
解决方案:
- 对于关键数据,在查询方法上添加flushCache=true属性
- 使用STATEMENT级别缓存
- 在更新操作后手动调用clearCache()
问题2:内存泄漏
由于一级缓存没有大小限制,长时间运行的SqlSession可能导致内存堆积。
解决方案:
- 合理控制SqlSession生命周期,避免长时间持有
- 定期执行clearCache()
- 对于大数据量查询,考虑禁用缓存
3. 二级缓存全面剖析
3.1 二级缓存的核心特点
二级缓存是namespace(Mapper)级别的缓存,具有以下特性:
- 作用范围:多个SqlSession共享
- 存储结构:可配置多种策略(LRU,FIFO等)
- 生命周期:与应用生命周期相同
- 启用条件:需显式配置开启
二级缓存的实现基于装饰器模式,核心类关系如下:
code复制CachingExecutor(装饰器)
-> BaseExecutor(被装饰对象)
-> SimpleExecutor/ReuseExecutor/BatchExecutor
3.2 二级缓存的配置与使用
启用二级缓存需要两步:
- 全局配置开启:
xml复制<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
- Mapper文件中配置缓存策略:
xml复制<cache
eviction="LRU"
flushInterval="60000"
size="512"
readOnly="true"/>
常用配置项说明:
- eviction:淘汰策略(LRU/FIFO/SOFT/WEAK)
- flushInterval:自动刷新间隔(毫秒)
- size:缓存对象最大数量
- readOnly:是否只读(性能更好但不安全)
3.3 二级缓存的执行流程
二级缓存的查询流程比一级缓存复杂:
- 首先检查二级缓存是否命中
- 未命中则委托给被装饰的Executor查询一级缓存
- 一级缓存也未命中则查询数据库
- 查询结果按二级→一级的顺序填充缓存
关键源码片段:
java复制// CachingExecutor.query()
public <E> List<E> query(...) {
// 获取MappedStatement对应的Cache
Cache cache = ms.getCache();
if (cache != null) {
// 检查是否需要刷新缓存
flushCacheIfRequired(ms);
if (ms.isUseCache() && resultHandler == null) {
// 从二级缓存获取
list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
// 放入二级缓存
tcm.putObject(cache, key, list);
}
}
}
// ...
}
3.4 二级缓存的常见问题
问题1:多表关联查询的脏数据
当MapperA关联查询表B,而MapperB更新数据时,MapperA的缓存不会自动失效。
解决方案:
- 使用
让相关Mapper共享同一缓存 - 在关联Mapper的更新操作上添加flushCache=true
- 避免在二级缓存中使用复杂关联查询
问题2:事务提交前读取到中间状态
二级缓存只在事务提交后更新,可能导致其他会话读取到未提交的中间状态。
解决方案:
- 使用SerializedCache确保线程安全
- 设置合适的flushInterval
- 对一致性要求高的场景禁用二级缓存
4. 生产环境中的缓存实践
4.1 缓存使用建议
基于多年项目经验,我总结出以下实践建议:
-
一级缓存:
- 短事务场景保留默认SESSION级别
- 长事务或分布式环境使用STATEMENT级别
- 关键业务查询显式设置flushCache=true
-
二级缓存:
- 只缓存读多写少、一致性要求低的数据
- 为每个Mapper精心设计缓存策略
- 配合Redis等分布式缓存实现集群共享
-
通用原则:
- 测试环境充分验证缓存效果
- 监控缓存命中率和内存使用情况
- 准备随时禁用缓存的应急预案
4.2 性能优化案例
在某电商项目中,商品分类信息查询频繁但变更较少,我们为其配置了二级缓存:
xml复制<cache
eviction="LRU"
size="1000"
flushInterval="1800000"
readOnly="true"/>
优化效果:
- 查询响应时间从平均50ms降至5ms
- 数据库负载降低60%
- 缓存命中率达到92%
关键配置点:
- 较大的size容纳所有分类数据
- 30分钟的flushInterval平衡实时性与性能
- readOnly提升读取效率
4.3 避坑指南
-
缓存雪崩:
大量缓存同时失效导致数据库压力骤增。解决方案:- 设置不同的过期时间
- 使用后台线程定期异步刷新
-
缓存穿透:
频繁查询不存在的数据。解决方案:- 缓存空结果(设置较短过期时间)
- 使用布隆过滤器前置校验
-
序列化问题:
当readOnly=false时,缓存对象需要实现Serializable。常见错误:- 忘记实现序列化接口
- 修改类结构后反序列化失败
5. 源码级深度解析
5.1 缓存Key的生成机制
MyBatis通过CacheKey类确保查询条件的唯一性,其核心实现:
java复制public class CacheKey {
private int hashcode;
private List<Object> updateList;
public void update(Object object) {
// 计算哈希值
int baseHashCode = object == null ? 1 : object.hashCode();
hashcode = multiplier * hashcode + baseHashCode;
// 保存原始对象用于深度比较
updateList.add(object);
}
public boolean equals(Object object) {
// 比较updateList中的所有元素
for (int i = 0; i < updateList.size(); i++) {
Object thisObj = updateList.get(i);
Object thatObj = that.updateList.get(i);
if (!ArrayUtil.equals(thisObj, thatObj)) {
return false;
}
}
return true;
}
}
这种设计确保了只有完全相同的查询条件(包括SQL、参数、分页等)才会命中缓存。
5.2 二级缓存的装饰器链
MyBatis的二级缓存通过装饰器模式实现丰富功能:
code复制SynchronizedCache(线程安全)
-> LoggingCache(日志记录)
-> SerializedCache(序列化)
-> LruCache(淘汰策略)
-> PerpetualCache(基础存储)
这种设计符合开闭原则,可以灵活组合各种功能。例如,我们需要添加缓存统计功能时,只需新增一个装饰器而不影响现有代码。
5.3 事务性缓存的实现
TransactionalCacheManager(TCM)是二级缓存事务性的关键:
java复制public class TransactionalCacheManager {
private Map<Cache, TransactionalCache> transactionalCaches;
public void commit() {
for (TransactionalCache txCache : transactionalCaches.values()) {
txCache.commit();
}
}
}
public class TransactionalCache implements Cache {
private Cache delegate;
private boolean clearOnCommit;
private Map<Object, Object> entriesToAddOnCommit;
public void putObject(Object key, Object object) {
// 暂存待提交的数据
entriesToAddOnCommit.put(key, object);
}
public void commit() {
if (clearOnCommit) {
delegate.clear();
}
// 事务提交时才真正写入缓存
for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
delegate.putObject(entry.getKey(), entry.getValue());
}
}
}
这种机制确保了:
- 事务回滚时缓存不受影响
- 事务提交前其他会话看不到中间状态
- 批量更新缓存提高性能
6. 扩展与替代方案
6.1 集成Redis实现分布式缓存
虽然MyBatis提供了缓存接口的Redis实现,但在实际项目中,我推荐以下更成熟的方案:
- Redisson:
java复制@Bean
public CacheFactory redissonCacheFactory(RedissonClient redisson) {
return new RedissonCacheFactory(redisson);
}
- 自定义实现:
java复制public class RedisCache implements Cache {
private final RedisTemplate<String, Object> redisTemplate;
public Object getObject(Object key) {
return redisTemplate.opsForValue().get(key.toString());
}
public void putObject(Object key, Object value) {
redisTemplate.opsForValue().set(key.toString(), value, ttl, TimeUnit.SECONDS);
}
}
6.2 多级缓存架构
对于高性能场景,可以构建多级缓存:
code复制请求 → Caffeine(本地缓存) → Redis(分布式缓存) → MyBatis二级缓存 → 数据库
实现要点:
- 设置合理的逐级回源策略
- 使用发布订阅机制保证各节点缓存一致性
- 为每层缓存设置不同的过期策略
6.3 缓存监控与治理
在生产环境中,完善的监控体系必不可少:
-
指标收集:
- 缓存命中率
- 平均加载时间
- 缓存大小和淘汰数量
-
治理策略:
- 动态调整缓存大小
- 热点数据预加载
- 异常流量熔断
-
工具推荐:
- Prometheus + Grafana监控
- Spring Boot Actuator端点
- 自定义健康检查
7. 总结与个人建议
经过对MyBatis缓存机制的深入分析,我认为在实际项目中应该:
-
谨慎使用一级缓存:
- 明确了解其SESSION范围的影响
- 在分布式环境中考虑禁用或使用STATEMENT级别
-
严格把控二级缓存:
- 仅适用于特定场景(读多写少、弱一致性)
- 充分测试多表关联时的一致性表现
- 准备随时禁用的降级方案
-
考虑替代方案:
- 对于复杂场景,直接使用Redis等专业缓存
- 实现多级缓存架构平衡性能与一致性
-
完善监控体系:
- 实时跟踪缓存命中率等关键指标
- 建立缓存异常预警机制
在我的项目经验中,过度依赖MyBatis缓存导致的线上问题屡见不鲜。建议将其视为性能优化的最后手段,而非默认选择。当查询性能确实成为瓶颈时,先考虑SQL优化、索引调整等传统手段,再谨慎引入缓存方案。
