MyBatis源码学习(一)-执行器及缓存介绍
一、MyBatis 执行器
1.1、JDBC 执行过程
1、执行流程
JDBC 的执行流程大致如下图
- 获取连接(Connection)
基于事务来获取连接
1 | /** 第一步: 获取连接 */ |
- 构建 Statement 对象
通过连接 Connection 对象来构造 Statement 对象
- 设置参数
- 执行修改
2、JDBC 中的 Statement
JDBC 有三种执行器,分别是
- Statement(简单执行器)
执行静态SQL
- PreparedStatement(预处理执行器)
设置预编译,防止SQL注入
- CallableStatement(存储过程执行器)
设置出参、读取参数(用于执行存储过程)
- 三者继承关系
CallableStatement 继承自 PreparedStatement ,而 PreparedStatement 又向上继承 Statement
1.2、MyBatis 核心组件(SqlSession 与 Executor)
MyBatis 在执行时关系到四个重要模块,分别是 动态代理(MapperProxy) ,SQL 会话(SqlSession) ,执行器(Executor) 和 JDBC 处理器(StatementHandler)
1、MyBatis 执行过程 – SQL 会话(SqlSession)
MyBatis 的 SQL 会话(SqlSession)采用门面模式设计,其核心作用是为用户提供一个统一的门面接口 API ,使得系统更容易使用
SqlSession 中的 API 包括增、删、改、查(基本 API )以及提交、关闭(辅助 API )等。其自身是没有能力处理这些请求的,所以内部会包含一个唯一的执行器 Executor,所有请求都会交给执行器来处理。
如下图中SqlSession接收用户“修改”请求,然后转交给Executor
SqlSession 如何将请求交给执行器?SqlSession 内部会存在一个 executor 属性,这个属性指向真实的执行器对象,当我们执行 CRUD 时,对应的方法会转交给实际的执行器对象
执行器实际上只有改查两个基本功能,为什么?
这是因为 JDBC 提供的 标准API 中只有 executeUpdate(改)与 executeQuery(查)两个功能。
2、MyBatis 执行过程 – 执行器(Executor)
Executor是一个大管家,核心功能包括:缓存维护、获取动态SQL、获取连接、以及最终的JDBC调用等。在图中所有蓝色节点全部都是在Executor中完成。
Executor 的出现是为了处理某些共性功能,如缓存、获取连接等
这么多事情无法全部亲力亲为,就需要把任务分派下去。所以Executor内部还会包含若干个组件:
- 缓存维护:cache
- 获取连接:Transaction
- 获取动态sql:SqlSource
- 调用jdbc:StatementHandler
上述组件中前三个和Executor是1对1关系,只有StatementHandler是1对多。每执行一次SQL 就会构造一个新的StatementHandler。
3、执行器实现 – 简单执行器 SimpleExecutor
简单执行器基本实现了执行器的所有 Api ,无论执行的 SQL 是否一样,简单执行器在每次执行之前都会进行一次预处理,效率不高。
每次都会创建一个新的预处理器,即 PrepareStatement 对象
4、执行器实现 – 可重用执行器 ReuseExecutor
对于相同的 SQL 语句,ReuseExecutor 只会预编译一次。
对于相同的 SQL ,不会进行重复的预处理
5、执行器实现 – 批处理执行器 BatchExecutor
批处理只针对增删改,即对于一次提交的多次增删改操作,只会进行一次预处理。
在使用 BatchExecutor 时,需要进行手动提交,将批处理提交的数据一次刷新到数据库中。
6、执行器抽象类 – BaseExecutor
基础执行器是上面三个执行器的父类,实现了三个执行器实现类的一些重复操作,包括一级缓存,获取连接
在 BaseExecutor 中存在着两个抽象方法,分别是执行增删改的 doUpdate 方法和执行查询的 doQuery 方法,它的三个实现子类在各自的类中分别实现这三个方法,以此达到执行不同的操作
- BaseExecutor 中的 doUpdate 抽象方法
实现类在自己的 doUpdate 方法中编写清理缓存的逻辑。
1 | protected abstract int doUpdate(MappedStatement ms, |
- BaseExecutor 中的 doQuery 抽象方法
实现类在自己的 doQuery 方法中编写使用缓存的逻辑。
1 | protected abstract <E> List<E> doQuery(MappedStatement ms, |
- BaseExecutor 中的 query 方法
1 |
|
- 重载的 query 方法
1 |
|
- queryFromDatabase 方法
可以看到,在 queryFromDatabase 方法中调用了其子类实现的 doQuery 方法
1 | private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { |
7、MyBatis 实现二级缓存 – CachingExecutor
MyBatis 没有在 BaseExecutor 中加入与二级缓存相关的功能,因为二级缓存是需要手动开启的;对于二级缓存,MyBatis 使用另一个 CachingExecutor 类来实现 Executor 接口,这个类不是一个抽象类
CachingExecutor 只专注于实现二级缓存,它实际上没有与数据库进行交互的功能,但它类中声明了一个 Executor 的属性 delegate ,这个属性指向一个 BaseExecutor 对象,delegate 属性就是被装饰的对象。
所以说,CachingExecutor 对象中与数据库进行交互的功能是由 BaseExecutor 对象提供的。
这是 装饰者模式 的具体实现,在不改变原有类结构和继承的情况下,通过包装源对象去扩展一个新功能。
相当于为 BaseExecutor 装饰了一层二级缓存的功能,保证了类的单一职责。
先走二级缓存,后走一级缓存
- CachingExecutor 中的 update 方法
- CachingExecutor 中的 query 方法
在开启二级缓存的条件下,执行查询操作时的操作步骤如下
调用 SqlSession 的 API ,SqlSession 将请求及参数交给其类中的 Executor 属性执行,注意,此时这个 Executor 指向的应该是一个 CachingExecutor 对象。
先查询二级缓存,如果没有二级缓存,那么调用 CachingExecutor 中的 deltegate 属性访问数据库,如果有,直接返回二级缓存,这里的 delegate 是一个 BaseExecutor 对象,其会调用实现子类的方法访问一级缓存或者与数据库进行交互
二、MyBatis 的缓存
MyBatis 的缓存分为一级缓存和二级缓存,其中二级缓存在 CachingExecutor 中实现,一级缓存在 BaseExecutor 中实现
2.1、一级缓存
1、说明
一级缓存也称为会话级缓存,指的是在同一会话内如果有两次相同的查询(Sql和参数均相同),那么第二次就会命中缓存。一级缓存通过会话进行存储,当会话关闭,缓存也就没有了。此外如果会话进行了修改(增删改) 操作,缓存也会被清空。
注意,如果执行的 SQL 和参数均相同,但 Statement Id 不同,那么一级缓存也不会生效,这里的 Statement Id 为 (Mapper 接口全类名 + 方法)。
一级缓存默认是开启的,而且不能关闭。
- 为什么一级缓存无法关闭?
因为 MyBatis 的一些关键特性,如通过
和 、避免循环引用(circular references)、加速重复嵌套查询等 都是基于 MyBatis 的一级缓存实现的,而且 MyBatis 结果集映射相关代码重度依赖 CacheKey,所以目前MyBatis不支持关闭一级缓存。建立级联映射
2、MyBatis 中一级缓存的级别
MyBatis 提供了一个配置参数 localCacheScope,用于控制一级缓存的级别,该参数的取值为 SESSION、STATEMENT
- 当指定 localCacheScope 参数值为 SESSION 时,缓存对整个 SqlSession 有效,只有执行 DML 语句(更新语句)时,缓存才会被清除。
- 当 localCacheScope 值为 STATEMENT 时,缓存仅对当前执行的语句有效,当语句执行完毕后,缓存就会被清空。
3、源码解析
一级缓存底层使用一个 HashMap 实现,存储结构为 key-value 结构
在上图中,如果不存在一级缓存,那么会执行 BaseExecutor 子类的 doQuery 方法访问数据库,在从数据库查询到数据后,将数据填充到一级缓存中,如下图所示
- 查看 BaseExecutor 中的 query 方法源码
1 |
|
从上面的源码可以看到,在 query 方法中使用 localCache 属性的 getObject 方法获取缓存,这个方法需要传入一个 CacheKey 对象,这个 localCache 在类中的声明如下
可以看到 localCache 是一个 PerpetualCache 类型的属性,我们下一步来查看 PerpetualCache 的源码
- PerpetualCache 的源码
同时,PerpetualCache 类中的 getObject 方法底层调用了 HashMap 的 get 方法
4、CacheKey
MyBatis 通过 CacheKey 对象来描述缓存的 Key 值。
在进行查询操作时,首先创建 CacheKey 对象( CacheKey 对象决定了缓存的 Key 与哪些因素有关系)。
如果两次查询操作 CacheKey 对象相同,就认为这两次查询执行的是相同的SQL语句。CacheKey 对象通过 BaseExecutor 类的 createCacheKey() 方法创建,代码如下:
1 |
|
CacheKey 类中的结构如下
- multiplier
计算hashcode的乘积数,默认为 37
- hashCode
默认为17
- updateList
用于决定是否命中缓存的变量,这是一个 Object 对象列表,里面通常填充六个值
- Statement Id : 区分 Mapper 接口方法的唯一指定值,为 Mapper 接口 + 方法名组成
- offset : 分页偏移量,默认为 0
- limit : 查询数据条数,默认为 Integer.MAX_VALUE,这个属性与上面的属性构成分页条件
- SQL :查询的 SQL 语句
- parameterValue : 查询参数的值
- environmentId : 环境变量,在配置mybatis环境的时候有一个标签(environment) ,他有一个属性id;environmentId的就是这个id的值;
在多次查询中,只有 updateList 中的六个变量全部吻合,那么才能命中一级缓存。
1 | public class CacheKey implements Cloneable, Serializable { |
断点追踪
5、写入一级缓存
在 BaseExecutor 的 queryFromDataBase 方法中与数据库进行交互,在从数据库中查询到数据后,会调用 localCache 的 putObject 方法将查询到的数据放入数据库中。
6、清空一级缓存
MyBatis 使用 clearLocalCache 方法来清空一级缓存
- 执行 update 时会调用 clearLocalCache 方法清空一级缓存
BaseExecutor 类中的 update 方法源码如下
- 在查询之前如果配置 flushCache = true ,那么也会清空缓存
- 如果缓存的作用域是 STATEMENT ,那么每次查询前也会清空一级缓存
注意,清空缓存不能发生在子查询中。
1 |
|
- 执行 rollback 和 commit 都会清空缓存
1 |
|
7、一级缓存失效
- Spring 整合 MyBatis 后,可能会出现一级缓存失效的情况。
这是由于在未开启事务的情况下,每次查询,Spring 都会关闭旧的 SqlSession 对象然后创建一个新的 SqlSession 对象,由于一级缓存是会话级缓存,所以不同会话的查询之间自然无法使用一级缓存。
在开启事务后,Spring 会使用 ThreadLocal 获取当前资源绑定同一个 SqlSession 对象,因为一级缓存是有效的。
- 解决方法
添加事务即可。
2.2、二级缓存
1、说明
二级缓存也称为应用级缓存,与一级缓存不同,它的作用范围是整个应用,并且可以跨线程使用。
所以二级缓存拥有更高的命中率,适合缓存一些修改较少的数据。
由于生命周期长,跨会话访问的因素所以二级在使用上要更谨慎,如果用的不好就会造成脏读。
2、设置方法
需要在 MyBatis 的主配置文件中的 settings 标签中设置 cacheEnable = true=
然后在 Mapper 文件中配置缓存策略、刷新频率和缓存容量等信息。
3、二级缓存扩展性需求
- 存储
一级缓存底层使用 HashMap 来实现缓存,我们可以将二级缓存中的数据保存在内存(速度快但断电即失)、硬盘或者是第三方缓存中(
Redis
)二级缓存底层的存储格式还是
key:value
- 缓存淘汰策略
由于二级缓存的生命周期很长,所以需要使用淘汰策略在空间不足时对一些缓存进行淘汰,常见的有
FIFO
,即先进先出的淘汰策略,除此之外,还有 LRU ,即淘汰最近最少使用的缓存。
过期清理
线程安全
命中率统计
序列化
…
3、责任链设计模式
责任链模式(Chain of Responsibility Pattern)为请求创建了一个接收者对象的链。让多个对象都有可能接收请求,将这些对象连接成一条链,并且沿着这条链传递请求,直到有对象处理它为止。
拦截的类需要实现统一接口,由于在实现缓存中需要设计大量操作,那么可以使用多个拥有不同职责的类来接收请求,这些类实现了统一接口并使用装饰者模式进行依赖,当一个类对象处理完请求的部分功能(如序列化、缓存命中计算)后,再将自己处理不了的需求通过 delegate 属性传递下去,交给其他类进行处理。
4、MyBatis 实现二级缓存
MyBatis 提供了 Cache 接口来实现二级缓存,当我们需要自定义二级缓存时,可以通过实现 Cache 接口来实现。
查看 Cache 接口
1 | public interface Cache { |
实现二级缓存的过程中, MyBatis 使用了装饰器 + 责任链模式,提升了程序的扩展性和逻辑性
追踪源码可以看到,SynchronizedCache 中存在着一个 delegate 属性,这个属性是一个 LoggingCache 对象,同时 LoggingCache 中又有一个 SerializedCache 类型的属性 delegate…
每个 Cache 对象处理完自己的工作后,将处理不了的功能传递到下一个对象中。
使用了责任链 + 装饰器模式
- 不同的功能由不同的缓存装饰器实现,下标是装饰器类与对应功能
装饰器 | 描述 |
---|---|
SynchronizedCache | 同步锁,用于保证对指定缓存区的操作都是同步的 |
LoggingCache | 统计器,记录缓存命中率 |
BlockingCache | 阻塞器,基于key加锁,防止缓存穿透 |
ScheduledCache | 时效检查,用于验证缓存有效器,并清除无效数据 |
LruCache | 溢出算法,淘汰闲置最久的缓存。 |
FifoCache | 溢出算法,淘汰加入时间最久的缓存 |
WeakCache | 溢出算法,基于java弱引用规则淘汰缓存 |
SoftCache | 溢出算法,基于java软引用规则淘汰缓存 |
PerpetualCache | 实际存储,内部采用HashMap进行存储。 |
5、缓存命中条件
MyBatis一二级缓存的CacheKey是一致的,必须满足以条件才可以命中缓存
- 相同的statement id
- 相同的Sql与参数
- 返回行范围相同
- 没有使用ResultHandler来自定义返回数据
- 没有配置UseCache=false 来关闭缓存
- 没有配置FlushCache=true 来清空缓存
- 在调用存储过程中不能使用出参,即Parameter中mode=out|inout
6、二级缓存写入
与一级缓存的实时写入不同,二级缓存是在事务提交或会话关闭之后才会触发缓存写入。
这么做其实也好理解,因为二级缓存是跨会话的,如果没有提交就写入,如果事务最后回滚,肯定导致别的会话脏读。
每一个会话都有自己对应的一块事务缓存管理器,管理器中拥有许许多多暂存区,在查询后,会先将查询到的数据放入暂存区中,如果事务提交,那么将暂存区中的数据放入对应的缓存区中,否则就不放入缓存区中。
暂存区 (TransactionalCache)用于暂时存放待缓存的数据区域,和缓存区是一一对应的。如果会话会涉及多个二级缓存的访问,那么对应暂存区也会有多个。暂存区生命周期与会话保持一致。
7、二级缓存的存取流程
- 我们先看一下 MyBatis 中 Executor 的体系结构图
可以看到, CachingExecutor 在 BaseExecutor 之前,故 MyBatis 是先访问二级缓存,后访问一级缓存。
- 二级缓存的存取过程如下图
- 查询
先判断是否存在二级缓存,如果存在二级缓存,那么从二级缓存中查询,如果不存在二级缓存,那么调用 BaseExecutor 及其子类查询数据库,同时将查询到的数据填充到对应的暂存区中。
- 修改
在执行修改操作时,会默认调用
flushCacheIfRequired
方法清空缓存,注意,这里清空的只是暂存区,而不是真正的清空二级缓存真正的清空发生在事务提交后,未提交前只是令二级缓存失效,MyBatis 提供了一个 clearOnCommit 标记,当这个值为 true 时,标记二级缓存是否失效,当这个值为 true 时,即使二级缓存中有值,那么也只会返回 null
下面是二级缓存空间类 TransactionalCache 中的 getObject 方法
1 |
|
- 提交
在提交事务时,会将暂存区中的缓存保存到二级缓存中
8、源码分析
- CachingExecutor 中的 query 方法
- 事务管理器的 getObject 方法需要传入缓存空间和 key
- 如果从缓存中获取到的对象为空,那么调用
BaseExecutor
的 query 方法查询数据库,并将查询到的数据放入暂存区中
1 |
|
- 查看事务管理器中的源码
可以看到事务管理器中维护了一个 Map ,这个 Map 以 Cache 对象,即暂存区对象为键,二级缓存空间为值
这样可以通过暂存区唯一找到一块二级缓存空间
- 这就是前面所讲的,每个暂存区对应二级缓存空间中的一块指定空间,在进行查询时,我们需要使用暂存区获取二级缓存中的指定空间
三、MyBatis 执行流程回顾
当查询缓存时,那么不会与 StatementHandler 打交道
StatementHandler 用于与 JDBC 进行交互,如声明 Statement 与填充参数等
笔记参考来源如下:
[1] 源码阅读网
[2] MyBatis源码解析大合集