MyBatis(6)--缓存与运行原理


一、缓存

1、一些核心概念

1. SqlSession : 代表和数据库的一次会话,向用户提供了操作数据库的方法。
2. MappedStatement: 代表要发往数据库执行的指令,可以理解为是Sql的抽象表示。
3. Executor: 具体用来和数据库交互的执行器,接受MappedStatement作为参数。
4. 映射接口: 在接口中会要执行的Sql用一个方法来表示,具体的Sql写在映射文件中。
5. 映射文件: 可以理解为是Mybatis编写Sql的地方,通常来说每一张单表都会对应着一个映射文件,在该文件中会定义Sql语句入参和出参的形式。

SqlSessionFactoryBuilder

这个类可以被实例化、使用和丢弃,一旦创建了 SqlSessionFactory,就不再需要它了。 因此 SqlSessionFactoryBuilder 实例的最佳作用域是方法作用域(也就是局部方法变量)。 你可以重用 SqlSessionFactoryBuilder 来创建多个 SqlSessionFactory 实例,但最好还是不要一直保留着它,以保证所有的 XML 解析资源可以被释放给更重要的事情。

SqlSessionFactory

SqlSessionFactory 一旦被创建就应该在应用的运行期间一直存在,没有任何理由丢弃它或重新创建另一个实例。 使用 SqlSessionFactory 的最佳实践是在应用运行期间不要重复创建多次,多次重建 SqlSessionFactory 被视为一种代码“坏习惯”。因此 SqlSessionFactory 的最佳作用域是应用作用域。 有很多方法可以做到,最简单的就是使用单例模式或者静态单例模式。

SqlSession

每个线程都应该有它自己的 SqlSession 实例。SqlSession 的实例不是线程安全的,因此是不能被共享的,所以它的最佳的作用域是请求或方法作用域。 绝对不能将 SqlSession 实例的引用放在一个类的静态域,甚至一个类的实例变量也不行。 也绝不能将 SqlSession 实例的引用放在任何类型的托管作用域中,比如 Servlet 框架中的 HttpSession。 如果你现在正在使用一种 Web 框架,考虑将 SqlSession 放在一个和 HTTP 请求相似的作用域中。 换句话说,每次收到 HTTP 请求,就可以打开一个 SqlSession,返回一个响应后,就关闭它。 这个关闭操作很重要,为了确保每次都能执行关闭操作,你应该把这个关闭操作放到 finally 块中。 下面的示例就是一个确保 SqlSession 关闭的标准模式:

try (SqlSession session = sqlSessionFactory.openSession()) {
  // 你的应用逻辑代码
}

在所有代码中都遵循这种使用模式,可以保证所有数据库资源都能被正确地关闭。

2、一级缓存

(1)简介

 * 一级缓存:(本地缓存):sqlSession级别的缓存。一级缓存是一直开启的;SqlSession级别的一个Map
 *         与数据库同一次会话期间查询到的数据会放在本地缓存中。
 *         以后如果需要获取相同的数据,直接从缓存中拿,没必要再去查询数据库;

在系统代码的运行中,我们可能会在一个数据库会话中,执行多次查询条件完全相同的Sql,鉴于日常应用的大部分场景都是读多写少,这重复的查询会带来一定的网络开销,同时select查询的量比较大的话,对数据库的性能是有比较大的影响的。

如果是Mysql数据库的话,在服务端和Jdbc端都开启预编译支持的话,可以在本地JVM端缓存Statement,可以在Mysql服务端直接执行Sql,省去编译Sql的步骤,但也无法避免和数据库之间的重复交互。

Mybatis提供了一级缓存的方案来优化在数据库会话间重复查询的问题。实现的方式是每一个SqlSession中都持有了自己的缓存,一种是SESSION级别,即在一个Mybatis会话中执行的所有语句,都会共享这一个缓存。一种是STATEMENT级别,可以理解为缓存只对当前执行的这一个statement有效。
image-20211114152212865

每一个SqlSession中持有了自己的Executor,每一个Executor中有一个Local Cache。当用户发起查询时,Mybatis会根据当前执行的MappedStatement生成一个key,去Local Cache中查询,如果缓存命中的话,返回。如果缓存没有命中的话,则写入Local Cache,最后返回结果给用户。

在 MyBatis 中,你使用SqlSessionFactory来创建一个SqlSession. 一旦你有了一个会话,你就用它来执行你的映射语句、提交或回滚连接,最后,当它不再需要时,你关闭会话。使用 MyBatis-Spring,你不需要SqlSessionFactory直接使用,因为你的 bean 可以被注入一个线程安全的SqlSession,它会根据 Spring 的事务配置自动提交、回滚和关闭会话。

@Autowired
private SqlSessionFactory sqlSessionFactory;

/**
* 测试一级缓存
*/
@Override
public void testCache1() {

    //开启一次会话
    SqlSession sqlSession = sqlSessionFactory.openSession();
    UserMapper mapper = sqlSession.getMapper(UserMapper.class);
    List<UserEntity> list1 = mapper.findAll();
    List<UserEntity> list2 = mapper.findAll();

    //如果是直接调用 baseMapper 的话,一级缓存是无效的,list1==list2返回false,原因在下文
    //List<UserEntity> list1 = baseMapper.findAll();
    //List<UserEntity> list2 = baseMapper.findAll();

    System.out.println(list1);
    System.out.println(list1 == list2); // true
}

list1 == list2 返回 true,表示2次查询返回的是同一个对象,即只有第一次查询去数据库中获取,第二次是从缓存中获取的第一次的结果,所以才会是同一个对象,控制台只打印一次 sql 语句。

注意:如果直接使用 @Autowired 注入 SqlSession 的话,多次查询的 SqlSession 也并不是同一个。

@Autowired
private SqlSession sqlSession;

/**
     * 测试一级缓存
     */
@Override
public void testCache1() {

    UserMapper mapper = sqlSession.getMapper(UserMapper.class);
    List<UserEntity> list1 = mapper.findAll();
    List<UserEntity> list2 = mapper.findAll();

    System.out.println(list1 == list2); // false
}

控制台中每次查询前都要创建一个 sqlSession ,查询后关闭

Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@7d57dbb5] was not registered for synchronization because synchronization is not active
...
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@7d57dbb5]
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@6e03db1f] was not registered for synchronization because synchronization is not active
...
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@6e03db1f]

false

(2)工作流程

image-20211114152413702

主要步骤如下:

  1. 对于某个Select Statement,根据该Statement生成key。
  2. 判断在Local Cache中,该key是否用对应的数据存在。
  3. 如果命中,则跳过查询数据库,继续往下走。
  4. 如果没命中,回去数据库中查询,然后写入到Local Cache中

(3)源码分析

  • 对于mybatis中有很多Executor执行器,在一级缓存中我主要学习BaseExecutor。

  • 一级缓存Local Cache的查询和写入是在Executor内部完成的。在阅读BaseExecutor的代码后,我们也发现Local Cache就是它内部的一个成员变量,如下代码所示。

public abstract class BaseExecutor implements Executor {
      protected Transaction transaction;
      protected Executor wrapper;

      protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
      //存储缓存对象
      protected PerpetualCache localCache;
      protected PerpetualCache localOutputParameterCache;
      protected Configuration configuration;
}
  • BaseExecutor成员变量之一的PerpetualCache,就是对Cache接口最基本的实现,其实现非常的简内部持有了hashmap,对一级缓存的操作其实就是对这个hashmap的操作。
public class PerpetualCache implements Cache {
private final String id;
private Map<Object, Object> cache = new HashMap<Object, Object>();
  • BaseExecutor的query核心方法,主要的功能是查库写缓存。
@SuppressWarnings("unchecked")
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
    if (closed) {
        throw new ExecutorException("Executor was closed.");
    }
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
        clearLocalCache();
    }
    List<E> list;
    try {
        queryStack++;
        //从缓存中获取
        list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
        if (list != null) {
            handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
        } else {
            //查不到的话,就从数据库查,在queryFromDatabase中,会对localcache进行写入。
            list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
        }
    } finally {
        queryStack--;
    }
    if (queryStack == 0) {
        for (DeferredLoad deferredLoad : deferredLoads) {
            deferredLoad.load();
        }
        deferredLoads.clear();
        //判断一级缓存级别是否是STATEMENT级别,如果是的话,就清空缓存,这也就是STATEMENT级别的一级缓存无法共享localCache的原因
        if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
            clearLocalCache();
        }
    }
    return list;
}
  • 最后确认一下insert/delete/update方法,缓存就会刷新的原因。
    DefaultSqlSession类中的执行方法,发现所有的方法都执行了update,
@Override
public int insert(String statement, Object parameter) {
    return update(statement, parameter);
}

@Override
public int update(String statement) {
    return update(statement, null);
}

@Override
public int update(String statement, Object parameter) {
    try {
        dirty = true;
        MappedStatement ms = configuration.getMappedStatement(statement);
        //update方法也是委托给了Executor执行
        return executor.update(ms, wrapCollection(parameter));
    } catch (Exception e) {
        throw ExceptionFactory.wrapException("Error updating database.  Cause: " + e, e);
    } finally {
        ErrorContext.instance().reset();
    }
}

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

@Override
public int delete(String statement, Object parameter) {
    return update(statement, parameter);
}
  • 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);
}

(4)总结

1、Mybatis 一级缓存的生命周期和 SqlSession 一致。

2、Mybatis 的缓存没有更新缓存和缓存过期的概念,同时只是使用了默认的hashmap,也没有做容量上的限定。

3、Mybatis 的一级缓存最大范围是 SqlSession 内部,有多个 SqlSession 或者分布式的环境下,有操作数据库写的话,会引起脏数据,建议是把一级缓存的默认级别设定为Statement,即不使用一级缓存。

(5)缓存失效原因

一级缓存失效情况(没有使用到当前一级缓存的情况,效果就是,还需要再向数据库发出查询):
1、sqlSession不同。
2、sqlSession相同,查询条件不同.(当前一级缓存中还没有这个数据)
3、sqlSession相同,两次查询之间执行了增删改操作(这次增删改可能对当前数据有影响)
4、sqlSession相同,手动清除了一级缓存(缓存清空)
    sqlSession.clearCache();

(6)mybatis-spring一级缓存

mybatis-spring 中的 sqlsession 通过 spring 去管理,

  • 前面说到 mybatis 的一级缓存生效的范围是 sqlsession ,是为了在 sqlsession 没有关闭时,业务需要重复查询相同数据使用的。一旦 sqlsession 关闭,则由这个 sqlsession 缓存的数据将会被清空。

  • springmybatissqlsession 的使用是由 template 控制的,sqlsession 又被spring当作resource放在当前线程的上下文里(threadlocal),spring通过mybatis调用数据库的过程如下:

1、需要访问数据
2、spring检查到了这种需求,于是去申请一个mybatis的sqlsession,并将申请到的sqlsession与当前线程绑定,放入threadlocal里面
3、template从threadlocal获取到sqlsession,去执行查询
4、查询结束,清空threadlocal中与当前线程绑定的sqlsession,释放资源
5、又需要访问数据
6、返回到步骤2

关于mybatis-spring:如果是在是事务中的查询,还是会缓存sqlsession的,因此mybatis的一级缓存还是会起作用的

@Override
@Transactional
public void testCache2() {
    List<UserEntity> list1 = baseMapper.findAll();
    List<UserEntity> list2 = baseMapper.findAll();
    System.out.println(list1);
    System.out.println(list1 == list2); // true,事务中一级缓存生效
}

结论:通过以上步骤后发现,同一线程里面两次查询同一数据所使用的sqlsession是不相同的,所以mybatis结合spring后,mybatis的一级缓存失效了。但在事务中一级缓存仍然是生效的。

  • 在未开启事物的情况之下,每次查询,spring都会关闭旧的sqlSession而创建新的sqlSession,因此此时的一级缓存是没有启作用的
  • 在开启事物的情况之下,spring使用threadLocal获取当前资源绑定同一个sqlSession,因此此时一级缓存是有效的

(7)关闭一级缓存

MyBatis 一级缓存默认开启,是 session 级别。如果要禁用一级缓存,就要设置为 statement 级别,即:

mybatis:
  configuration:
    local-cache-scope: statement  # statement关闭一级缓存,session(默认、开启) 

3、二级缓存

(1)简介

二级缓存:(全局缓存):基于namespace级别的缓存:一个namespace对应一个二级缓存:

工作机制

1、一个会话,查询一条数据,这个数据就会被放在当前会话的一级缓存中;

2、如果会话关闭;一级缓存中的数据会被保存到二级缓存中;新的会话查询信息,就可以参照二级缓存中的内容;

效果

sqlSession===EmployeeMapper==>Employee
            DepartmentMapper===>Department

不同namespace查出的数据会放在自己对应的缓存中(map)

效果:数据会从二级缓存中获取

  • 查出的数据都会被默认先放在一级缓存中。

  • 只有会话提交或者关闭以后,一级缓存中的数据才会转移到二级缓存中

(2)如何开启?

全局开启二级缓存

原生mybatis开启二级缓存方法

修改配置文件 mybatis-config.xml 加入 <setting name="cacheEnabled"value="true"/>,全局配置参数。

<configuration>
    <settings>    
        <!--  开启所有mapper的二级缓存 -->
        <setting name="cacheEnabled" value="true" />
    </settings>
</configuration>

Springboot项目开启二级缓存方法

可在配置文件开启即可

mybatis:
  configuration:
    cache-enabled: true

单个Mapper文件开启二级缓存

***Mapper.xml 中配置使用二级缓存:<cache></cache>

<mapper namespace="com.rewind.datasbase.mapper.UserMapper">
    <!-- 表示该mapper使用二级缓存 -->
    <cache></cache>

    <!-- useCache:该查询是否使用二级缓存 -->
    <select id="findAll" resultType="....." useCache="true">
        ...
    </select>

</mapper>

3、我们的POJO需要实现序列化接口Serializable

(3)cache标签属性

<cache eviction="FIFO" flushInterval="60000" readOnly="false" size="1024"></cache>
<!--  
eviction:缓存的回收策略:
    • LRU – 最近最少使用的:移除最长时间不被使用的对象。
    • FIFO – 先进先出:按对象进入缓存的顺序来移除它们。
    • SOFT – 软引用:移除基于垃圾回收器状态和软引用规则的对象。
    • WEAK – 弱引用:更积极地移除基于垃圾收集器状态和弱引用规则的对象。
    • 默认的是 LRU。
flushInterval:缓存刷新间隔
    缓存多长时间清空一次,默认不清空,设置一个毫秒值
readOnly:是否只读:
    true:只读;mybatis认为所有从缓存中获取数据的操作都是只读操作,不会修改数据。
             mybatis为了加快获取速度,直接就会将数据在缓存中的引用交给用户。不安全,速度快
    false:非只读:mybatis觉得获取的数据可能会被修改。
            mybatis会利用序列化&反序列的技术克隆一份新的数据给你。安全,速度慢
size:缓存存放多少元素;
type="":指定自定义缓存的全类名;
        实现Cache接口即可;
-->

(4)演示

public SqlSessionFactory getSqlSessionFactory() throws IOException {
    String resource = "mybatis-config.xml";
    InputStream inputStream = Resources.getResourceAsStream(resource);
    return new SqlSessionFactoryBuilder().build(inputStream);
}

@Test
public void testSecondLevelCache02() throws IOException{
    SqlSessionFactory sqlSessionFactory = getSqlSessionFactory();
    SqlSession openSession = sqlSessionFactory.openSession();
    SqlSession openSession2 = sqlSessionFactory.openSession();
    try{

        DepartmentMapper mapper = openSession.getMapper(DepartmentMapper.class);
        DepartmentMapper mapper2 = openSession2.getMapper(DepartmentMapper.class);

        Department deptById = mapper.getDeptById(1);
        System.out.println(deptById);
        openSession.close();    // 关闭会话

        Department deptById2 = mapper2.getDeptById(1);
        System.out.println(deptById2);
        openSession2.close();
        //第二次查询是从二级缓存中拿到的数据,并没有发送新的sql

    }finally{

    }
}

(5)和缓存相关的设置

* 和缓存有关的设置/属性:
* 1)、cacheEnabled=true:false:关闭缓存(二级缓存关闭)(一级缓存一直可用的)
* 2)、每个select标签都有useCache="true":(默认为 true)
*             false:不使用缓存(一级缓存依然使用,二级缓存不使用)
* 3)、【每个增删改标签的:flushCache="true":(一级二级都会清除)】
*             增删改执行完成后就会清楚缓存;
*             测试:flushCache="true":一级缓存就清空了;二级也会被清除;
*             查询标签:flushCache="false":
*                 如果flushCache=true;每次查询之后都会清空缓存;缓存是没有被使用的;
* 4)、sqlSession.clearCache();只是清除当前session的一级缓存;
* 5)、localCacheScope:本地缓存作用域:(一级缓存SESSION);
*            SESSION(默认):当前会话的所有数据保存在会话缓存中;
*             STATEMENT:可以禁用一级缓存;

(6)总结

image-20211114223511654

为什么不建议使用二级缓存

主要有以下引起两个主要问题问题: 1 缓存是以namespace为单位的,不同namespace下的操作互不影响。insert,update,delete操作会清空所在namespace下的全部缓存。 2 多表操作会导致查询结果可能不正确

4、整合第三方缓存

(1)整合步骤

1、编写缓存适配类,实现cache接口,官方已经写好了 redis 的

  <dependency>
    <groupId>org.mybatis.caches</groupId>
    <artifactId>mybatis-redis</artifactId>
    <version>1.0.0-beta2</version>
  </dependency>

2、配置 mapper.xml

<mapper namespace="org.acme.FooMapper">
    <!-- RedisCache:redis适配类 -->
  <cache type="org.mybatis.caches.redis.RedisCache" />
  ...
</mapper>

(2)redis适配类

https://github.com/mybatis/redis-cache/blob/master/src/main/java/org/mybatis/caches/redis/RedisCache.java

二、运行原理

\1. 简单执行器

simpleExecutor,每次执行SQL需要预编译SQL语句。

\2. 可重用执行器

ReuseExecutor,同一SQL语句执行只需要预编译一次SQL语句

\3. 批处理执行器

BatchExecutor,只针对修改操作的SQL语句预编译一次,并且需要手动刷新SQL执行才生效。

\4. 执行器抽象类

BaseExecutor,执行上面3个执行器的重复操作,比如一级缓存、doQuery、doUpdate方法。

\5. 二级缓存

CachingExecutor,与一级缓存的区别:一级缓存查询数据库操作后会直接缓存,二级缓存需要当次数据库操作提交事务后才能进行缓存(二级缓存跨线程处理,一级缓存不用)。


  目录