Mybatis缓存机制爬坑记录

背景

笔者某次业务开发中遇到一个诡异的数据不一致现象:使用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级),但现在又需要让某条语句的一级缓存失效,该如何操作?

  1. 声明式清除:在 Mapper.xml 的 SQL 语句中设置 flushCache="true",查询后强制清理一级缓存。
java 复制代码
<select id="selectByName" resultMap="BaseResultMap" flushCache="true">
    ...
</select>
  1. 编程式清除:查询前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();
    }
}
  1. 反射清除:调用 SqlSessionclearCache() 方法
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共享
默认开启
适用场景 单次会话中频繁查询 多线程访问相同数据,且读多写少
性能影响 频繁修改时影响较高

总结

框架的便利性往往伴随着隐形成本。在提升开发效率的同时,也暗藏认知债务。当开发者不了解其原理时,犹如在冰面起舞。所谓知己知彼,多去理解框架的实现细节,才能避免落入这种诡异的陷阱。

相关推荐
刘鹏3782 分钟前
深入浅出Java中的CAS:原理、源码与实战应用
后端
当归10245 分钟前
微服务与消息队列RabbitMQ
java·微服务
Lx3526 分钟前
《从头开始学java,一天一个知识点》之:循环结构:for与while循环的使用场景
java·后端
fliter7 分钟前
RKE1、K3S、RKE2 三大 Kubernetes 发行版的比较
后端
aloha_7 分钟前
mysql 某个客户端主机在短时间内发起了大量失败的连接请求时
后端
程序员爱钓鱼9 分钟前
Go 语言高效连接 SQL Server(MSSQL)数据库实战指南
后端·go·sql server
xjz18429 分钟前
Java AQS(AbstractQueuedSynchronizer)实现原理详解
后端
Victor35610 分钟前
Zookeeper(97)如何在Zookeeper中实现分布式协调?
后端
至暗时刻darkest10 分钟前
go mod文件 项目版本管理
开发语言·后端·golang
程序员爱钓鱼10 分钟前
Go 语言高效连接 MySQL 数据库:从入门到实战
后端·mysql·go