MyBatis 内置了一个强大的事务性查询缓存机制,它可以非常方便地配置和定制。Mybatis框架中的缓存分为一级缓存和二级缓存,三级缓存基本都要借助自定义缓存或第三方服务来进行实现。但本质上是一样的,都是借助Cache接口实现的。缓存模块在Mybatis的源码结构中是在 org.apache.ibatis.cache
包下面存放着的, 如下图:
Cache接口是缓存模块中最核心的接口, Mybatis框架中的实现类如下图所示,起始核心的实现类就只有一个, 就是
PerpetualCache类。
其他的实现类都是通过装饰者模式对PerpetualCache类的功能扩展和增强, 有兴趣可以自行查看源码。
核心方法都比较简单,都是一些很容易理解的方法,说白了就是对Map集合基本操作的封装。
缓存只作用于 cache 标签所在的映射文件中的语句。如果你混合使用 Java API 和 XML
映射文件,在共用接口中的语句将不会被默认缓存。你需要使用 @CacheNamespaceRef 注解指定缓存作用域。
这些属性可以通过 cache 元素的属性来修改。比如:
<cache eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>
翻译一下上面这行代码:
eviction
属性代表缓存清除策略
可用的清除策略有:
LRU
– 最近最少使用:移除最长时间不被使用的对象。FIFO
– 先进先出:按对象进入缓存的顺序来移除它们。SOFT
– 软引用:基于垃圾回收器状态和软引用规则移除对象。WEAK
– 弱引用:更积极地基于垃圾收集器状态和弱引用规则移除对象。
Mybatis默认清除策略是
LRU
。
flushInterval
属性代表缓存刷新时间间隔
刷新间隔)属性可以被设置为任意的正整数,设置的值应该是一个以毫秒为单位的合理时间量。
默认情况是不设置,也就是没有刷新间隔,缓存仅仅会在调用语句时刷新。
size
属性代表缓存对象数量
引用数目)属性可以被设置为任意正整数,要注意欲缓存对象的大小和运行环境中可用的内存资源。默认值是
1024
。
readOnly
属性代表缓存对象是否只读
(只读)属性可以被设置为 true 或 false。只读的缓存会给所有调用者返回缓存对象的相同实例。
因此这些对象不能被修改。这就提供了可观的性能提升。而可读写的缓存会(通过序列化)返回缓存对象的拷贝。
速度上会慢一些,但是更安全,因此默认值是false
。
注意:二级缓存是事务性的。这意味着,当 SqlSession 完成并提交时,或是完成并回滚,但没有执行 flushCache=true 的
insert/delete/update 语句时,缓存会获得更新。
首先通过本文第2小节我们已经知道了Mybatis框架提供了一个Cache接口,同时提供了一个基本的缓存实现PerpetualCache,基本的缓存功能都已经提供了,但是这里有没有什么问题?
这里直接抛出几个问题, 大家思考一下:
这些问题大家想想,如果只依赖基础的PerpetualCache实现类, 能帮我们实现么, 肯定是不能的,需要我们自己去实现么? 肯定也是不需要的, Mybatis框架的设计者就这些问题结合设计模式为我们提供了现成的解决方案,就是一下这些缓存装饰器, 我们可以通过多个组合使用来实现我们在实际业务中的一些特殊的需求。
缓存实现类 | 实现类说明 | 装饰缓存属性条件 | 主要功能和作用 |
---|---|---|---|
基本缓存 | 缓存基本实现类 | 无 | 默认是PerpetualCache,也可以自定义比如RedisCache、EhCache等,具备基本功能的缓存类 |
LruCache | LRU策略的缓存 | eviction=“LRU”(默认) | 当缓存到达上限时候,删除最近最少使用的缓存(Least Recently Use) |
FifoCache | FIFO策略的缓存 | eviction=“FIFO” | 当缓存到达上限时候,删除最先入队的缓存 |
SoftCacheWeakCache | eviction="SOFT"eviction=“WEAK” | 带清理策略的缓存 | 通过JVM的软引用和弱引用来实现缓存,当JVM内存不足时,会自动清理掉这些缓存,基于SoftReference和WeakReference |
LoggingCache | 带日志功能的缓存 | Base | 比如:输出缓存命中率 |
SynchronizedCache | 同步缓存 | Base | 基于synchronized关键字实现,解决并发问题 |
BlockingCache | 阻塞缓存 | blocking=true | 通过在get/put方式中加锁,保证只有一个线程操作缓存,基于Java重入锁实现 |
SerializedCache | readOnly=false(默认) | 支持序列化的缓存 | 将对象序列化以后存到缓存中,取出时反序列化 |
ScheduledCache | 定时调度的缓存 | flushInterval不为空 | 在进行get/put/remove/getSize等操作前,判断缓存时间是否超过了设置的最长缓存时间(默认是一小时),如果是则清空缓存–即每隔一段时间清空一次缓存 |
TransactionalCache | 事务缓存 | 在TransactionalCacheManager中用Map维护对应关系 | 在二级缓存中使用,可一次存入多个缓存,移除多个缓存 |
下面通过SynchronizedCache实现类来进行说明,
/*
* Copyright 2009-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.ibatis.cache.decorators;
import org.apache.ibatis.cache.Cache;
/**
* @author Clinton Begin
* 这个实现类的作用就是处理在并发缓存操作的安全问题,
* 所有的方法都加了synchronized关键字来保证线程安全
*/
public class SynchronizedCache implements Cache {
private final Cache delegate;
public SynchronizedCache(Cache delegate) {
this.delegate = delegate;
}
@Override
public String getId() {
return delegate.getId();
}
@Override
public synchronized int getSize() {
return delegate.getSize();
}
@Override
public synchronized void putObject(Object key, Object object) {
delegate.putObject(key, object);
}
@Override
public synchronized Object getObject(Object key) {
return delegate.getObject(key);
}
@Override
public synchronized Object removeObject(Object key) {
return delegate.removeObject(key);
}
@Override
public synchronized void clear() {
delegate.clear();
}
@Override
public int hashCode() {
return delegate.hashCode();
}
@Override
public boolean equals(Object obj) {
return delegate.equals(obj);
}
}
通过源码我们能够发现,SynchronizedCache本质上就是在我们操作缓存数据的实际调用方法上加了synchronized 同步锁保证了线程安全。其他具体实现类,大家可以自行查阅源码。
配置文件中缓存相关的配置参数解析
Myabtis框架中一级缓存和二级缓存默认是开启的
缓存默认的作用域是Session
Configuration初始化的时候会为我们的各种Cache实现类完成别名注册
解析全局配置文件mybatis-config.xml时会解析cacheEnabled属性并赋值给Configuration类的cacheEnabled属性, 默认值为true
如果你启动了二级缓存的配置, 那么在解析*Mapper.xml配置文件时,也会解析响应的cache属性,创建Cache对象并设置到Configuration类的caches属性中区,源码中具体实现见下图:
上图中的最后一行builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
调用了MapperBuilderAssistant
类中的useNewCache()
方法, 具体代码实现细节见下图, 也很简单:
首先需要明白的是我们需要缓存的对象是什么? 基本上就可以猜测出来我们需要在框架的那个具体环节进行处理。
通常我们呢需要缓存的就是我们从数据库中匹配出来的数据,所以这个缓存的操作一定是发生在SQL语句执行阶段的, 在Mybatis框架中专门负责语句执行的就是Executor, 所有我们呢只需要在Executor的实现类中查找关于缓存的处理逻辑代码实现即可。
一级缓存也叫本地缓存(Local Cache)。
MyBatis的一级缓存是在会话(SqlSession)层面进行缓存的。
MyBatis的一级缓存是默认开启的,不需要做任何的配置。
如果我们需要关闭一级缓存, 只需要修改Configuration中localCacheScope属性的取值为STATEMENT即可。
在我们的Executor对象创建的时候, 我们的一级缓存localCache对象就已经创建了,Executor对象又是什么时候创建的,是在SqlSession对象创建的时候完成的创建。验证流程如下图:
一级缓存对象是在BaseExecutor的构造器执行的时候进行创建的, BaseExecutor的构造器是在new SimpleExecutor()的时候被调用的,Configuration类的newExecutor方法中创建了SimpleExecutor对象,
而Configuration类的newExecutor方法又在factory.openSession()方法执行时被调用。
所以结论就是在我们创建sqlSession对象完成的时候, 一级缓存对象就已经准备就绪了。
Mybatis框架中提供了关闭一级缓存的示例代码,在BaseExecutor类的query()方法中,如下:
一级缓存的实现是在BaseExecutor
执行器实现类中实现的,具体代码如下:
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameter);
// 创建缓存Key
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
// 执行查询方法
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
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.");
}
// flushCache="true"时,即使是查询操作也要先清空一级缓存
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 {
// 如果没有命中,从数据库中查询
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
queryStack--;
}
if (queryStack == 0) {
for (DeferredLoad deferredLoad : deferredLoads) {
deferredLoad.load();
}
// issue #601
deferredLoads.clear();
// 如果当前本地缓存的作用域设置为STATEMENT,则将一级缓存关闭(清空相当于关闭操作)
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
// issue #482
clearLocalCache();
}
}
return list;
}
验证思路, 根据上面的流程图设计, 如果一级缓存命令, 就不会执行数据库查询操作, 那么查询语句在控制台肯定就只会输出一次, 同时两次查询都可以查询出结果。按照这个思路我们写一个单元测试来验证一下:
1)验证一:同一个SqlSession会话中
, 多次调用同一个查询方法
,验证一级缓存是否生效
验证代码:
public static void main(String[] args) {
SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
try (InputStream ins = Resources.getResourceAsStream("mybatis-config.xml")) {
SqlSessionFactory factory = factoryBuilder.build(ins);
SqlSession sqlSession = factory.openSession(true);
LibBookMapper mapper = sqlSession.getMapper(LibBookMapper.class);
List<LibBook> libBooks = mapper.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks = mapper.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks.forEach(System.out::println);
System.out.println("------------------------------------------");
libBooks = mapper.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks.forEach(System.out::println);
} catch (IOException e) {
e.printStackTrace();
}
}
验证结果:
验证结论:一级缓存只在同一sqlSession会话内的相同查询(方法相同、输入参数相同)才生效。
2)验证二:不同SqlSession会话中
, 调用同一个查询方法
,验证一级缓存是否生效
验证代码:
SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
try (InputStream ins = Resources.getResourceAsStream("mybatis-config.xml")) {
SqlSessionFactory factory = factoryBuilder.build(ins);
SqlSession sqlSession = factory.openSession(true);
SqlSession sqlSession1 = factory.openSession(true);
LibBookMapper mapper = sqlSession.getMapper(LibBookMapper.class);
LibBookMapper mapper1 = sqlSession1.getMapper(LibBookMapper.class);
List<LibBook> libBooks = mapper.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks = mapper.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks.forEach(System.out::println);
System.out.println("------------------------------------------");
libBooks = mapper1.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks.forEach(System.out::println);
} catch (IOException e) {
e.printStackTrace();
}
验证结果:
验证结论:一级缓存的作用范围是"Session",只在Session会话级别才生效
二级缓存是用来解决一级缓存不能跨会话(SqlSession)共享的问题的,范围是Mapper级别的,可以被多个SqlSession共享(只要是同一个接口里面的相同方法,都可以共享),生命周期和应用同步。
Mybatis框架中设计的二级缓存默认也是开启的, 但是我们在应用层调用时, 需要进行一些配置才可以生效。
同样我们通过缓存的作用范围再来反推一下, 二级缓存应该是发生在什么时候, 二级缓存的默认的作用范围是在Statement, 必然也是在Executor执行的时候才会进行缓存操作。
同时应为全局的cacheEnabled属性默认为true, 所以在创建Executor对象的时候会被CachingExecutor装饰, 那么二级缓存相关的操作大概率是在CachingExecutor类中实现的,下面我们到源码中验证一下
第一步:在全局配置文件mybatis-config.xml文件的settings标签对中开启全局配置二级缓存开启的配置
<settings>
<setting name="cacheEnabled" value="true" />
settings>
第二步:在mapper映射配置文件**Mapper.xml文件的增加如下配置:
<mapper namespace="com.kkarma.mapper.LibBookMapper">
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true" />
mapper>
Myabtis框架中对于缓存的存储都是在Configuration类中的caches属性中存储的。通过CacheKey进行一级和二级缓存的区分
*Mapper.xml文件中的解析顺序如下:
二级缓存对象是在**Mapper.xml文件解析的时候进行初始化, 因为如果开启二级缓存,*Mapper.xml文件中势必会配置cache
标签,会先解析cache
标签为当前mapper.xml文件的命名空间创建一个Cache对象。
在创建mappedStatement对象时, 会将单前命名空间中解析出来的二级缓存对象设置到当前mappedStatement对象中去。
二级缓存的操作是在CachingExecutor中完成的,
@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameterObject);
// 创建缓存KEY
CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
// 执行查询
return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
// 获取当前mapper接口对应的缓存对象, 这里就是二级缓存
// 二级缓存作为MappedStatement对象的一个属性,之前我们已经解析过, mapper映射文件解析之后会生成对应的MappedStatement对象
// 所以二级缓存肯定是在MappedStatement创建的时候进行的初始化,验证一下
Cache cache = ms.getCache();
if (cache != null) {
// 如果当前方法需要在执行之前刷新缓存, 这里就执行刷新缓存操作
flushCacheIfRequired(ms);
// 如果当前mappedStatement对象启用缓存并且未设置resultHandler
if (ms.isUseCache() && resultHandler == null) {
// 这里是排除存储过程语句执行的干扰
ensureNoOutParams(ms, boundSql);
@SuppressWarnings("unchecked")
// 从二级缓存中查询结果
List<E> list = (List<E>) tcm.getObject(cache, key);
// 如果缓存没命中,从数据库中进行数据查询
if (list == null) {
list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
// 将查询结果设置到二级缓存中
tcm.putObject(cache, key, list); // issue #578 and #116
}
// 返回查询结果为调用者
return list;
}
}
// 如果当前mappedStatement对象未启用缓存或设置了resultHandler
return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
2)验证二:不同SqlSession会话中
, 调用同一个查询方法
,验证一级缓存是否生效
验证代码:
public static void main(String[] args) {
SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
try (InputStream ins = Resources.getResourceAsStream("mybatis-config.xml")) {
SqlSessionFactory factory = factoryBuilder.build(ins);
SqlSession sqlSession = factory.openSession(true);
SqlSession sqlSession1 = factory.openSession(true);
LibBookMapper mapper = sqlSession.getMapper(LibBookMapper.class);
LibBookMapper mapper1 = sqlSession1.getMapper(LibBookMapper.class);
List<LibBook> libBooks = mapper.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks.forEach(System.out::println);
// sqlSession在执行完查询操作之后, 必须提交之后,缓存才会生效, 其他sqlSession对象次才能查询到缓存的数据
// 如果这里不进行提交操作, 那么sqlSession1执行相同方法时也会查询数据库, 缓存不会命中
sqlSession.commit();
System.out.println("------------------------------------------");
libBooks = mapper1.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks.forEach(System.out::println);
sqlSession.close();
sqlSession1.close();
} catch (IOException e) {
e.printStackTrace();
}
}
验证结论:二级缓存的作用范围是"statement",不同的sqlSession会话针对与同一个mapper命名空间下的方法查询都会走二级缓存。
注意事项:
在事务提交之前,并不会真正存储到二级缓存,而是先存储到一个临时属性,等事务提交之后才会真正存储到二级缓存。
这么做的目的就是防止脏读。
因为假如你在一个事务中修改了数据,然后去查询,这时候直接缓存了,那么假如事务回滚了呢?所以这里会先临时存储一下。
二级缓存的操作最终都是借助TransactionalCache
装饰器来实现的。因为二级缓存需要开启事务。
具体代码请自行查看TransactionalCache
类的实现
三级缓存一般都是自定义缓存。分布式缓存框架:我们系统为了提高系统并发和性能,一般对系统进行分布式部署(集群部署方式)不适用分布缓存, 缓存的数据在各个服务单独存储,不方便系统开发。所以要使用分布式缓存对缓存数据进行集中管理.ehcache,redis ,memcache,caffeine缓存框架,这里演示caffeine和redis缓存框架来进行实现:
这里使用的是mybatis-encache框架来实现:
项目地址: https://github.com/mybatis/caffeine-cache
文档地址:https://mybatis.org/caffeine-cache/
这个代码本质上就是通过拓展实现Mybatis框架的Cahce接口来实现的,有兴趣可以自行研究,代码实现也很简单,很容易看。
第一步:在我们的pom文件中引入依赖
<dependency>
<groupId>org.mybatis.cachesgroupId>
<artifactId>mybatis-caffeineartifactId>
<version>1.0.0version>
dependency>
第三步:将*Mapper.xml文件中的二级缓存类型替换成CaffeineCache
<mapper namespace="org.kkarma.mapper.LibBookMapper">
<cache type="org.mybatis.caches.redis.CaffeineCache" />
mapper>
验证代码:
public static void main(String[] args) {
SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
try (InputStream ins = Resources.getResourceAsStream("mybatis-config.xml")) {
SqlSessionFactory factory = factoryBuilder.build(ins);
SqlSession sqlSession = factory.openSession(true);
SqlSession sqlSession1 = factory.openSession(true);
LibBookMapper mapper = sqlSession.getMapper(LibBookMapper.class);
LibBookMapper mapper1 = sqlSession1.getMapper(LibBookMapper.class);
List<LibBook> libBooks = mapper.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks.forEach(System.out::println);
// sqlSession在执行完查询操作之后, 必须提交之后,缓存才会生效, 其他sqlSession对象次才能查询到缓存的数据
// 如果这里不进行提交操作, 那么sqlSession1执行相同方法时也会查询数据库, 缓存不会命中
sqlSession.commit();
System.out.println("------------------------------------------");
libBooks = mapper1.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks.forEach(System.out::println);
sqlSession.close();
sqlSession1.close();
} catch (IOException e) {
e.printStackTrace();
}
}
这里使用的是mybatis-redis框架来实现:
项目地址: https://github.com/mybatis/redis-cache
文档地址:http://mybatis.org/redis-cache/index.html
这个代码本质上就是通过拓展实现Mybatis框架的Cahce接口来实现的,有兴趣可以自行研究,代码实现也很简单,很容易看。
第一步:在我们的pom文件中引入依赖
<dependency>
<groupId>org.mybatis.cachesgroupId>
<artifactId>mybatis-redisartifactId>
<version>1.0.0-beta2version>
dependency>
第二步:开启二级缓存
第三步:将*Mapper.xml文件中的二级缓存l类型替换成RedisCache
<mapper namespace="org.kkarma.mapper.LibBookMapper">
<cache type="org.mybatis.caches.redis.RedisCache" />
mapper>
第四步:在resoure目录下增加redis连接的相关配置文件redis.properties
,文件内容如下:
host=***.***.***.***
port=****
password=********
databse=*
验证代码:
public static void main(String[] args) {
SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
try (InputStream ins = Resources.getResourceAsStream("mybatis-config.xml")) {
SqlSessionFactory factory = factoryBuilder.build(ins);
SqlSession sqlSession = factory.openSession(true);
SqlSession sqlSession1 = factory.openSession(true);
LibBookMapper mapper = sqlSession.getMapper(LibBookMapper.class);
LibBookMapper mapper1 = sqlSession1.getMapper(LibBookMapper.class);
List<LibBook> libBooks = mapper.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks.forEach(System.out::println);
// sqlSession在执行完查询操作之后, 必须提交之后,缓存才会生效, 其他sqlSession对象次才能查询到缓存的数据
// 如果这里不进行提交操作, 那么sqlSession1执行相同方法时也会查询数据库, 缓存不会命中
sqlSession.commit();
System.out.println("------------------------------------------");
libBooks = mapper1.selectBooksByCondition("T311.5/3-1", null, null, null);
libBooks.forEach(System.out::println);
sqlSession.close();
sqlSession1.close();
} catch (IOException e) {
e.printStackTrace();
}
}