mybatis原理分析(三)---一级缓存和二级缓存

1.概述

上一篇博客《mybatis原理分析(二)—深入理解Executor》中介绍了mybatis中的一个重要的组件Executor,还有两个该组件的实现类没有介绍,那就是BaseExecutor和CachingExecutor,它们分别去处理一级缓存的逻辑和二级缓存的逻辑。这篇博客主要就是介绍这两种缓存的差别和命中场景,并且分析源码来看看mybatis是如何实现的一级缓存和二级缓存。

1.1 BaseExecutor

BaseExecutor是上一篇博客中介绍的三个Executor的父类,主要的功能就是维护一级缓存和对事务的管理。事务是通过会话中调用commit、rollback来管理。重点在于缓存这块是如何处理的。它提供基本的api如query和update,在query方法中处理一级缓存的逻辑,即根据sql以及参数等条件来判断缓存中是否有数据,有的话就走缓存,没有的话就会调用子类的doQuery方法来执行查询。而在update方法中,对于缓存方面的参与就是清空缓存。

在这里插入图片描述

1.2 CachingExecutor

CachingExecutor,处理二级缓存的逻辑,二级缓存默认是关闭的,而一级缓存默认是打开的且必须是打开的,之后会说明为什么。

二级缓存可以根据参数来控制开启或者关闭,它的实现是采用了装饰者的方式,在CachingExecutor中聚合了一个BaseExecutor。处理完二级缓存的逻辑后,剩余的逻辑就交给里面的Executor来实现。看下面的测试代码

在这里插入图片描述

将简单执行器聚合到缓存执行器中,来执行两次查询,控制台的输出内容如下:

在这里插入图片描述

第一次查询缓存中没有所以命中率为0.而第二次查询的时候可以看到命中率变成了0.5 说明第二次查询命中了缓存,两次查询中有一次查询命中了缓存,算出命中率为0.5

若直接使用简单缓存执行器的结果如下:

在这里插入图片描述

sql预编译了两次,并且执行了两次sql查询。不会走二级缓存。

2.一级缓存

一级缓存也叫做会话级缓存,生命周期仅存在于当前会话,不可以直接关关闭。但可以通过flushCache和localCacheScope对其做相应控制。一级缓存默认打开。

2.1 一级缓存的命中场景

因为缓存中的key是由以下参数组成的,所以缓存的命中条件就是以下参数构成的key在缓存中可以找到。

  • SQL与参数相同
  • 同一个会话
  • 相同的MapperStatement ID
  • RowBounds行范围相同

2.2 触发清空一级缓存

  1. 手动调用clearLocalCache,在BaseExecutor中有这个方法可以清空一级缓存

    在这里插入图片描述

  2. 执行提交回滚,都会去调用上面的这个清空一级缓存的方法在这里插入图片描述

  3. 执行update操作在这里插入图片描述

  4. 配置flushCache=true 在子查询的时候不会清空 清空缓存不能发生在子查询里面。在这里插入图片描述

    在query方法中有如下判断,说明当flushCache设为true的时候,并且不是子查询的时候,会清空一级缓存。queryStack表示子查询递归的层数。

    在这里插入图片描述

  5. 缓存作用域改为Statement 同理 子查询的时候不会清空

    同样在query方法中有另一个判断,如下:判断缓存的作用与是不是statement否则,就会将其清空。在这里插入图片描述

    可以在mybatis的配置文件中修改一级缓存的作用域 如下:在这里插入图片描述

4和5的差别是 4是在查询前清空,5是在查询后清空。

2.3 一级缓存源码分析

如下的测试代码,debug调试

在这里插入图片描述

首先进来的是BaseExecutor的方法,第一行代码是动态绑定sql,这里先不考虑是如何实现的,后面会当作一个专题写一篇博客来介绍。

第二行就是做的创建缓存key,点进去一探究进

在这里插入图片描述

就是将MapperStatement的id,分页参数,sql语句,传入的参数都保存在cachekey中,这也说明了为什么只有当这些条件都一样的时候,才会命中缓存。

在这里插入图片描述

创建好了缓存key 会去调用BaseExecutor中的另一个重载的query方法

在这里插入图片描述

看红色的框框,这里根据缓存key从localCache中获取对象,这个localCache也就是一级缓存。localCache是一个PerpetualCache对象,里面有一个cache Map集合。在这里插入图片描述

缓存key作为键,查询的结果作为value。

如果从缓存中拿到了结果 则将结果返回,如果没有则将执行queryFromDatabase去查询数据库。会将缓存key和结果集放入一级缓存中。

在这里插入图片描述

BaseExecutor中的query和queryFromDatabase 也是解决嵌套子查询中循环依赖问题的关键。后面会单独写一篇博客来讲这个知识点。现在我们只要关注于一级缓存是如何实现的即可。

3 二级缓存

二级缓存也称作是应用级缓存,与一级缓存不同的,是它的作用范围是整个应用,而且可以跨线程使用。所以二级缓存有更高的命中率,适合缓存一些修改较少的数据。在流程上是先访问二级缓存,在访问一级缓存。

3.1 二级缓存的设计

一个应用级别的缓存,需要考虑如何存储缓存,溢出淘汰机制的设计等。

mybatis中,使用了责任链的设计模式,将这一串的逻辑,设计成可拔插,自由组装的组件。责任链模式顾名思义就是每一个阶段处理一个阶段的责任,将责任细化,方便扩展和使用。

涉及到的组件如下:都是一个一个套娃的形式,内部聚合了下一个组件。这样执行完这一层的逻辑可以交给下一个组件执行

事务组件TransactionalCache->同步组件SynchronizedCache->日志组件LoggingCache->序列化组件SerializedCache->移除淘汰组件LruCache->存储策略组件PerpetualCache

在这里插入图片描述

从这一串默认的二级缓存的执行组件中可以看出,mybatis使用的默认的溢出淘汰机制是最近最少使用(LRU),使用的存储策略是内存存储。

仔细看看源码里每一个组件都干了些什么

  1. 调试源码,可以发现上面这一串的逻辑是在执行了commit的时候进行的。commit处打个断点。在这里插入图片描述

  2. CachingExecutor

    由CachingExecutor内部聚合的Executor去执行会话提交,然后提交事务缓存管理器的暂存区,进行更新二级缓存的操作。

    在这里插入图片描述

  3. TransactionalCacheManager

    管理二级缓存空间和对应的暂存区的关系,一一对应。有多少个二级缓存空间,每个线程内就有多少个暂存区。

    遍历暂存区,执行事务组件的commit方法在这里插入图片描述

  4. TransactionalCache

    判断是否设置了提交后清空。然后执行flushPendingEntries,遍历缓存在事务缓存管理器的暂存区里的对象。一个一个的执行下一个组件的putObject方法,也就是将暂存区里的键值对都更新到二级缓存空间中。
    在这里插入图片描述
    在这里插入图片描述

  5. SynchronizedCache

    这个组件只做一件事情,为每个方法做同步处理,加上了synchronized。因为二级缓存是线程共享的,所以需要做同步处理

    在这里插入图片描述

  6. LoggingCache

    这是日志组件,仅对getObject方法做了修改,打印出日志,计算输出二级缓存的命中率
    在这里插入图片描述

  7. SerializedCache

    序列化组件,put的时候将查询结果进行序列化,get的时候进行反序列化。因为二级缓存是跨线程使用的。若保存在缓存中的对象不进行序列化的话,之后多个线程拿到的对象是同一个对象 这样会有线程安全的问题。
    在这里插入图片描述

  8. LruCache

    溢出淘汰机制组件,默认是最近最少使用的淘汰掉。

    在这里插入图片描述
    在这里插入图片描述

  9. PerpetualCache

    存储策略组件,采用内存存储。

    在这里插入图片描述

3.2 二级缓存的使用

二级缓存默认是不开启的,需要为其声明缓存空间才可以使用,通过在mapper上设置注解

在这里插入图片描述

或者在mybatis的配置文件中,设置打开二级缓存的支持

在这里插入图片描述

然后在mapper中设置

< cache/> 表示开启这个mapper的二级缓存空间

值得注意的是,如果UserMapper中加了二级缓存的注解 并且UserMapper.xml中同时设置了< cache/> 将会报错。需要将其中之一改成二级缓存的空间引用。例如在xml中将< cache/> 改成 指向注解声明的缓存空间。

在这里插入图片描述

@CacheNamespace 注解详细的配置信息见下图,说明可以对这个二级缓存做自定义。实现自定义的相关实现类。例如自定义的存储实现类,自定义的缓存溢出淘汰机制。默认是最近最少使用。

在这里插入图片描述

3.3 二级缓存的命中场景

  1. 相同sql和参数
  2. 相同的statement的id
  3. RowBounds行范围相同
  4. 会话提交之后

3.4 二级缓存源码分析

3.4.1 query查询操作。
  1. 首先执行的CachingExecutor的query方法,获取动态绑定sql,创建缓存key然后执行另一个重载的query方法,缓存key和一级缓存中的一样。
    在这里插入图片描述

  2. 这里做的事情就比较多了。从statement中得到二级缓存空间。判断二级缓存空间是否存在,也就是有没有开启二级缓存的支持。如果有,则进入第一个if。没有的话就交给里面BaseExecutor执行它的query逻辑。
    在这里插入图片描述

  3. flushCacheIfRequired(ms) 检查是否设置了清空二级缓存的参数,如果是true,则执行清空二级缓存。默认的查询相关的statement中的flushCacheRequired属性是false,可以通过手动的将其设置为true,那么在执行查询的时候也会清空二级缓存。

  4. ensureNoOutParams 如果是callcabelStatement 不支持对出参的缓存。抛出异常

  5. tcm.getObject 从二级缓存中找是否存在缓存key对应的结果。如果此时还没有创建二级缓存空间对应的暂存区,则执行创建。事务缓存管理器里面维护了一个暂存区Map。二级缓存空间作为key,暂存区作为value。一一对应。并且将这个key,记录下来,表明它没有命中。

    TransactionalCache 中的 private final Set entriesMissedInCache;就是用来记录没有命中的缓存key

    在这里插入图片描述

  6. 如果此时二级缓存中没有,则执行查询操作。

  7. 将结果集,放入到事务缓存管理器的暂存区中。

    在这里插入图片描述

    在这里插入图片描述

    TransactionalCache 中的 private final Map<Object, Object> entriesToAddOnCommit //暂存区,key是之前创建出来的缓存key,value是对应的查询结果。

3.4.2 commit提交操作。
  1. 首先来到CachingExecutor 中的comit方法 主要做两件事情,一是将会话提交,清空一级缓存。二是提交事务缓存管理器中的所有暂存区,进行更新二级缓存的操作。
    在这里插入图片描述

  2. TransactionalCacheManager 中的commit

    会话提交这里不考虑,看看事务缓存管理器中的commit方法

    遍历暂存区,对每一个暂存区执行提交操作。

    在这里插入图片描述

  3. TransactionalCache 中的commit

    执行flushPendingEntries 和 reset

    在这里插入图片描述

  4. flushPendingEntries

    遍历暂存区所有的键值对,全部放到二级缓存空间中。然后遍历之前存放的没有命中二级缓存中的缓存key。如果这个键在暂存区没有出现过。则将这个键和null值提交给二级缓存空间。这样做的目的是为了避免无效的查询,多次的去查询数据库,而造成数据库很大的压力负担。也就是解决缓存穿透的问题。
    在这里插入图片描述

3.4.3 update操作
  1. 首先来到的是CachingExecutor中的update方法,做两件事情,清空暂存区并设置提交清空标记为true。交给baseExecutor执行它的update逻辑。在这里插入图片描述

  2. 检查清空缓存标记是否为true,对于update相关的statement中的flushCacheRequired属性默认是true,所以对于update操作必然会清空二级缓存。而Select相关的statement中的flushCacheRequired属性默认是false。如果将这个属性手动的设置为true,则也会执行清空二级缓存的操作。让我们看看缓存事务管理器中的clear是做了哪些事情

    在这里插入图片描述

  3. 根据二级缓存空间拿到对应的暂存区,执行clear

    在这里插入图片描述

  4. 设置提交时清空标记为true,然后清空暂存区。可以看到此时并没有真正的对二级缓存空间进行清空,而仅仅是设置了提交时清空标记,等到执行commit的时候,才会清空二级缓存空间。

    在这里插入图片描述

3.5 为什么只有会话提交成功才会更新或清空二级缓存

假设现在数据库中有一行数据,name=gongsenlin,age=18

此时第一个线程,执行了update操作,将age更新成了16,并且执行了select方法,若不是按照mybatis的设计思路提交后才更新二级缓存,而是直接在查询后就更新二级缓存。

那么此时第二个线程执行select方法,会看到二级缓存中有数据,则直接从二级缓存中获取,会查询到name=gongsenlin,age=16。

但是此时第一个线程突然出现了错误,回滚了,之前的更新操作失效了。那么此时数据库中的数据又回到了name=gongsenlin,age=18。

但是第二个线程却读到了age=16,造成了脏读。所以mybatis是为了避免多线程脏读的出现,才设计成提交后更新或清空二级缓存。

4. 后续

下一篇博客介绍mybatis中的Jdbc处理器—StatementHandler。

猜你喜欢

转载自blog.csdn.net/gongsenlin341/article/details/108695153