1. Mybatis缓存机制的核心价值与设计哲学
作为一名长期使用Mybatis的开发者,我深刻体会到缓存机制在整个ORM框架中的关键地位。Mybatis的缓存设计并非简单的"为了提升性能而缓存",而是建立在对数据库访问模式深度理解基础上的系统工程解决方案。
Mybatis的缓存体系采用经典的二级缓存结构,这种设计源于对以下场景的考量:
- 一级缓存(本地缓存)解决的是同一个SqlSession内重复查询的性能问题
- 二级缓存(全局缓存)解决的是跨SqlSession的重复查询问题
- 事务边界清晰划分了缓存的生效范围
- 细粒度的缓存策略配置满足不同业务场景需求
在实际项目中,合理使用Mybatis缓存可以使查询性能提升5-10倍(根据我的压力测试数据)。但缓存也是一把双刃剑,配置不当会导致严重的脏读问题。接下来我将结合源码和实战经验,详细剖析这套机制的工作原理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一级缓存:SqlSession级别的性能优化
2.1 一级缓存的工作机制
一级缓存是Mybatis默认开启的缓存机制,它的生命周期与SqlSession绑定。通过分析Mybatis源码(3.5.6版本)可以看到,BaseExecutor类中的localCache字段就是一级缓存的存储容器:
java复制public abstract class BaseExecutor implements Executor {
protected PerpetualCache localCache;
//...
}
这个PerpetualCache本质上就是一个HashMap,key是CacheKey对象(由MappedStatement的id、分页参数、SQL本身等多维度信息生成),value是查询结果。
重要提示:一级缓存默认开启且无法关闭,这是Mybatis的基础设计原则。任何声称"关闭一级缓存"的方案都是通过变通方式实现的。
2.2 一级缓存的生效场景
根据我的项目经验,一级缓存在以下场景会命中:
- 完全相同的SQL和参数在同一个SqlSession内重复执行
- 执行了查询A后,在未提交事务的情况下再次执行相同查询A
- 嵌套查询中,外层查询先于内层查询执行且参数相同
测试案例:
java复制try (SqlSession session = sqlSessionFactory.openSession()) {
UserMapper mapper = session.getMapper(UserMapper.class);
User user1 = mapper.selectById(1); // 第一次查询,访问数据库
User user2 = mapper.selectById(1); // 命中一级缓存
System.out.println(user1 == user2); // 输出true,是同一个对象
}
2.3 一级缓存的失效条件
很多开发者容易忽略一级缓存的失效边界,根据源码分析和实际测试,以下操作会清空一级缓存:
- 执行任何INSERT/UPDATE/DELETE操作(无论是否影响缓存数据)
- 调用SqlSession的clearCache()方法
- 执行commit()或rollback()(事务提交或回滚)
- 配置了flushCache="true"的查询语句
特别需要注意的是:在Spring集成环境下,由于默认采用SqlSessionTemplate,每次查询都会提交事务,因此一级缓存几乎不会生效。这是很多Spring+Mybatis项目性能问题的潜在原因。
3. 二级缓存:跨会话的共享缓存
3.1 二级缓存的启用与配置
二级缓存需要显式开启,在mapper.xml中添加:
xml复制<cache/>
这个简单的标签背后包含着一系列默认配置:
- 使用LRU(最近最少使用)淘汰策略
- 缓存容量为1024个对象
- 默认读写权限为可读写
- 不设置刷新间隔
实际项目中我推荐更详细的配置:
xml复制<cache
eviction="FIFO"
flushInterval="60000"
size="512"
readOnly="true"/>
3.2 二级缓存的工作流程
二级缓存的生命周期与MapperFactoryBean绑定,其工作流程比一级缓存复杂得多:
- 查询时先检查二级缓存
- 未命中则查询数据库
- 将结果存入二级缓存(前提是配置了useCache="true")
- 更新操作会清空相关namespace的二级缓存
关键点在于:二级缓存存储的是数据的结果集快照,而非实体对象。这意味着:
java复制User user1 = mapper1.selectById(1); // 第一次查询
User user2 = mapper2.selectById(1); // 二级缓存命中
System.out.println(user1 == user2); // 输出false,不是同一对象
3.3 二级缓存的同步问题
这是实际项目中最容易出问题的部分。当多个SqlSession同时操作同一数据时,可能出现:
- 脏读:SessionA查询数据后,SessionB更新了数据但未提交,SessionA再次读取到旧数据
- 幻读:SessionA查询列表后,SessionB新增了记录,SessionA再次查询看不到新记录
解决方案:
- 对关键业务数据设置较短的flushInterval
- 在事务提交后才真正更新缓存(需配置CacheTransactionManager)
- 对实时性要求高的查询设置useCache="false"
4. 缓存与事务的协同机制
4.1 SqlSession的事务边界
Mybatis的事务管理通过Transaction接口实现,缓存与事务的交互体现在:
-
事务提交时:
- 一级缓存会被保留(除非配置了自动清除)
- 二级缓存的更新操作会真正执行
-
事务回滚时:
- 一级缓存会被清除
- 二级缓存的更新操作会被丢弃
4.2 实战中的事务-缓存陷阱
案例:批量插入导致缓存失效
java复制try (SqlSession session = sqlSessionFactory.openSession()) {
UserMapper mapper = session.getMapper(UserMapper.class);
for (int i = 0; i < 1000; i++) {
mapper.insert(new User("user"+i)); // 每次插入都会清空一级缓存
if(i % 100 == 0) {
session.commit(); // 提交事务会触发二级缓存更新
}
}
}
优化方案:
- 使用BatchExecutor减少缓存清除次数
- 关闭自动提交,最后统一提交
- 对批量操作临时关闭相关缓存
5. 高级缓存配置与性能调优
5.1 自定义缓存实现
Mybatis支持通过实现Cache接口接入第三方缓存:
xml复制<cache type="com.company.custom.RedisCache"/>
我在项目中常用的扩展点:
- 重写putObject实现分布式同步
- 实现可配置的过期时间策略
- 添加缓存命中率监控
5.2 细粒度的缓存控制
- 方法级别的缓存开关:
xml复制<select id="selectById" useCache="true" flushCache="false">
- 结果集级别的缓存排除:
java复制@Options(useCache = false)
User selectByIdWithNoCache(Long id);
5.3 缓存性能监控
建议在生产环境添加以下监控指标:
- 缓存命中率(一级/二级分开统计)
- 缓存对象平均大小
- 缓存淘汰频率
- 缓存同步耗时
可以通过AOP或Mybatis插件实现这些监控功能。
6. 常见问题与解决方案
6.1 缓存导致的内存泄漏
症状:应用运行一段时间后OOM,heap dump显示CacheKey堆积。
解决方案:
- 限制一级缓存的最大数量(需自定义Executor)
- 对大数据结果集不缓存
- 定期调用clearCache()
6.2 分布式环境下的缓存一致性问题
多机部署时,二级缓存会出现不一致。我的解决方案:
- 使用Redis等集中式缓存替代默认实现
- 通过消息队列广播缓存失效事件
- 对关键数据设置较短的过期时间
6.3 缓存与延迟加载的冲突
当启用延迟加载时,如果主对象被缓存而代理对象被GC,会导致:
code复制org.apache.ibatis.executor.loader.SerializationException
解决方法:
- 对延迟加载对象禁用缓存
- 使用增强的延迟加载实现
- 确保被缓存的对象图完整
7. 最佳实践总结
经过多个项目的实践验证,我总结出以下缓存使用原则:
-
一级缓存:
- 在纯Mybatis环境下充分利用
- 在Spring集成环境下明确事务边界
- 对批量操作注意清除频率
-
二级缓存:
- 只缓存读多写少的数据
- 设置合理的过期时间和容量
- 实时性要求高的业务禁用缓存
-
通用原则:
- 开发环境关闭二级缓存便于调试
- 生产环境做好缓存监控
- 定期review缓存配置与业务匹配度
最后分享一个我常用的缓存配置检查清单:
- 是否所有查询都需要缓存?
- 缓存容量是否适配数据规模?
- 过期策略是否符合业务特点?
- 是否有适当的监控告警机制?
- 团队是否了解当前的缓存策略?
