MyBatis一级缓存和二级缓存

176 阅读8分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

MyBatis包含一个非常强大的查询缓存特性,它可以非常方便地定制和配置缓存。

MyBatis系统中默认定义了两级缓存:一级缓存二级缓存

  • 默认情况下,只有一级缓存开启。(SqlSession级别的缓存,也称为本地缓存)
  • 二级缓存需要手动开启和配置,他是基于namespace级别的缓存
  • 为了提高扩展性,MyBatis定义了缓存接口Cache,我们可以通过实现Cache接口来自定义二级缓存

1. 一级缓存

一级缓存也叫本地缓存:

  • 与数据库同一次会话期间查询到的数据会放在本地缓存中
  • 以后如果需要获取相同的数据,直接从缓存中拿

1.1 测试使用

mapper层接口方法

// 根据id查询
User selectById(int id);

mapper.xml

<select id="selectUser" resultMap="BaseResultMap">
    select
    id, `name`, pwd
    from `user`
</select>
<resultMap id="BaseResultMap" type="user">
    <id column="id" jdbcType="INTEGER" property="id"/>
    <result column="name" jdbcType="VARCHAR" property="name"/>
    <result column="pwd" jdbcType="VARCHAR" property="pwd"/>
</resultMap>

测试方法

/**
 * 测试根据id查询
 */
@Test
public void selectById(){
    SqlSession session = MybatisConfig.getSession();
    UserMapper mapper = session.getMapper(UserMapper.class);
    
    User user = mapper.selectById(1);
    System.out.println("user1:"+user1);
    
    User user2 = mapper.selectById(1);
    System.out.println("user2:"+user2);
    
    System.out.println("user1==user2? "+(user1==user2));
    
    // 关闭连接
    session.close();
}

1.2 一级缓存失效的几种情况

(1)sqlSession不同

/**
 * 测试缓存失效 -- sqlSession不同
 */
@Test
public void selectById1(){
    SqlSession session1 = MybatisConfig.getSession();
    SqlSession session2 = MybatisConfig.getSession();
    
    UserMapper mapper1 = session1.getMapper(UserMapper.class);
    UserMapper mapper2 = session2.getMapper(UserMapper.class);

    User user1 = mapper1.selectById(1);
    System.out.println("user1:"+user1);

    // 关闭连接1
    session1.close();

    User user2 = mapper2.selectById(1);
    System.out.println("user2:"+user2);

    System.out.println("user1==user2? "+(user1==user2));

    //关闭连接2
    session2.close();
}

测试结果,每个sqlSession中的缓存相互独立

(2)sqlSession相同,两次查询之间执行了写操作

mapper层新增更新接口

int updateUser(User user);

mapper.xml

<update id="updateUser">
    update user
    set name=#{name},
        pwd=#{pwd}
    where id = #{id}
</update>

测试方法

/**
 * 测试缓存失效 -- sqlSession相同,两次查询之间执行了写操作
 */
@Test
public void selectById2(){
    SqlSession session = MybatisConfig.getSession();
    UserMapper mapper = session.getMapper(UserMapper.class);

    User user1 = mapper.selectById(1);
    System.out.println("user1:"+user1);
    
    //更新id为1的用户信息
    user1.setPwd("123456update");
    int i = mapper.updateUser(user1);
    if (i > 0){
        logger.debug("更新成功,i="+i);
    }else {
        logger.debug("更新失败,i="+i);
    }

    User user2 = mapper.selectById(1);
    System.out.println("user2:"+user2);

    System.out.println("user1==user2? "+(user1==user2));

    // 关闭连接
    session.close();
}

(3)sqlSession相同,手动清除一级缓存

/**
 * 测试缓存失效 -- sqlSession相同,手动清除一级缓存
 */
@Test
public void selectById3(){
    SqlSession session = MybatisConfig.getSession();
    UserMapper mapper = session.getMapper(UserMapper.class);

    User user1 = mapper.selectById(1);
    System.out.println("user1:"+user1);

    // 手动清除一级缓存
    session.clearCache();
    
    User user2 = mapper.selectById(1);
    System.out.println("user2:"+user2);

    System.out.println("user1==user2? "+(user1==user2));

    // 关闭连接
    session.close();
}

总结

  • 第一次发起查询用户id为1的用户信息,先去找缓存中是否有id为1的用户信息,如果没有,从数据库查询用户信息。得到用户信息,将用户信息存储到一级缓存中。
  • 如果中间sqlSession去执行commit操作(执行插入、更新、删除),则会清空SqlSession中的一级缓存,这样做的目的为了让缓存中存储的是最新的信息,避免脏读
  • 第二次发起查询用户id为1的用户信息,先去找缓存中是否有id为1的用户信息,缓存中有,直接从缓存中获取用户信息

2. 二级缓存

二级缓存也叫全局缓存,是基于namespace级别的缓存,一个名称空间,对应一个二级缓存

二级缓存的工作机制:

  • 一个会话查询一条数据,这个数据就会被放在当前会话的一级缓存中
  • 如果当前会话关闭了,这个会话对应的一级缓存就消失,而当会话关闭时,一级缓存中的数据被保存到二级缓存中
  • 新的会话查询信息,就可以从二级缓存中获取内容
  • 不同的mapper查出的数据会放在自己对应的缓存(map)中

2.1 测试使用

在MybatisConfig.xml中开启全局缓存

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

要启用全局的二级缓存,只需要在你的 SQL 映射文件(XxxMapper.xml)中添加一行

<cache/>

这个简单语句的效果如下:

  • 映射语句文件中的所有 select 语句的结果将会被缓存。
  • 映射语句文件中的所有 insert、update 和 delete 语句会刷新缓存。
  • 缓存会使用最近最少使用算法(LRU, Least Recently Used)算法来清除不需要的缓存。
  • 缓存不会定时进行刷新(也就是说,没有刷新间隔)。
  • 缓存会保存列表或对象(无论查询方法返回哪种)的 1024 个引用。
  • 缓存会被视为读/写缓存,这意味着获取到的对象并不是共享的,可以安全地被调用者修改,而不干扰其他调用者或线程所做的潜在修改。

缓存只作用于 cache 标签所在的映射文件中的语句。如果你混合使用 Java API 和 XML 映射文件,在共用接口中的语句将不会被默认缓存。需要使用 @CacheNamespaceRef 注解指定缓存作用域。

这些属性可以通过 cache 元素的属性来修改。比如:

<cache
  eviction="FIFO"
  flushInterval="60000"
  size="512"
  readOnly="true"/>

这个更高级的配置创建了一个 FIFO 缓存,每隔 60 秒刷新,最多可以存储结果对象或列表的 512 个引用,而且返回的对象被认为是只读的,因此对它们进行修改可能会在不同线程中的调用者产生冲突。

可用的清除策略有:

  • LRU – 最近最少使用:移除最长时间不被使用的对象。
  • FIFO – 先进先出:按对象进入缓存的顺序来移除它们。
  • SOFT – 软引用:基于垃圾回收器状态和软引用规则移除对象。
  • WEAK – 弱引用:更积极地基于垃圾收集器状态和弱引用规则移除对象。

默认的清除策略是 LRU。

flushInterval(刷新间隔)属性可以被设置为任意的正整数,设置的值应该是一个以毫秒为单位的合理时间量。 默认情况是不设置,也就是没有刷新间隔,缓存仅仅会在调用语句时刷新。

size(引用数目)属性可以被设置为任意正整数,要注意欲缓存对象的大小和运行环境中可用的内存资源。默认值是 1024。

readOnly(只读)属性可以被设置为 true 或 false。只读的缓存会给所有调用者返回缓存对象的相同实例。 因此这些对象不能被修改。这就提供了可观的性能提升。而可读写的缓存会(通过序列化)返回缓存对象的拷贝。 速度上会慢一些,但是更安全,因此默认值是 false。

测试方法:

/**
 * 二级缓存
 */
@Test
public void selectById4(){
    SqlSession session1 = MybatisConfig.getSession();
    SqlSession session2 = MybatisConfig.getSession();
    
    UserMapper mapper1 = session1.getMapper(UserMapper.class);
    UserMapper mapper2 = session2.getMapper(UserMapper.class);

    User user1 = mapper1.selectById(1);
    System.out.println("user1:"+user1);
    session1.close();
    
    User user2 = mapper2.selectById(1);
    System.out.println("user2:"+user2);
    session2.close();
    
    System.out.println("user1==user2? "+(user1==user2));
}

开启了二级缓存后,还需要将要缓存的pojo实现Serializable接口,为了将缓存数据取出执行反序列化操作,因为二级缓存数据存储介质多种多样,不一定只存在内存中,有可能存在硬盘中,如果我们要再取这个缓存的话,就需要反序列化了。所以mybatis中的pojo都去实现Serializable接口。

测试结果 ,只要开启了二级缓存,我们在同一个Mapper中的查询,可以在二级缓存中拿到数据

查出的数据都会被默认先放在一级缓存中,只有会话提交或者关闭以后,一级缓存中的数据才会转到二级缓存中

如果一个Mapper需要开启二级缓存,但是这里面的某些查询对数据的实时性要求很高,不想走缓存,那么我们可以在单个Statement ID上配置关闭二级缓存

useCache="false"

3. 缓存原理

当开一个会话时,一个SqlSession对象会使用一个Executor对象来完成会话操作,MyBatis的二级缓存机制的关键就是对这个Executor对象做文章。如果用户配置了"cacheEnabled=true",那么MyBatis在为SqlSession对象创建Executor对象时,会对Executor对象加上一个装饰者:CachingExecutor,这时SqlSession使用CachingExecutor对象来完成操作请求。CachingExecutor对于查询请求,会先判断该查询请求在Application级别的二级缓存中是否有缓存结果,如果有查询结果,则直接返回缓存结果;如果缓存中没有,再交给真正的Executor对象来完成查询操作,之后CachingExecutor会将真正Executor返回的查询结果放置到缓存中,然后在返回给用户。

4. 第三方做二级缓存

除了MyBatis自带的二级缓存外,也可以通过实现Cache接口来定义二级缓存。
MyBatis官方提供了一些第三方缓存集成方式,如:ehcache和redis

EhCache

maven依赖

<!-- https://mvnrepository.com/artifact/org.mybatis.caches/mybatis-ehcache -->
<dependency>
    <groupId>org.mybatis.caches</groupId>
    <artifactId>mybatis-ehcache</artifactId>
    <version>1.2.1</version>
</dependency>

在mapper.xml中使用ehcache缓存

<!--  使用自定义缓存实现  -->
<cache type="org.mybatis.caches.ehcache.EhcacheCache"/>

ehcache.xml配置文件

<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd"
         updateCheck="false">
    <!--
        diskStore:为缓存路径,ehcache分为内存和磁盘两级,此属性定义磁盘的缓存位置。
        参数解释如下:
            user.home – 用户主目录
            user.dir – 用户当前工作目录
            java.io.tmpdir – 默认临时文件路径
     -->
    <diskStore path="./tmpdir/Tmp_EhCache"/>

    <!--defaultCache:默认缓存策略,当ehcache找不到定义的缓存时,则使用这个缓存策略。只能定义一个。 -->
    <defaultCache eternal="false"
                  maxElementsInMemory="10000"
                  overflowToDisk="false"
                  diskPersistent="false"
                  timeToIdleSeconds="1800"
                  timeToLiveSeconds="259200"
                  memoryStoreEvictionPolicy="LRU"/>

    <!--
        以下属性是必须的: 
            name: Cache的名称,必须是唯一的(ehcache会把这个cache放到HashMap里)。maxElementsInMemory:在内存中缓存的element的最大数目。 
            eternal:设定缓存的elements是否永远不过期。如果为true,则缓存的数据始终有效,如果为false那么还要根据timeToIdleSeconds,timeToLiveSeconds判断。 
            maxElementsInMemory:cache 中最多可以存放的元素的数量。如果放入cache中的元素超过这个数值,有两种情况: 
                1、若overflowToDisk的属性值为true,会将cache中多出的元素放入磁盘文件中。 
                2、若overflowToDisk的属性值为false,会根据memoryStoreEvictionPolicy的策略替换cache中原有的元素。 
            overflowToDisk: 如果内存中数据超过内存限制,是否要缓存到磁盘上。 
            maxElementsOnDisk:在磁盘上缓存的element的最大数目,默认值为0,表示不限制。 

        以下属性是可选的: 
            timeToIdleSeconds: 对象空闲时间,指对象在多长时间没有被访问就会失效。只对eternal为false的有效。默认值0,表示一直可以访问。以秒为单位。 
            timeToLiveSeconds: 对象存活时间,指对象从创建到失效所需要的时间。只对eternal为false的有效。默认值0,表示一直可以访问。以秒为单位。 
            diskPersistent: 是否在磁盘上持久化。指重启jvm后,数据是否有效。默认为false。 
            diskExpiryThreadIntervalSeconds: 对象检测线程运行时间间隔。标识对象状态的线程多长时间运行一次。以秒为单位。 
            diskSpoolBufferSizeMB: DiskStore使用的磁盘大小,默认值30MB。每个cache使用各自的DiskStore。 
            memoryStoreEvictionPolicy: 如果内存中数据超过内存限制,向磁盘缓存时的策略。默认值LRU,可选FIFO、LFU。 
        
        缓存的3 种清空策略 : 
            FIFO ,first in first out (先进先出). 
            LFU , Less Frequently Used (最少使用).意思是一直以来最少被使用的。缓存的元素有一个hit 属性,hit 值最小的将会被清出缓存。 
            LRU ,Least Recently Used(最近最少使用). (ehcache 默认值).缓存的元素有一个时间戳,当缓存容量满了,而又需要腾出地方来缓存新的元素的时候,那么现有缓存元素中时间戳离当前时间最远的元素将被清出缓存。
    -->
    <cache name="cloud_user"
           eternal="false"
           maxElementsInMemory="5000"
           overflowToDisk="true"
           diskPersistent="false"
           timeToIdleSeconds="1800"
           timeToLiveSeconds="1800"
           memoryStoreEvictionPolicy="LRU"/>
    
</ehcache>

再次测试,正常

如何验证是否走了配置文件,配置文件中定义的cache下overflowToDisk为true,代表如果当缓存存储的数据达到maxInMemory限制时是否overflow到磁盘上,在测试完成后项目根目录下新生成了diskStore指定目录与相应的文件

一般来说缓存会使用redis其它缓存中间件单独做缓存功能,不依赖于mybatis的二级缓存,但是mybatis的一级缓存是默认开启的