1. Mybatis缓存机制深度解析
作为Java生态中最流行的ORM框架之一,Mybatis的缓存设计一直是开发者关注的焦点。很多人在面试中被问到"Mybatis一级缓存和二级缓存有什么区别"时,往往只能回答出"一级缓存是SqlSession级别的,二级缓存是Mapper级别的"这样的表面结论。今天我们就来彻底拆解这两级缓存的实现原理、使用场景和避坑指南,让你真正掌握Mybatis的缓存机制。
我在实际项目中使用Mybatis已有5年时间,处理过各种因缓存导致的诡异bug。本文将结合源码和实战案例,带你深入理解:
- 一级缓存的工作机制和生命周期
- 二级缓存的配置陷阱和序列化问题
- 在分布式环境下如何正确使用缓存
- 常见的缓存失效场景及解决方案
2. 一级缓存:SqlSession的生命周期
2.1 一级缓存的基本特性
一级缓存是Mybatis默认开启的缓存机制,它的作用域是单个SqlSession。这意味着在同一个SqlSession中执行的相同SQL查询,第二次会直接从内存返回结果而不访问数据库。
java复制// 示例1:一级缓存生效场景
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,是同一个对象
}
一级缓存的底层实现是一个HashMap,key由以下要素组成:
- Mapper Id(如com.example.mapper.UserMapper.selectById)
- SQL语句
- 参数值
- 分页参数
- 环境ID
2.2 一级缓存的失效场景
很多开发者误以为只要SQL相同就会命中缓存,其实不然。以下情况会导致一级缓存失效:
- SqlSession不同:每个新创建的SqlSession都有自己独立的一级缓存
- 参数不同:即使SQL相同但参数值不同
- 执行了增删改操作:任何INSERT/UPDATE/DELETE都会清空当前SqlSession的缓存
- 手动清空缓存:调用sqlSession.clearCache()
- 提交或回滚事务:执行commit()或rollback()会清空缓存
java复制// 示例2:一级缓存失效场景
try (SqlSession session = sqlSessionFactory.openSession()) {
UserMapper mapper = session.getMapper(UserMapper.class);
User user1 = mapper.selectById(1);
mapper.updateName(1, "新名字"); // 增删改操作清空缓存
User user2 = mapper.selectById(1); // 重新查询数据库
System.out.println(user1 == user2); // 输出false,不是同一个对象
}
重要提示:一级缓存默认是开启的,且无法关闭。在批处理场景下,大量数据操作可能导致内存溢出,此时应该定期调用clearCache()或使用新的SqlSession。
3. 二级缓存:Mapper级别的共享缓存
3.1 二级缓存的启用与配置
二级缓存的作用域是Mapper(Namespace)级别,多个SqlSession可以共享同一个Mapper的二级缓存。要启用二级缓存需要两步配置:
- 在mybatis-config.xml中开启全局缓存:
xml复制<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
- 在Mapper XML中添加
标签:
xml复制<mapper namespace="com.example.mapper.UserMapper">
<cache/>
...
</mapper>
二级缓存的核心配置参数:
- eviction:缓存淘汰策略(LRU/FIFO/SOFT/WEAK)
- flushInterval:刷新间隔(毫秒)
- size:缓存对象数量
- readOnly:是否只读
- blocking:是否使用阻塞缓存
3.2 二级缓存的实现原理
二级缓存的底层是通过装饰器模式实现的,核心类为CachingExecutor。当缓存命中时,执行流程如下:
- 创建CacheKey
- 查询缓存是否存在
- 存在则返回缓存结果
- 不存在则查询数据库并存入缓存
java复制// 简化版的二级缓存执行流程
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) {
Cache cache = ms.getCache();
if (cache != null) {
if (ms.isUseCache() && resultHandler == null) {
Object cached = cache.getObject(key);
if (cached != null) {
return (List<E>) cached;
}
}
}
// 查询数据库...
}
3.3 二级缓存的序列化问题
二级缓存默认使用内存存储,当多个SqlSession共享缓存时,返回的是对象的深拷贝而非引用。这意味着:
- 从缓存获取的对象与原始对象不同(==比较为false)
- 对象必须实现Serializable接口
- 大型对象序列化/反序列化有性能开销
java复制// 示例3:二级缓存的序列化问题
try (SqlSession session1 = sqlSessionFactory.openSession();
SqlSession session2 = sqlSessionFactory.openSession()) {
UserMapper mapper1 = session1.getMapper(UserMapper.class);
UserMapper mapper2 = session2.getMapper(UserMapper.class);
User user1 = mapper1.selectById(1); // 查询数据库
session1.commit(); // 提交后存入二级缓存
User user2 = mapper2.selectById(1); // 从二级缓存获取
System.out.println(user1 == user2); // 输出false
}
4. 缓存使用中的常见陷阱
4.1 脏读问题
当多个SqlSession操作同一数据时,可能出现缓存与数据库不一致的情况:
java复制// 示例4:缓存脏读问题
try (SqlSession session1 = sqlSessionFactory.openSession();
SqlSession session2 = sqlSessionFactory.openSession()) {
// Session1查询数据
UserMapper mapper1 = session1.getMapper(UserMapper.class);
User user1 = mapper1.selectById(1); // 查询数据库,存入一级缓存
// Session2更新数据
UserMapper mapper2 = session2.getMapper(UserMapper.class);
mapper2.updateName(1, "新名字");
session2.commit(); // 更新数据库,清空二级缓存
// Session1再次查询
User user2 = mapper1.selectById(1); // 从一级缓存获取旧数据
System.out.println(user1.getName().equals(user2.getName())); // true,脏读
}
解决方案:
- 对于关键数据,在查询前调用clearCache()
- 设置合适的flushInterval
- 在更新操作后立即提交事务
4.2 分布式环境下的缓存一致性问题
在集群环境中,本地缓存(一级和二级)会导致各个节点数据不一致。常见解决方案:
- 禁用二级缓存,使用集中式缓存如Redis
- 实现自定义的Cache接口集成Redis
- 使用消息队列通知各节点失效缓存
xml复制<!-- 自定义Redis缓存实现 -->
<cache type="com.example.cache.RedisCache">
<property name="host" value="redis.example.com"/>
</cache>
4.3 缓存雪崩与击穿
当大量缓存同时失效或热点数据失效时,可能导致数据库压力激增:
- 雪崩解决方案:设置不同的过期时间
- 击穿解决方案:使用互斥锁
java复制// 伪代码:使用互斥锁防止缓存击穿
public User getUserById(Long id) {
User user = cache.get(id);
if (user == null) {
synchronized (this) {
user = cache.get(id);
if (user == null) {
user = db.query(id);
cache.put(id, user);
}
}
}
return user;
}
5. 性能优化实践
5.1 缓存命中率监控
通过实现自定义Cache接口,可以统计缓存命中率:
java复制public class MonitorCache implements Cache {
private final Cache delegate;
private AtomicLong hits = new AtomicLong();
private AtomicLong misses = new AtomicLong();
@Override
public Object getObject(Object key) {
Object value = delegate.getObject(key);
if (value != null) {
hits.incrementAndGet();
} else {
misses.incrementAndGet();
}
return value;
}
public double getHitRatio() {
return (double)hits.get() / (hits.get() + misses.get());
}
}
5.2 合理的缓存粒度设计
缓存粒度太细(如每条记录单独缓存)会导致缓存利用率低;太粗(如整个表缓存)会导致频繁失效。建议:
- 高频查询但很少修改的数据适合缓存
- 关联查询结果可以整体缓存
- 实时性要求高的数据不适合缓存
5.3 批量操作时的缓存处理
批量插入/更新时,默认会清空整个缓存,这可能不是最优的。可以通过@CacheNamespace注解实现更精细的控制:
java复制@CacheNamespace(
implementation = MyCustomCache.class,
eviction = MyCustomEviction.class
)
public interface UserMapper {
@Options(flushCache = Options.FlushCachePolicy.FALSE)
void batchInsert(List<User> users);
}
6. 最佳实践总结
经过多年使用Mybatis的经验,我总结出以下缓存使用原则:
- 理解默认行为:一级缓存始终开启且无法禁用,二级缓存需要显式配置
- 合理选择缓存级别:优先考虑一级缓存,二级缓存要评估数据特性和一致性要求
- 注意事务边界:事务提交后才会将一级缓存刷入二级缓存
- 监控缓存效果:定期检查缓存命中率和内存占用
- 分布式环境特殊处理:考虑集中式缓存替代本地缓存
- 及时处理失效场景:对于关键业务数据,必要时主动清空缓存
最后分享一个实用技巧:在开发环境中,可以通过在mybatis-config.xml添加如下配置,输出缓存命中日志:
xml复制<settings>
<setting name="logImpl" value="STDOUT_LOGGING"/>
</settings>
这样可以在控制台看到每次查询是否命中了缓存,便于调试和优化。