背景
笔者某次业务开发中遇到一个诡异的数据不一致现象:使用mybatis进行前后两次相同的sql查询,得到的结果居然不同,而中途并没有修改数据库。具体表现为:
java
public class Test {
public static void main(String[] args) {
User user = userMapper.selectByName("A");
System.out.println(user.getName()); // 输出结果:A
user1.setName("B");
System.out.println(userMapper.selectByName("A").getName()); // 输出结果:B
}
}
这似乎是挺反直觉的事。仔细核对代码才发现,第二次查询前,对第一次查询的对象进行了修改。而前后两次查询似乎返回的是同一个对象。此时才突然想起mybatis还有缓存的功能。由于mybatis二级缓存默认关闭,只能是一级缓存导致了这种现象。关闭一级缓存后,两次查询结果便相同了。所以问题的本质在于:MyBatis一级缓存返回的是对象引用,而非数据副本,若缓存对象被修改,后续查询获取到的是被污染的缓存对象。原来mybatis在背后默默付出了这么多。使用了很久mybatis,今天一不小心摔进了坑里,还是需要稍微了解一下缓存机制。
一级缓存机制解析
为什么一级缓存不返回深拷贝的值
虽然mapper返回的是旧对象是很反直觉的事,但深拷贝会消耗额外的时间和内存,为了提升性能和简化设计,mybatis一级缓存直接返回了同一引用。如果需要修改缓存对象,又担心后人需要使用缓存,可以先进行防御性拷贝
缓存的作用
实际开发中,并不一定会把所有数据都进行参数传递,在不同的方法执行多次查询条件完全相同的SQL很正常,这时候MyBatis 的一级缓存就派上了用场,如果是相同的SQL语句,不会再去数据库查询,而是直接返回缓存中的数据
存储结构与查询
一级缓存是BaseExecutor
类中的一个 PerpetualCache
对象。key由Mapper方法全限定名、SQL语句(包括查询参数、分页)等构成
java
// org.apache.ibatis.executor.BaseExecutor
public abstract class BaseExecutor implements Executor {
protected PerpetualCache localCache;
}
缓存查询流程
第一次执行时从数据库查询,并将结果对象放入缓存。第二次查询时,由于cacheKey
相同,所以返回了之前缓存的对象。
java
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
try {
queryStack++;
// 先尝试从缓存localCache查询
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
// 无缓存,则从数据库查询,并放入缓存
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
queryStack--;
}
if (queryStack == 0) {
// 如果缓存作用范围是单条语句(对应枚举为STATEMENT),则清空缓存
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
clearLocalCache();
}
}
return list;
}
缓存控制策略
如何关闭一级缓存(作用域配置)
考虑到很多开发可能对mybatis缓存不够熟悉,为了避免后人踩坑,在缓存命中率不高的情况下,最好还是关闭一级缓存。但mybatis其实并不支持关闭一级缓存,只能修改一级缓存作用范围。SESSION
表示当前sqlSession均可用,是默认的作用范围;STATEMENT
表示仅当前语句有效。改为STATEMENT
就达到了预期的效果
yaml
mybatis:
configuration:
local-cache-scope: statement # 修改一级缓存作用范围
如何让一级缓存失效(精确控制)
如果项目中之前有依靠一级缓存运行的代码,便不适合全局关闭一级缓存(session级),但现在又需要让某条语句的一级缓存失效,该如何操作?
- 声明式清除:在 Mapper.xml 的 SQL 语句中设置
flushCache="true"
,查询后强制清理一级缓存。
java
<select id="selectByName" resultMap="BaseResultMap" flushCache="true">
...
</select>
- 编程式清除:查询前mapper执行更新操作(如
insert/update/delete
),会触发一级缓存清理
java
public int update(MappedStatement ms, Object parameter) throws SQLException {
// 清理一级缓存
clearLocalCache();
return doUpdate(ms, parameter);
}
public void clearLocalCache() {
if (!closed) {
localCache.clear();
}
}
- 反射清除:调用
SqlSession
的clearCache()
方法
java
Field sqlSessionField = Proxy.getInvocationHandler(userShopMapper).getClass().getDeclaredField("sqlSession");
sqlSessionField.setAccessible(true);
SqlSession sqlSession = (SqlSession) sqlSessionField.get(Proxy.getInvocationHandler(userShopMapper));
sqlSession.clearCache();
二级缓存与一级缓存的区别
二级缓存是指定Mapper或sql级别的缓存,可跨SqlSession共享数据,适用于多线程访问相同数据,并且数据读多写少的场景。但实用性较低,默认是关闭的
特性 | 一级缓存 | 二级缓存 |
---|---|---|
作用范围 | SqlSession | Mapper级,跨SqlSession共享 |
默认开启 | 是 | 否 |
适用场景 | 单次会话中频繁查询 | 多线程访问相同数据,且读多写少 |
性能影响 | 低 | 频繁修改时影响较高 |
总结
框架的便利性往往伴随着隐形成本。在提升开发效率的同时,也暗藏认知债务。当开发者不了解其原理时,犹如在冰面起舞。所谓知己知彼,多去理解框架的实现细节,才能避免落入这种诡异的陷阱。