MyBatis原理深入解析(三)
11 MyBatis
一级缓存实现
11.1 什么是一级缓存? 为什么使用一级缓存?
每当我们使用MyBatis
开启一次和数据库的会话,MyBatis
会创建出一个SqlSession
对象表示一次数据库会话。
在对数据库的一次会话中,我们有可能会反复地执行完全相同的查询语句,如果不采取一些措施的话,每一次查询都会查询一次数据库,而我们在极短的时间内做了完全相同的查询,那么它们的结果极有可能完全相同,由于查询一次数据库的代价很大,这有可能造成很大的资源浪费。
为了解决这一问题,减少资源的浪费,
MyBatis
会在表示会话的SqlSession
对象中建立一个简单的缓存,将每次查询到的结果结果缓存起来,当下次查询的时候,如果判断先前有个完全一样的查询,会直接从缓存中直接将结果取出,返回给用户,不需要再进行一次数据库查询了。如下图所示,
MyBatis
会在一次会话的表示—-一个SqlSession
对象中创建一个本地缓存local cache
,对于每一次查询,都会尝试根据查询的条件去本地缓存中查找是否在缓存中,如果在缓存中,就直接从缓存中取出,然后返回给用户;否则,从数据库读取数据,将查询结果存入缓存并返回给用户。
对于会话Session
级别的数据缓存,我们称之为一级数据缓存,简称一级缓存。
11.2 MyBatis
中的一级缓存是怎样组织的?(即SqlSession
中的缓存是怎样组织的?)
由于MyBatis
使用SqlSession
对象表示一次数据库的会话,那么,对于会话级别的一级缓存也应该是在SqlSession
中控制的。
实际上, SqlSession
只是一个MyBatis
对外的接口,SqlSession
将它的工作交给了Executor
执行器这个角色来完成,负责完成对数据库的各种操作。当创建了一个SqlSession
对象时,MyBatis
会为这个SqlSession
对象创建一个新的Executor
执行器,而缓存信息就被维护在这个Executor
执行器中,MyBatis
将缓存和对缓存相关的操作封装成了Cache
接口中。SqlSession
、Executor
、Cache
之间的关系如下列类图所示:
如上述的类图所示,Executor
接口的实现类BaseExecutor
中拥有一个Cache
接口的实现类PerpetualCache
,则对于BaseExecutor
对象而言,它将使用PerpetualCache
对象维护缓存。
综上,SqlSession
对象、Executor
对象、Cache
对象之间的关系如下图所示:
由于Session
级别的一级缓存实际上就是使用PerpetualCache
维护的,那么PerpetualCache
是怎样实现的呢?
PerpetualCache
实现原理其实很简单,其内部就是通过一个简单的HashMap<k,v>
来实现的,没有其他的任何限制。如下是PerpetualCache
的实现代码:
1
|
package org.apache.ibatis.cache.impl;
|
11.3 一级缓存的生命周期有多长?
MyBatis
在开启一个数据库会话时,会创建一个新的SqlSession
对象,SqlSession
对象中会有一个新的Executor
对象,Executor
对象中持有一个新的PerpetualCache
对象;当会话结束时,SqlSession
对象及其内部的Executor
对象还有PerpetualCache
对象也一并释放掉。- 如果
SqlSession
调用了close()
方法,会释放掉一级缓存PerpetualCache
对象,一级缓存将不可用;- 如果
SqlSession
调用了clearCache()
,会清空PerpetualCache
对象中的数据,但是该对象仍可使用;SqlSession
中执行了任何一个update
操作update()、delete()、insert()
,都会清空PerpetualCache
对象的数据,但是该对象可以继续使用;
11.4 SqlSession
一级缓存的工作流程
- 对于某个查询,根据
statementId
,params
,rowBounds
来构建一个key
值,根据这个key
值去缓存Cache
中取出对应的key
值存储的缓存结果;- 判断从
Cache
中根据特定的key
值取的数据数据是否为空,即是否命中;- 如果命中,则直接将缓存结果返回;
- 如果没命中:
- 去数据库中查询数据,得到查询结果;
- 将
key
和查询到的结果分别作为key
,value
对存储到Cache
中;- 将查询结果返回;
- 结束。
11.5 Cache
接口的设计以及CacheKey
的定义
如下图所示,MyBatis
定义了一个org.apache.ibatis.cache.Cache
接口作为其Cache
提供者的SPI(Service Provider Interface)
,所有的MyBatis
内部的Cache
缓存,都应该实现这一接口。MyBatis
定义了一个PerpetualCache
实现类实现了Cache
接口,实际上,在SqlSession
对象里的Executor
对象内维护的Cache
类型实例对象,就是PerpetualCache
子类创建的。
MyBatis
内部还有很多Cache
接口的实现,一级缓存只会涉及到这一个PerpetualCache
子类,Cache
的其他实现将会放到二级缓存中介绍。
我们知道,Cache
最核心的实现其实就是一个Map
,将本次查询使用的特征值作为key
,将查询结果作为value
存储到Map
中。现在最核心的问题出现了:怎样来确定一次查询的特征值?换句话说就是:怎样判断某两次查询是完全相同的查询?也可以这样说:如何确定Cache
中的key值?
MyBatis
认为,对于两次查询,如果以下条件都完全一样,那么就认为它们是完全相同的两次查询:
- 传入的
statementId
- 查询时要求的结果集中的结果范围(结果的范围通过
rowBounds.offset
和rowBounds.limit
表示)- 这次查询所产生的最终要传递给
JDBC
java.sql.Preparedstatement
的Sql语句字符串boundSql.getSql()
- 传递给
java.sql.Statement
要设置的参数值
现在分别解释上述四个条件:
- 传入的
statementId
,对于MyBatis
而言,你要使用它,必须需要一个statementId
,它代表着你将执行什么样的Sql
;MyBatis
自身提供的分页功能是通过RowBounds
来实现的,它通过rowBounds.offset
和rowBounds.limit
来过滤查询出来的结果集,这种分页功能是基于查询结果的再过滤,而不是进行数据库的物理分页;- 由于
MyBatis
底层还是依赖于JDBC
实现的,那么,对于两次完全一模一样的查询,MyBatis
要保证对于底层JDBC
而言,也是完全一致的查询才行。而对于JDBC
而言,两次查询,只要传入给JDBC
的SQL
语句完全一致,传入的参数也完全一致,就认为是两次查询是完全一致的。- 上述的第
3
个条件正是要求保证传递给JDBC
的SQL
语句完全一致;第4
条则是保证传递给JDBC
的参数也完全一致;即3
、4
两条MyBatis
最本质的要求就是:调用JDBC
的时候,传入的SQL
语句要完全相同,传递给JDBC
的参数值也要完全相同。
综上所述,CacheKey
由以下条件决定:statementId
+ rowBounds
+ 传递给JDBC
的SQL
+ 传递给JDBC
的参数值;
1. CacheKey
的创建
对于每次的查询请求,Executor
都会根据传递的参数信息以及动态生成的SQL
语句,将上面的条件根据一定的计算规则,创建一个对应的CacheKey
对象。
我们知道创建CacheKey
的目的,就两个:
- 根据
CacheKey
作为key
,去Cache
缓存中查找缓存结果;- 如果查找缓存命中失败,则通过此
CacheKey
作为key
,将从数据库查询到的结果作为value
,组成key
,value
对象存储到Cache
缓存中;
CacheKey
的构建被放置到了Executor
接口的实现类BaseExecutor
中,定义如下:
1
|
/**
|
2. CacheKey
的hashcode
生成算法
刚才已经提到,Cache
接口的实现,本质上是使用的HashMap<k,v>
,而构建CacheKey
的目的就是为了作为HashMap<k,v>
中的key
值。而HashMap
是通过key
值的hashcode
来组织和存储的,那么,构建CacheKey
的过程实际上就是构造其hashCode
的过程。下面的代码就是CacheKey
的核心hashcode
生成算法,感兴趣的话可以看一下:
1
|
public void update(Object object) {
|
MyBatis
认为的完全相同的查询,不是指使用sqlSession
查询时传递给Session
的所有参数值完完全全相同,你只要保证statementId
,rowBounds
,最后生成的SQL
语句,以及这个SQL
语句所需要的参数完全一致就可以了。
11.6 一级缓存的性能分析
1. MyBatis
对会话Session
级别的一级缓存设计的比较简单,就简单地使用了HashMap
来维护,并没有对HashMap
的容量和大小进行限制
读者有可能就觉得不妥了:如果我一直使用某一个SqlSession
对象查询数据,这样会不会导致HashMap
太大,而导致java.lang.OutOfMemoryError
错误啊?读者这么考虑也不无道理,不过MyBatis
的确是这样设计的。
MyBatis
这样设计也有它自己的理由:
- 一般而言
SqlSession
的生存时间很短。一般情况下使用一个SqlSession
对象执行的操作不会太多,执行完就会消亡; - 对于某一个
SqlSession
对象而言,只要执行update
操作update、insert、delete
,都会将这个SqlSession
对象中对应的一级缓存清空掉,所以一般情况下不会出现缓存过大,影响JVM
内存空间的问题; - 可以手动地释放掉
SqlSession
对象中的缓存。
2. 一级缓存是一个粗粒度的缓存,没有更新缓存和缓存过期的概念
MyBatis
的一级缓存就是使用了简单的HashMap
,MyBatis
只负责将查询数据库的结果存储到缓存中去,不会去判断缓存存放的时间是否过长、是否过期,因此也就没有对缓存的结果进行更新这一说了。
根据一级缓存的特性,在使用的过程中,我认为应该注意:
- 对于数据变化频率很大,并且需要高时效准确性的数据要求,我们使用
SqlSession
查询的时候,要控制好SqlSession
的生存时间,SqlSession
的生存时间越长,它其中缓存的数据有可能就越旧,从而造成和真实数据库的误差;同时对于这种情况,用户也可以手动地适时清空SqlSession
中的缓存; - 对于只执行、并且频繁执行大范围的
select
操作的SqlSession
对象,SqlSession
对象的生存时间不应过长。
12 MyBatis
二级缓存实现
MyBatis
的二级缓存是Application
级别的缓存,它可以提高对数据库查询的效率,以提高应用的性能。
12.1 MyBatis
的缓存机制整体设计以及二级缓存的工作模式
如上图所示,当开一个会话时,一个SqlSession
对象会使用一个Executor
对象来完成会话操作,MyBatis
的二级缓存机制的关键就是对这个Executor
对象做文章。如果用户配置了cacheEnabled="true"
,那么MyBatis
在为SqlSession
对象创建Executor
对象时,会对Executor
对象加上一个装饰者:CachingExecutor
,这时SqlSession
使用CachingExecutor
对象来完成操作请求。CachingExecutor
对于查询请求,会先判断该查询请求在Application
级别的二级缓存中是否有缓存结果,如果有查询结果,则直接返回缓存结果;如果缓存中没有,再交给真正的Executor
对象来完成查询操作,之后CachingExecutor
会将真正Executor
返回的查询结果放置到缓存中,然后在返回给用户。
CachingExecutor
是Executor
的装饰者,以增强Executor
的功能,使其具有缓存查询的功能,这里用到了设计模式中的装饰者模式,CachingExecutor
和Executor
的接口的关系如下类图所示:
12.2 MyBatis
二级缓存的划分
MyBatis
并不是简单地对整个Application
就只有一个Cache
缓存对象,它将缓存划分的更细,即是Mapper
级别的,即每一个Mapper
都可以拥有一个Cache
对象,具体如下:
1. 为每一个Mapper
分配一个Cache
缓存对象(使用<cache>
节点配置)
MyBatis
将Application
级别的二级缓存细分到Mapper
级别,即对于每一个Mapper.xml
,如果在其中使用了<cache>
节点,则MyBatis
会为这个Mapper
创建一个Cache
缓存对象,如下图所示:
注:上述的每一个
Cache
对象,都会有一个自己所属的namespace
命名空间,并且会将Mapper
的namespace
作为它们的ID
;
2. 多个Mapper
共用一个Cache
缓存对象(使用<cache-ref>
节点配置)
如果你想让多个Mapper
公用一个Cache
的话,你可以使用<cache-ref namespace="">
节点,来指定你的这个Mapper
使用到了哪一个Mapper
的Cache
缓存。
12.3 使用二级缓存,必须要具备的条件
MyBatis
对二级缓存的支持粒度很细,它会指定某一条查询语句是否使用二级缓存。
虽然在Mapper
中配置了<cache>
,并且为此Mapper
分配了Cache
对象,这并不表示我们使用Mapper
中定义的查询语句查到的结果都会放置到Cache
对象之中,我们必须指定Mapper
中的某条选择语句是否支持缓存,即如下所示,在<select>
节点中配置useCache="true"
,Mapper
才会对此select
的查询支持缓存特性,否则,不会对此select
查询,不会经过Cache
缓存。如下所示,select
语句配置了useCache="true"
,则表明这条select
语句的查询会使用二级缓存。
1
|
<select id="selectByMinSalary" resultMap="BaseResultMap" parameterType="java.util.Map" useCache="true">
|
总之,要想使某条select
查询支持二级缓存,你需要保证:
MyBatis
支持二级缓存的总开关:全局配置变量参数cacheEnabled=true
;- 该
select
语句所在的Mapper
,配置了<cache>
或<cached-ref>
节点,并且有效;- 该
select
语句的参数useCache=true
;
12.4 一级缓存和二级缓存的使用顺序
请注意,如果你的MyBatis
使用了二级缓存,并且你的Mapper
和select
语句也配置使用了二级缓存,那么在执行select
查询的时候,MyBatis
会先从二级缓存中取输入,其次才是一级缓存,即MyBatis
查询数据的顺序是:二级缓存 ———> 一级缓存 ——> 数据库。
12.5 二级缓存实现的选择
MyBatis
对二级缓存的设计非常灵活,它自己内部实现了一系列的Cache
缓存实现类,并提供了各种缓存刷新策略如LRU
,FIFO
等等;另外,MyBatis
还允许用户自定义Cache
接口实现,用户是需要实现org.apache.ibatis.cache.Cache
接口,然后将Cache
实现类配置在<cache type="">
节点的type
属性上即可;除此之外,MyBatis
还支持跟第三方内存缓存库如Memecached
的集成,总之,使用MyBatis
的二级缓存有三个选择:
MyBatis
自身提供的缓存实现;- 用户自定义的
Cache
接口实现;- 跟第三方内存缓存库的集成;
12.6 MyBatis自身提供的二级缓存的实现
MyBatis
自身提供了丰富的,并且功能强大的二级缓存的实现,它拥有一系列的Cache
接口装饰者,可以满足各种对缓存操作和更新的策略。
MyBatis
定义了大量的Cache
的装饰器来增强Cache
缓存的功能,如下类图所示。
对于每个Cache
而言,都有一个容量限制,MyBatis
各供了各种策略来对Cache
缓存的容量进行控制,以及对Cache
中的数据进行刷新和置换。MyBatis
主要提供了以下几个刷新和置换策略:
- LRU:(Least Recently Used),最近最少使用算法,即如果缓存中容量已经满了,会将缓存中最近最少被使用的缓存记录清除掉,然后添加新的记录;
- FIFO:(First in first out),先进先出算法,如果缓存中的容量已经满了,那么会将最先进入缓存中的数据清除掉;
- Scheduled:指定时间间隔清空算法,该算法会以指定的某一个时间间隔将Cache缓存中的数据清空;
13 如何细粒度地控制你的MyBatis
二级缓存
13.1 一个关于MyBatis
的二级缓存的实际问题
现有AMapper.xml
中定义了对数据库表ATable
的CRUD
操作,BMapper
定义了对数据库表BTable
的CRUD
操作;
假设MyBatis
的二级缓存开启,并且AMapper
中使用了二级缓存,AMapper
对应的二级缓存为ACache
;
除此之外,AMapper
中还定义了一个跟BTable
有关的查询语句,类似如下所述:
1
|
<select id="selectATableWithJoin" resultMap="BaseResultMap" useCache="true">
|
执行以下操作:
- 执行
AMapper
中的selectATableWithJoin
操作,此时会将查询到的结果放置到AMapper
对应的二级缓存ACache
中; - 执行
BMapper
中对BTable
的更新操作update、delete、insert
后,BTable
的数据更新; - 再执行
1
完全相同的查询,这时候会直接从AMapper
二级缓存ACache
中取值,将ACache中
的值直接返回;
好,问题就出现在第3
步上:
由于AMapper
的selectATableWithJoin
对应的SQL
语句需要和BTable
进行join
查找,而在第2
步BTable
的数据已经更新了,但是第3
步查询的值是第1
步的缓存值,已经极有可能跟真实数据库结果不一样,即ACache
中缓存数据过期了!
总结来看,就是:
对于某些使用了
join
连接的查询,如果其关联的表数据发生了更新,join
连接的查询由于先前缓存的原因,导致查询结果和真实数据不同步;
从MyBatis
的角度来看,这个问题可以这样表述:
对于某些表执行了更新
update、delete、insert
操作后,如何去清空跟这些表有关联的查询语句所造成的缓存;
13.2 当前MyBatis
二级缓存的工作机制
MyBatis
二级缓存的一个重要特点:即松散的Cache
缓存管理和维护。
一个
Mapper
中定义的增删改查操作只能影响到自己关联的Cache
对象。如上图所示的Mapper namespace1
中定义的若干CRUD
语句,产生的缓存只会被放置到相应关联的Cache1
中,即Mapper namespace2,namespace3,namespace4
中的CRUD
的语句不会影响到Cache1
。可以看出,
Mapper
之间的缓存关系比较松散,相互关联的程度比较弱。
现在再回到上面描述的问题,如果我们将AMapper
和BMapper
共用一个Cache
对象,那么,当BMapper
执行更新操作时,可以清空对应Cache
中的所有的缓存数据,这样的话,数据不是也可以保持最新吗?
确实这个也是一种解决方案,不过,它会使缓存的使用效率变的很低!AMapper
和BMapper
的任意的更新操作都会将共用的Cache
清空,会频繁地清空Cache
,导致Cache
实际的命中率和使用率就变得很低了,所以这种策略实际情况下是不可取的。
最理想的解决方案就是:
对于某些表执行了更新
update、delete、insert
操作后,如何去清空跟这些表有关联的查询语句所造成的缓存;这样,就是以很细的粒度管理MyBatis
内部的缓存,使得缓存的使用率和准确率都能大大地提升。
13.3 mybatis-enhanced-cache
插件的设计和工作原理
该插件主要由两个构件组成:EnhancedCachingExecutor
和EnhancedCachingManager
。源码地址:https://github.com/LuanLouis/mybatis-enhanced-cache。
EnhancedCachingExecutor
是针对于Executor
的拦截器,拦截Executor
的几个关键的方法;EnhancedCachingExecutor
主要做以下几件事:
- 每当有
Executor执行query
操作时,- 记录下该查询
statementId
和CacheKey
,然后将其添加到EnhancedCachingManager
中; - 记录下该查询
statementId
和此statementId
所属Mapper
内的Cache
缓存对象引用,添加到EnhancedCachingManager
中;
- 记录下该查询
- 每当
Executor
执行了update
操作时,将此update
操作的statementId
传递给EnhancedCachingManager
,让EnhancedCachingManager
根据此update
的statementId
的配置,去清空指定的查询语句所产生的缓存;
另一个构件:EnhancedCachingManager
,它也是本插件的核心,它维护着以下几样东西:
- 整个
MyBatis
的所有查询所产生的CacheKey
集合(以statementId
分类);- 所有的使用过了的查询的
statementId
及其对应的Cache
缓存对象的引用;update
类型的statementId
和查询statementId
集合的映射,用于当update
类型的语句执行时,根据此映射决定应该清空哪些查询语句产生的缓存;
如下图所示:
原理很简单,就是当执行了某个
update
操作时,根据配置信息去清空指定的查询语句在Cache
中所产生的缓存数据。
13.4 mybatis-enhanced-cache
插件的使用实例
1. 配置MyBatis
配置文件
1
|
<plugins>
|
其中,
<property name="dependency">
中的value
属性是statementId
之间的依赖关系的配置文件路径。
2. 配置statementId
之间的依赖关系
1
|
|
<statement>
节点配置的是更新语句的statementId
,其内的子节点<observer>
配置的是当更新语句执行后,应当清空缓存的查询语句的statementId
。子节点<observer>
可以有多个。
如上的配置,则说明,如果
com.louis.mybatis.dao.DepartmentsMapper.updateByPrimaryKey
更新语句执行后,由com.louis.mybatis.dao.EmployeesMapper.selectWithDepartments
语句所产生的放置在Cache
缓存中的数据都都会被清空。
3. 配置DepartmentsMapper.xml
和EmployeesMapper.xml
1
|
|
1
|
|
4. 测试代码:
1
|
public class SelectDemo3 {
|
5. 结果分析:
从上述的结果可以看出,前四次执行了
com.louis.mybatis.dao.EmployeesMapper.selectWithDepartments
语句,EmployeesMapper
对应的Cache
缓存中存储的结果缓存由1
个增加到4
个。当执行了
com.louis.mybatis.dao.DepartmentsMapper.updateByPrimaryKey
后,EmployeeMapper
对应的缓存Cache
结果被清空了,即com.louis.mybatis.dao.DepartmentsMapper.updateByPrimaryKey
更新语句引起了EmployeeMapper中
的com.louis.mybatis.dao.EmployeesMapper.selectWithDepartments
缓存的清空。
原文作者:文/陶邦仁(简书作者)
原文标题:终结篇:MyBatis原理深入解析(三)