MyBatis缓存机制详解

MyBatis缓存机制详解

  • 1. MyBatis缓存
    • 1.1 MyBatis缓存概述
    • 1.2 MyBatis一二级缓存区别
  • 2. MyBatis一级缓存
    • 2.1 MyBatis一级缓存概述
    • 2.2 MyBatis一级缓存配置
    • 2.3 MyBatis一级缓存原理分析
    • 2.4 MyBatis一级缓存总结
  • 3. MyBatis二级缓存
    • 3.1 MyBatis二级缓存概述
    • 3.2 MyBatis二级缓存配置
    • 3.3 MyBatis二级缓存原理分析
    • 3.4 MyBatis二级缓存总结
  • 4. MyBatis缓存测试
  • 5. 参考文档

1. MyBatis缓存

1.1 MyBatis缓存概述

MyBatis作为目前最常用的ORM数据库访问持久层框架,其本身支持动态SQL存储映射等高级特性也非常优秀,通过Mapper文件采用动态代理模式使SQL与业务代码相解耦,日常开发中使用也非常广泛,本文主要讨论mybatis缓存功能,mybatis缓存本身设计初衷是为了解决同一会话相同查询的效率问题,单机环境下也确实起到了提高查询效率的作用,但是随着业务场景变化以及分布式微服务的出现,其弊端也渐渐显现出来,不同会话间操作数据,关联查询数据采用mybatis缓存时会存在出现脏数据的风险。

1.2 MyBatis一二级缓存区别

1.Mybatis一级缓存是SQLSession级别的,一级缓存的作用域是SQlSession;Mabits一级缓存默认是开启的。 在同一个SqlSession中,执行相同的SQL查询时;第一次会去查询数据库,并写在缓存中,第二次会直接从缓存中取。 在同一次会话中执行两次相同查询中间执行了更新操作的时候,缓存会被清空,第二次相同查询仍然会去查询数据库。

2.Mybatis二级缓存是Mapper级别的,二级缓存的作用域是全局的,多个SQlSession共享的,二级缓存的作用域更大;Mybatis二级缓存默认是没有开启的。 第一次调用mapper下的SQL去查询用户的信息,查询到的信息会存放在该mapper对应的二级缓存区域。 第二次调用namespace下的mapper映射文件中,相同的sql去查询用户信息,会去对应的二级缓存内取结果。

2. MyBatis一级缓存

2.1 MyBatis一级缓存概述

默认情况下,只启用了本地的会话缓存,也就是一级缓存,它仅仅对一个会话中的数据进行缓存。 mybatis一级缓存指的是在应用运行过程中,一次数据库会话中,执行多次相同的查询,会优先查询缓存中的数据,减少数据库查询次数,提高查询效率。
每个SqlSession中持有了Executor,每个Executor中有一个LocalCache。当用户发起查询时,MyBatis根据当前执行的语句生成MappedStatement,在Local Cache进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入Local Cache,最后返回结果给用户。

2.2 MyBatis一级缓存配置

mybatis一级缓存默认是开启的,可根据需要选择级别是session或这statement。开发者只需在MyBatis的配置文件中,添加如下语句,就可以使用一级缓存。共有两个选项,session或者statement,默认是session级别,即在一个MyBatis会话中执行的所有语句,都会共享这一个缓存。一种是statement级别,可以理解为缓存只对当前执行的这一个Statement有效。

<setting name="localCacheScope" value="SESSION"/>

2.3 MyBatis一级缓存原理分析

1.在初始化SqlSesion时,会使用Configuration类创建一个全新的Executor,作为DefaultSqlSession构造函数的参数。

    // newExecutor 尤其可以注意这里,如果二级缓存开关开启的话,是使用CahingExecutor装饰BaseExecutor的子类
    if (cacheEnabled) {
      executor = new CachingExecutor(executor);                      
    }

2.SqlSession创建完毕后,根据Statment的不同类型,会进入SqlSession的不同方法中,如果是Select语句的话,最后会执行到SqlSession的selectList,代码如下所示:

@Override
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
      MappedStatement ms = configuration.getMappedStatement(statement);
      return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
}

3.SqlSession把具体的查询职责委托给了Executor。如果只开启了一级缓存的话,首先会进入BaseExecutor的query方法。代码如下所示:

@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameter);
    CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
    return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

4.在上述代码中,会先根据传入的参数生成CacheKey,进入该方法查看CacheKey是如何生成的,代码如下所示:

CacheKey cacheKey = new CacheKey();
cacheKey.update(ms.getId());
cacheKey.update(rowBounds.getOffset());
cacheKey.update(rowBounds.getLimit());
cacheKey.update(boundSql.getSql());
//后面是update了sql中带的参数
cacheKey.update(value);

在上述的代码中,将MappedStatement的Id、SQL的offset、SQL的limit、SQL本身以及SQL中的参数传入了CacheKey这个类,最终构成CacheKey。以下是这个类的内部结构:

public CacheKey() {
    this.hashcode = DEFAULT_HASHCODE;
    this.multiplier = DEFAULT_MULTIPLYER;
    this.count = 0;
    this.updateList = new ArrayList<Object>();
}

首先是成员变量和构造函数,有一个初始的hachcode和乘数,同时维护了一个内部的updatelist。在CacheKey的update方法中,会进行一个hashcode和checksum的计算,同时把传入的参数添加进updatelist中。如下代码所示:

public void update(Object object) {
    int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object); 
    count++;
    checksum += baseHashCode;
    baseHashCode *= count;
    hashcode = multiplier * hashcode + baseHashCode;
    
    updateList.add(object);
}

除去hashcode、checksum和count的比较外,只要updatelist中的元素一一对应相等,那么就可以认为是CacheKey相等。只要两条SQL的下列五个值相同,即可以认为是相同的SQL。
Statement Id + Offset + Limmit + Sql + Params

5.BaseExecutor的query方法继续往下走,代码如下所示:

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);
}

如果查不到的话,就从数据库查,在queryFromDatabase中,会对localcache进行写入。

在query方法执行的最后,会判断一级缓存级别是否是STATEMENT级别,如果是的话,就清空缓存,这也就是STATEMENT级别的一级缓存无法共享localCache的原因。代码如下所示:

if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        clearLocalCache();
}

在源码分析的最后,我们确认一下,如果是insert/delete/update方法,缓存就会刷新的原因。
SqlSession的insert方法和delete方法,都会统一走update的流程,代码如下所示:

@Override
public int insert(String statement, Object parameter) {
    return update(statement, parameter);
  }
   @Override
  public int delete(String statement) {
    return update(statement, null);
}

update方法也是委托给了Executor执行。BaseExecutor的执行方法如下所示:

@Override
public int update(MappedStatement ms, Object parameter) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    clearLocalCache();
    return doUpdate(ms, parameter);
}

每次执行update前都会清空localCache。

2.4 MyBatis一级缓存总结

1.MyBatis一级缓存的生命周期和SqlSession一致。
2.MyBatis一级缓存内部设计简单,只是一个没有容量限定的HashMap,在缓存的功能性上有所欠缺。
3.MyBatis的一级缓存最大范围是SqlSession内部,有多个SqlSession或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为Statement。

3. MyBatis二级缓存

3.1 MyBatis二级缓存概述

在上文中提到的一级缓存中,其最大的共享范围就是一个SqlSession内部,如果多个SqlSession之间需要共享缓存,则需要使用到二级缓存。开启二级缓存后,会使用CachingExecutor装饰Executor,进入一级缓存的查询流程前,先在CachingExecutor进行二级缓存的查询,具体的工作流程如下所示。
MyBatis缓存机制详解_第1张图片

3.2 MyBatis二级缓存配置

要正确的使用二级缓存,需完成如下配置的。
1.在MyBatis的配置文件中开启二级缓存。

<setting name="cacheEnabled" value="true"/>

2.在MyBatis的映射XML中配置cache或者 cache-ref 。
cache标签用于声明这个namespace使用二级缓存,并且可以自定义配置。

<cache/>   
type:cache使用的类型,默认是PerpetualCache,这在一级缓存中提到过。
eviction: 定义回收的策略,常见的有FIFO,LRU。
flushInterval: 配置一定时间自动刷新缓存,单位是毫秒。
size: 最多缓存对象的个数。
readOnly: 是否只读,若配置可读写,则需要对应的实体类能够序列化。
blocking: 若缓存中找不到对应的key,是否会一直blocking,直到有对应的数据进入缓存。

3.cache-ref代表引用别的命名空间的Cache配置,两个命名空间的操作使用的是同一个Cache。

<cache-ref namespace="mapper.StudentMapper"/>

3.3 MyBatis二级缓存原理分析

源码分析从CachingExecutor的query方法展开,源代码走读过程中涉及到的知识点较多,不能一一详细讲解,读者朋友可以自行查询相关资料来学习。
CachingExecutor的query方法,首先会从MappedStatement中获得在配置初始化时赋予的Cache。

Cache cache = ms.getCache();

本质上是装饰器模式的使用,具体的装饰链是:
SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache。

以下是具体这些Cache实现类的介绍,他们的组合为Cache赋予了不同的能力。

SynchronizedCache:同步Cache,实现比较简单,直接使用synchronized修饰方法。
LoggingCache:日志功能,装饰类,用于记录缓存的命中率,如果开启了DEBUG模式,则会输出命中率日志。
SerializedCache:序列化功能,将值序列化后存到缓存中。该功能用于缓存返回一份实例的Copy,用于保存线程安全。
LruCache:采用了Lru算法的Cache实现,移除最近最少使用的Key/Value。
PerpetualCache: 作为为最基础的缓存类,底层实现比较简单,直接使用了HashMap。

3.4 MyBatis二级缓存总结

1.MyBatis的二级缓存相对于一级缓存来说,实现了SqlSession之间缓存数据的共享,同时粒度更加的细,能够到namespace级别,通过Cache接口实现类不同的组合,对Cache的可控性也更强。
2.MyBatis在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用二级缓存的条件比较苛刻。
3.在分布式环境下,由于默认的MyBatis Cache实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将MyBatis的Cache接口实现,有一定的开发成本,直接使用Redis、Memcached等分布式缓存可能成本更低,安全性也更高。

4. MyBatis缓存测试

测试案例地址:https://gitee.com/rjzhu/opencode/tree/master/mybatis-cache-demo

/**
 * MyBatis缓存测试类
 */
public class StudentMapperTest {

    private SqlSessionFactory factory;

    /**
     * 初始化SqlSessionFactory
     */
    @Before
    public void setUp() throws Exception {
        factory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("mybatis-config.xml"));
    }

    /**
     * 查看缓存配置是否生效
     * 
     * 
     */
    @Test
    public void showDefaultCacheConfiguration() {
        System.out.println("本地缓存范围: " + factory.getConfiguration().getLocalCacheScope());
        System.out.println("二级缓存是否被启用: " + factory.getConfiguration().isCacheEnabled());
    }

    /**
     * MyBatis缓存测试一
     * 测试:同一个会话,相同查询连续查询三次
     * 结果:第一次查询数据库,二三次查询从缓存读取
     * 结论:同一个会话,多次相同查询,只有第一次查询数据库,其他都是缓存中获取,提高了查询效率
     */
    @Test
    public void testLocalCache() {
        SqlSession sqlSession = factory.openSession(true); // 自动提交事务
        StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);

        //第一次查询数据库,二三次查询直接从缓存读取
        System.out.println("第一次查询:" + studentMapper.getStudentById(1));
        System.out.println("第二次查询:" + studentMapper.getStudentById(1));
        System.out.println("第三次查询:" + studentMapper.getStudentById(1));

        sqlSession.close();
    }

    /**
     * MyBatis缓存测试二
     * 测试:同一个会话,先查询,再新增,再次重复第一次查询
     * 结果:第一次与第二次查询都查询数据库,修改操作后执行的相同查询,查询了数据库,一级缓存失效。
     * 结论:同一个会话执行更新操作后缓存失效,源码中会清空缓存
     */
    @Test
    public void testLocalCacheClear() {
        SqlSession sqlSession = factory.openSession(true); // 自动提交事务
        StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);

        //第一次与第二次查询都查询数据库,修改操作后执行的相同查询,查询了数据库,一级缓存失效。
        System.out.println("第一次查询:" + studentMapper.getStudentById(1));
        System.out.println("增加了" + studentMapper.addStudent(StudentEntity.builder().name("明明").age(20).build()) + "个学生");
        System.out.println("第二次查询:" + studentMapper.getStudentById(1));

        sqlSession.close();
    }

    /**
     * MyBatis缓存测试三
     * 测试:同时开启两个会话,会话一连续两次查询,会话二更新操作,会话一再次相同查询,会话二相同查询
     * 结果:会话一第一次查询数据库,第二次查询缓存,会话二更新完成,会话一再次相同查询仍然查询缓存(读取脏数据),会话二查询数据库获取最新数据。
     * 结论:缓存作用范围是一个会话当中,当其中有会话更新数据,其他会话会读取到脏数据
     */
    @Test
    public void testLocalCacheScope() {
        //开启两个SqlSession,在sqlSession1中查询数据,使一级缓存生效,在sqlSession2中更新数据库
        //验证一级缓存只在数据库会话内部共享。
        SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
        SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务

        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);

        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2更新了" + studentMapper2.updateStudentName("小岑", 1) + "个学生的数据");
        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
    }

    /**
     * MyBatis缓存测试四
     * 测试:同时开启两个会话,两个会话执行相同的查询
     * 结果:两次都是查询数据库
     * 结论:缓存作用范围是一个会话当中,不同会话,即使是相同查询,只使用各自的缓存
     */
    @Test
    public void testCacheWithoutCommitOrClose() {
        SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
        SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务

        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);

        //两次都是从数据库读取,说明需要提交事务,第二次查询才能走缓存
        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));

    }

    /**
     * MyBatis缓存测试四
     * 测试:同时开启两个会话,两个会话执行相同的查询
     * 结果:两次都是查询数据库
     * 结论:缓存作用范围是一个会话当中,不同会话,即使是相同查询,只使用各自的缓存
     */
    @Test
    public void testCacheWithCommitOrClose() {
        SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
        SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务

        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);

        //第一次提交以后,第二次走缓存
        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
        sqlSession1.close();
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));

    }

    /**
     * MyBatis缓存测试五
     * 测试:同时开启三个会话,通过接口方式,会话一查询后提交事务,会话二执行相同查询,缓存查询,会话三更新提交事务,会话二查询缓存
     * 结果:只有第一次查询数据库,其余都是查询缓存
     * 结论:只有提交事务以后,后续相同查询才会查询缓存,否则查询数据库
     */
    @Test
    public void testCacheWithUpdate() {
        SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
        SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务
        SqlSession sqlSession3 = factory.openSession(true); // 自动提交事务

        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
        StudentMapper studentMapper3 = sqlSession3.getMapper(StudentMapper.class);

        System.out.println("studentMapper1读取数据: " + studentMapper.getStudentById(1));
        sqlSession1.close();
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));

        studentMapper3.updateStudentName("方方", 1);
        sqlSession3.commit();
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
    }

    /**
     * MyBatis缓存测试六
     * 测试:测试关联查询,出现脏数据问题,
     * 结论:不同会话之间关联查询的时候,其中会话更新单独更新关联的其中一个表,另一个会话感知不到,在不同的mapper文件中,缓存查询会出现脏数据情况
     */
    @Test
    public void testCacheWithDiffererntNamespace() {
        // 设置自动提交事务
        SqlSession sqlSession1 = factory.openSession(true);
        SqlSession sqlSession2 = factory.openSession(true);
        SqlSession sqlSession3 = factory.openSession(true);

        StudentMapper studentMapper1 = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
        ClassMapper classMapper3 = sqlSession3.getMapper(ClassMapper.class);

        System.out.println("studentMapper1读取数据: " + studentMapper1.getStudentByIdWithClassInfo(1));
        sqlSession1.close();

        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));

        //更新数据
        classMapper3.updateClassName("特色一班", 1);
        sqlSession3.commit();

        //读取到脏数据,studentMapper2读取数据: StudentEntity(id=1, name=方方, age=16, className=一班)
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));
    }

}

5. 参考文档

MyBatis中文网:https://mybatis.net.cn/index.html
MyBatis英文网:https://mybatis.org/mybatis-3/index.html
MyBatis执行流程源码分析:https://blog.csdn.net/m0_37583655/article/details/122115750
聊聊MyBatis缓存机制
mybatis一级缓存和二级缓存的区别是什么

你可能感兴趣的:(基础框架,MyBatis缓存机制详解)