前言
提示:自从上次发现mybatis缓存可被修改后,就一直想针对myBatis缓存单独做一期分析,包含其原理和运行方式,现在终于得空来详细写一篇了。讲Mybatis的缓存时,我们提到了CachingExecutor,知道了这个带缓存的执行器就是二级缓存的来源,这次我们系统的分析下其是如何产生作用的
一、MyBatis的两级缓存介绍
熟悉MyBatis的应该知道,MyBatis内置了两级缓存,会在查询数据库时,将查询结果缓存到内存中,以便下次查询时可以直接从缓存中获取数据,从而提高数据查询效率 MyBatis缓存一般分为一级缓存和二级缓存。
-
一级缓存 是指MyBatis自身的缓存机制,是SqlSession级别的缓存。当同一个SqlSession执行相同的SQL语句时,MyBatis会将查询结果缓存到内存中。一级缓存的作用域是SqlSession,当当前的SqlSession关闭时,一级缓存也将被清空。
-
二级缓存 是指MyBatis全局的缓存机制,在多个SqlSession之间共享缓存数据。二级缓存的作用域是Mapper级别的,每个Mapper对应一个缓存。在同一应用程序中的多个SqlSession都可以共享同一个缓存,这是一种横向共享的缓存机制。但是需要注意的是,该缓存只有在Mapper映射文件中声明了缓存的情况下才能启用。
二、一级缓存
1. sqlSession的结构和目的
在说SqlSession级别的缓存前,我们需要介绍下sqlSession本身的一些内容。我们都知道,在早期项目中,与数据库的连接通常以JDBC 的 connection(即连接)为核心,通过jdbc的API获取数据库连接,并执行代码,其形式大体如下:
import java.sql.*;
public class MySqlConnectionExample {
public static void main(String[] args) {
String url = "jdbc:mysql://localhost:3306/mydatabase";
String user = "root";
String password = "mypassword";
String sql = "SELECT * FROM mytable";
try {
// 1. 加载 MySQL JDBC 驱动程序
Class.forName("com.mysql.cj.jdbc.Driver");
// 2. 建立数据库连接(仅举例,实际项目一般都有连接池)
Connection conn = DriverManager.getConnection(url, user, password);
// 3. 创建 Statement 对象
Statement stmt = conn.createStatement();
// 4. 执行 SQL 查询语句,并返回结果集
ResultSet rs = stmt.executeQuery(sql);
// 5. 遍历结果集,输出查询结果
while (rs.next()) {
int id = rs.getInt("id");
String name = rs.getString("name");
System.out.println("id: " + id + ", name: " + name);
}
// 6. 关闭结果集、Statement 对象和数据库连接
rs.close();
stmt.close();
conn.close();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
看起来,通过获取连接来执行sql似乎就够了,为什么myBatis还要加入一个sqlSqlsession,sqlSqlsession又是拿来做什么的呢?其实,使用 SqlSession 相比于 Connection 直接执行 SQL 的主要优点如下:
- 封装了数据库连接的创建和释放,简化了代码编写,降低了出错风险。
- 提供了多种执行 SQL 的方法,支持动态 SQL 和对象关系映射(ORM)等高级特性。
- 支持缓存机制,可以缓存操作结果和查询结果,提高系统性能。
在应用程序启动时执行的进程。下述处理(1)至(4)对应于这种类型
- SqlSessionFactoryBean 请求SqlSessionFactory 构建SqlSessionFactoryBuilder
- SqlSessionFactoryBuilder 读取 MyBatis 配置文件生成SqlSessionFactory.
- SqlSessionFactoryBuilder 根据 MyBatis 配置文件的定义生成SqlSessionFactory ,SqlSessionFactory 生成的对象由 Spring 容器存储
- MapperFactoryBean 生成一个线程安全的SqlSession (SqlSessionTemplate)和一个线程安全的Mapper对象(Mapper接口的Proxy对象)。生成的Mapper对象存储在Spring 容器中。Mapper对象使用线程安全的SqlSession ( 即 SqlSessionTemplate) 提供线程安全的实现。
为每个请求执行的过程。下述处理(5)至(11)对应于这种类型
- 请求应用程序的进程
- Application(Service)调用容器注入的Mapper对象(实现Mapper接口的Proxy对象)的方法。
- Mapper对象调用与之对应的SqlSession (SqlSessionTemplate) 方法。
- SqlSession ( SqlSessionTemplate) 启用代理并调用其线程安全的SqlSession 方法
- 代理内的SqlSession 分配给事务。当事务没有对应的sqlSession时,将调用SqlSessionFactory 去获取一个SqlSession
- SqlSessionFactory 返回 SqlSession。返回的SqlSession 被分配给事务,如果它在同一个事务内,则使用该SqlSession而不创建新的
- SqlSession 从映射文件中获取要执行的 SQL 并执行 SQL。
2. sqlSession缓存
sqlSession缓存并非直接挂在sqlSession对象下,而是存储在执行器 BaseExecutor.class 中的 localCache(缓存查询结果) 和 localOutputParameterCache(缓存存储过程调用结果)实现,类名为 PerpetualCache.class
而PerpetualCache类则内含一个普通的HashMap,这个HashMap即真正的缓存位置
这样的缓存键值对,值我们是能想到的,就是SQL查询后返回的结果列表,那么键是什么呢?
CacheKey key = this.createCacheKey(ms, parameter, rowBounds, boundSql);
CacheKey 由以下几个因素影响
- namespace.id
- 用户传递给 SQL 语句的实际参数值
- 指定查询结果集的范围(分页信息)
- 查询所使用的 SQL 语句
而它的存储流程,其实很简单
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
// 省略部分代码
if (queryStack == 0 && ms.isFlushCacheRequired()) {
// 如果开启了flushCache,则清除缓存
clearLocalCache();
}
List<E> list;
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);
}
// 省略部分代码
return list;
}
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
this.localCache.putObject(key, ExecutionPlaceholder.EXECUTION_PLACEHOLDER);
List list;
try {
list = this.doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
this.localCache.removeObject(key);
}
this.localCache.putObject(key, list);
if (ms.getStatementType() == StatementType.CALLABLE) {
this.localOutputParameterCache.putObject(key, parameter);
}
return list;
}
3. 缓存生命周期
BaseExecutor不仅定义了一级缓存,同时也声明了这种缓存的生命周期:
- 如果SqlSession调用了close()方法,会释放掉一级缓存PerpetualCache对象,一级缓存将不可用;
- 如果SqlSession调用了clearCache(),会清空PerpetualCache对象中的数据,但是该对象仍可使用;
- SqlSession中执行了任何一个update操作(update()、delete()、insert()) ,都会清空PerpetualCache对象的数据,但是该对象可-以继续使用;
- 会话结束
但是,从源码我们不难发现一个问题,从缓存中取到的内容,是作为查询结果直接返回的。这意味着,如果我们对结果进行修改,将直接改变缓存值。并且,因为一级缓存没有提供关闭的参数,所以此时我们有两种方式解决
- 深拷贝sql结果,对拷贝的对象进行操作而非原对象
- 对指定sql,设置参数 flushCache=“true”,表示任何时候语句被调用,都会导致本地缓存和二级缓存被清空,形如
<select id="save" parameterType="XX" flushCache="true" useCache="false"> </select>
这样设置能生效的原因是,该参数打开后,会在SQL执行前清除掉缓存,这样相当于禁用了缓存功能
三、二级缓存
1. 开启二级缓存
二级缓存不同于一级缓存,它默认是关闭的,需要手动开启,我们知道一级缓存是由BaseExecutor负责存储的,那么负责二级缓存的实际上是它的兄弟类CachingExecutor
它的缓存存储位置在TransactionalCacheManager 类中
public class CachingExecutor implements Executor {
// 普通的执行器,通常就是我们上面的BaseExecutor的一个子类
private final Executor delegate;
private final TransactionalCacheManager tcm = new TransactionalCacheManager();
}
public class TransactionalCacheManager {
private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
}
那么,我们如何才能启用这个执行器呢?首先,我们必须得在全局设置缓存可用,在配置文件上加上
mybatis.configuration.cache-enabled=true 或 mybatis-plus.configuration.cache-enabled=true
然后在我们都mapper层加上设置即可。
<mapper namespace="xx.xxx.xxx.mapper.xxxMapper">
<cache eviction="FIFO" flushInterval="60000" readOnly="false" size="1024"></cache>
</mapper>
当然,如果sql不是以xml形式写的,而是在mapper接口里以注解形式写的,那只要在mapper层加上注解 @CacheNamespace 也是同样的效果
2. 二级缓存的弊端
二级缓存开启后,同一个 namespace 下的所有操作语句,都影响着同一个 Cache,即二级缓存被多个 SqlSession 共享,是一个全局的变量。
即使我们为其设定了自动刷新的时间,二级缓存的脏读概率仍然很大。尤其是在多表查询的情况下,尽管可以给不同的mapper设定同一个缓存,但复杂场景下这种繁琐的配置几乎不可用。
<cache-ref namespace="com.example.mapper.UserInfoMapper" />
所以二级缓存实际上很少有人会开启。
3. 二级缓存的内部细节-CachingExecutor的在逻辑定位
1. 流程图中的位置
我把CachingExecutor在逻辑链路中的位置标出来了,就是储存在会话对象中,通过会话可使用到CachingExecutor,而CachingExecutor又内置一个SimpleExecutor,熟悉设计模式的同学应该知道这就是所谓的委派模式。当然,这里面会话内置的也可能直接就是SimpleExecutor了,那样的话,调用的就直接是SimpleExecutor执行器了。
4. 二级缓存的内部细节-CachingExecutor的生效
既然知道了CachingExecutor在会话对象中,那毫无疑问,就是在创建会话的时候,把一个CachingExecutor放入到会话对象中的,我们来看看,要实现这个目标要做什么
1.全局参数
首先,要想启用CachingExecutor,我们得开启一个全局的设置参数
mybatis.configuration.cache-enabled=true
为什么开了这个参数就有用呢?其实不难猜想,其作用的位置肯定还是在创建会话对象的时候,我们直接看源码吧
SqlSessionUtils.class
// SqlSessionUtils.class
// 通过工厂对象(单例,存在容器中),开启会话,获得会话对象,executorType是执行器枚举类(SIMPLE, REUSE, BATCH),一般是SIMPLE
session = sessionFactory.openSession(executorType);
DefaultSqlSessionFactory.class
// DefaultSqlSessionFactory.class
private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
Transaction tx = null;
try {
final Environment environment = configuration.getEnvironment();
final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
// 通过configuration(Mybatis配置,单例)创建执行器
final Executor executor = configuration.newExecutor(tx, execType);
return new DefaultSqlSession(configuration, executor, autoCommit);
} catch (Exception e) {
closeTransaction(tx); // may have fetched a connection so lets call close()
throw ExceptionFactory.wrapException("Error opening session. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
Configuration.class
// Configuration.class
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
executorType = executorType == null ? defaultExecutorType : executorType;
executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
Executor executor;
if (ExecutorType.BATCH == executorType) {
executor = new BatchExecutor(this, transaction);
} else if (ExecutorType.REUSE == executorType) {
executor = new ReuseExecutor(this, transaction);
} else {
// 创建了一个简易执行器
executor = new SimpleExecutor(this, transaction);
}
// 如果开启了缓存,即 mybatis.configuration.cache-enabled=true
if (cacheEnabled) {
// 创建了缓存执行器,并且把简易执行器作为其 delegate,通过构造方法放入
executor = new CachingExecutor(executor);
}
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}
如上,可以看出CachingExecutor就是由 mybatis.configuration.cache-enabled=true 开启的,并且和会话的情况无关,这是一个全局设置,一旦开启,所有会话都会首先调用CachingExecutor
2. MappedStatement启用Cache
是不是我们启用了CachingExecutor就可以使用二级缓存了呢?为什么这么说,我们还是直接看CachingExecutor的源码 CachingExecutor.class
// CachingExecutor.class
@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
// mappedStatement 就是我们写的mapper接口里的某个方法的所有信息,注意其描述的是一个方法,而不是一整个mapper接口的所有方法
// 当然,它同样也包含某些mapper层次的设置,如对应的xml文件位置等,此处的Cache同样是mapper层次的设置
Cache cache = ms.getCache();
// 有Cache才会真正的去找二级缓存,否则直接就让委托的执行器去查询数据了。
// 注意此处的Cache并不是缓存本身,而是mapper里的缓存配置
if (cache != null) {
// ....
List<E> list = (List<E>) tcm.getObject(cache, key);
// ....
return list;
}
}
return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
可以看到,要想真正启用缓存,还得在Mapper层级进行一次缓存配置,也就是所谓的
声明下Cache时可以设置一下参数
<cache eviction="FIFO" flushinterval="60000" size="512" readOnly="true"/>
当然也可以不进行任何参数配置,就单独声明下Cache,如下:
<cache/>
我们看一下此时MappedStatement里Cache的构成,可以说是套娃巅峰,把委派模式玩到了极致
我们通过源码看其实现 MapperBuilderAssistant.class
// MapperBuilderAssistant.class
public Cache useNewCache(Class\<? extends Cache> typeClass,
Class\<? extends Cache> evictionClass,
Long flushInterval,
Integer size,
boolean readWrite,
boolean blocking,
Properties props) {
Cache cache = new CacheBuilder(currentNamespace)
.implementation(valueOrDefault(typeClass, PerpetualCache.class))
.addDecorator(valueOrDefault(evictionClass, LruCache.class))
.clearInterval(flushInterval)
.size(size)
.readWrite(readWrite)
.blocking(blocking)
.properties(props)
.build();
configuration.addCache(cache);
currentCache = cache;
return cache;
}
5. 二级缓存的内部细节-二级缓存的存取
上面我们已经看了如何使二级缓存生效,但真正的查询和存入还没有细看,现在来看看其存储的位置,及如何读取,我们直接看下源码
1. 缓存源码分析
// CachingExecutor.class query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
if (cache != null) {
// 是否清缓存,即如果Cache配置里含有flushCache=“true” 则进行 tcm.clear();
flushCacheIfRequired(ms);
if (ms.isUseCache() && resultHandler == null) {
ensureNoOutParams(ms, boundSql);
@SuppressWarnings("unchecked")
// tcm 是一个成员变量,是通过new TransactionalCacheManager()赋值的,此处为查询
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
// 通过 tcm 进行结果的存储
tcm.putObject(cache, key, list); // issue #578 and #116
}
return list;
}
}
在进行下一步前,我们有必要看一下获取缓存,为什么要传两个入参,即cache和key?
- cache:mapper级别的缓存配置及二级缓存
- key:方法的全限定名及完整的sql即sql入参
接下来,我们不难发现已经出现了 存储、获取、清除这三种方法,且都围绕着tcm(TransactionalCacheManager),因此接下来,我们还得关注一下TransactionalCacheManager。
public class TransactionalCacheManager {
private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
public Object getObject(Cache cache, CacheKey key) {
// 先把缓存设置作为key,查询出一个TransactionalCache
return getTransactionalCache(cache).getObject(key);
}
private TransactionalCache getTransactionalCache(Cache cache) {
// 作为第一次查询,不难发现,所谓的value居然也是依托缓存设置来构造的
return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);
}
// ....
}
public class TransactionalCache implements Cache {
private final Cache delegate;
private boolean clearOnCommit;
// 临时待加缓存,查询数据库返回的结果,首先会放在这里,等本次事务提交后,才会加入到真正的二级缓存中,即调用套娃对象,层层深入,最终存入HashMap
// 在事务提交前,其他会话甚至本会话自己都无法看见该缓存,更无法使用该缓存
private final Map<Object, Object> entriesToAddOnCommit;
// 查二级缓存没查到时,会把key值存在这个miss的集合中
private final Set<Object> entriesMissedInCache;
public TransactionalCache(Cache delegate) {
this.delegate = delegate;
this.clearOnCommit = false;
this.entriesToAddOnCommit = new HashMap<>();
this.entriesMissedInCache = new HashSet<>();
}
public Object getObject(Object key) {
// issue #116
// 观察后,可以知道,这里的delegate其实就是我们说的mapper缓存设置
// 由此可见,缓存设置中也能包含缓存的值,并且以完整sql为键,sql结果为值以map存储
Object object = delegate.getObject(key);
if (object == null) {
entriesMissedInCache.add(key);
}
// issue #146
if (clearOnCommit) {
return null;
} else {
return object;
}
}
}
由于Cache的套娃十分严重,实际形成了链状引用,而且受配置的影响很大,所以没有办法把每一种配置缓存间的相互调用阐述详尽。但是我们仍然可以讲出其核心思想。忽略掉中间的套娃,最终实现存储的缓存类为PerpetualCache.class,其包含了一个HashMap,键就是我们上面提及的key(混合了方法信息,完整sql等),值为sql返回值的字节数组
2. 二级缓存可见性
二级缓存并不是即时生效的,我们可以关注下TransactionalCache 类,这个类看名字也知道是事务有关,它有一个成员变量
private final Map\<Object, Object> entriesToAddOnCommit;
这个变量我们上面源码分析里其实说了,就算查数据库,返回了结果,也不是立即就到我们说的终极位置——PerpetualCache的HashMap里。而是在这个变量里暂存,等待事务提交了,再把这里的数据存入真正的二级缓存处
而在此之前,即使是本会话,也没法从二级缓存中捞到东西,也就是说在一个事务里,你连续执行两次同样的sql,尽管二级缓存已经暂存了数据,但第二次sql经过CachingExecutor时,它并不会把这个数据给你,那么自然,其他的会话也是无法看见这个数据的。
因此,我们说二级缓存的数据,只在本事务提交后才正式可见,在此之前,其他会话甚至本会话自己,都无法使用该二级缓存
3. 一二级缓存优先级
看上图,不难明白,如果开启了二级缓存,则先查的是二级缓存(mapper级别),这和我们一般的认知相悖,因为大多数缓存层级,都是优先查一级缓存,未命中再去查的二级缓存。除了上面因为可见性问题导致的,先查一级缓存,Mybatis里在一二级都开启的情况下,优先使用的是二级缓存的数据,因此这里需要特别注意。