MyBatis缓存原理

383 阅读14分钟

前言

提示:自从上次发现mybatis缓存可被修改后,就一直想针对myBatis缓存单独做一期分析,包含其原理和运行方式,现在终于得空来详细写一篇了。讲Mybatis的缓存时,我们提到了CachingExecutor,知道了这个带缓存的执行器就是二级缓存的来源,这次我们系统的分析下其是如何产生作用的

一、MyBatis的两级缓存介绍

熟悉MyBatis的应该知道,MyBatis内置了两级缓存,会在查询数据库时,将查询结果缓存到内存中,以便下次查询时可以直接从缓存中获取数据,从而提高数据查询效率 MyBatis缓存一般分为一级缓存和二级缓存。

image.png

  1. 一级缓存 是指MyBatis自身的缓存机制,是SqlSession级别的缓存。当同一个SqlSession执行相同的SQL语句时,MyBatis会将查询结果缓存到内存中。一级缓存的作用域是SqlSession,当当前的SqlSession关闭时,一级缓存也将被清空。

  2. 二级缓存 是指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 的主要优点如下:

  1. 封装了数据库连接的创建和释放,简化了代码编写,降低了出错风险。
  2. 提供了多种执行 SQL 的方法,支持动态 SQL 和对象关系映射(ORM)等高级特性。
  3. 支持缓存机制,可以缓存操作结果和查询结果,提高系统性能。

image.png

在应用程序启动时执行的进程。下述处理(1)至(4)对应于这种类型

  1. SqlSessionFactoryBean 请求SqlSessionFactory 构建SqlSessionFactoryBuilder
  2. SqlSessionFactoryBuilder 读取 MyBatis 配置文件生成SqlSessionFactory.
  3. SqlSessionFactoryBuilder 根据 MyBatis 配置文件的定义生成SqlSessionFactory ,SqlSessionFactory 生成的对象由 Spring 容器存储
  4. MapperFactoryBean 生成一个线程安全的SqlSession (SqlSessionTemplate)和一个线程安全的Mapper对象(Mapper接口的Proxy对象)。生成的Mapper对象存储在Spring 容器中。Mapper对象使用线程安全的SqlSession ( 即 SqlSessionTemplate) 提供线程安全的实现。

为每个请求执行的过程。下述处理(5)至(11)对应于这种类型

  1. 请求应用程序的进程
  2. Application(Service)调用容器注入的Mapper对象(实现Mapper接口的Proxy对象)的方法。
  3. Mapper对象调用与之对应的SqlSession (SqlSessionTemplate) 方法。
  4. SqlSession ( SqlSessionTemplate) 启用代理并调用其线程安全的SqlSession 方法
  5. 代理内的SqlSession 分配给事务。当事务没有对应的sqlSession时,将调用SqlSessionFactory 去获取一个SqlSession
  6. SqlSessionFactory 返回 SqlSession。返回的SqlSession 被分配给事务,如果它在同一个事务内,则使用该SqlSession而不创建新的
  7. SqlSession 从映射文件中获取要执行的 SQL 并执行 SQL。

2. sqlSession缓存

sqlSession缓存并非直接挂在sqlSession对象下,而是存储在执行器 BaseExecutor.class 中的 localCache(缓存查询结果) 和 localOutputParameterCache(缓存存储过程调用结果)实现,类名为 PerpetualCache.class

image.png

image.png

而PerpetualCache类则内含一个普通的HashMap,这个HashMap即真正的缓存位置

image.png

这样的缓存键值对,值我们是能想到的,就是SQL查询后返回的结果列表,那么键是什么呢?

CacheKey key = this.createCacheKey(ms, parameter, rowBounds, boundSql);

CacheKey 由以下几个因素影响

  1. namespace.id
  2. 用户传递给 SQL 语句的实际参数值
  3. 指定查询结果集的范围(分页信息)
  4. 查询所使用的 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不仅定义了一级缓存,同时也声明了这种缓存的生命周期:

  1. 如果SqlSession调用了close()方法,会释放掉一级缓存PerpetualCache对象,一级缓存将不可用;
  2. 如果SqlSession调用了clearCache(),会清空PerpetualCache对象中的数据,但是该对象仍可使用;
  3. SqlSession中执行了任何一个update操作(update()、delete()、insert()) ,都会清空PerpetualCache对象的数据,但是该对象可-以继续使用;
  4. 会话结束

但是,从源码我们不难发现一个问题,从缓存中取到的内容,是作为查询结果直接返回的。这意味着,如果我们对结果进行修改,将直接改变缓存值。并且,因为一级缓存没有提供关闭的参数,所以此时我们有两种方式解决

  1. 深拷贝sql结果,对拷贝的对象进行操作而非原对象
  2. 对指定sql,设置参数 flushCache=“true”,表示任何时候语句被调用,都会导致本地缓存和二级缓存被清空,形如
<select id="save" parameterType="XX" flushCache="true" useCache="false"> </select>

这样设置能生效的原因是,该参数打开后,会在SQL执行前清除掉缓存,这样相当于禁用了缓存功能

三、二级缓存

1. 开启二级缓存

二级缓存不同于一级缓存,它默认是关闭的,需要手动开启,我们知道一级缓存是由BaseExecutor负责存储的,那么负责二级缓存的实际上是它的兄弟类CachingExecutor

image.png

它的缓存存储位置在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 共享,是一个全局的变量。

image.png

即使我们为其设定了自动刷新的时间,二级缓存的脏读概率仍然很大。尤其是在多表查询的情况下,尽管可以给不同的mapper设定同一个缓存,但复杂场景下这种繁琐的配置几乎不可用。

<cache-ref namespace="com.example.mapper.UserInfoMapper" />

所以二级缓存实际上很少有人会开启。

3. 二级缓存的内部细节-CachingExecutor的在逻辑定位

1. 流程图中的位置

我把CachingExecutor在逻辑链路中的位置标出来了,就是储存在会话对象中,通过会话可使用到CachingExecutor,而CachingExecutor又内置一个SimpleExecutor,熟悉设计模式的同学应该知道这就是所谓的委派模式。当然,这里面会话内置的也可能直接就是SimpleExecutor了,那样的话,调用的就直接是SimpleExecutor执行器了。

image.png

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?

  1. cache:mapper级别的缓存配置及二级缓存
  2. 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返回值的字节数组

image.png

2. 二级缓存可见性
二级缓存并不是即时生效的,我们可以关注下TransactionalCache 类,这个类看名字也知道是事务有关,它有一个成员变量
private final Map\<Object, Object> entriesToAddOnCommit;

这个变量我们上面源码分析里其实说了,就算查数据库,返回了结果,也不是立即就到我们说的终极位置——PerpetualCache的HashMap里。而是在这个变量里暂存,等待事务提交了,再把这里的数据存入真正的二级缓存处

而在此之前,即使是本会话,也没法从二级缓存中捞到东西,也就是说在一个事务里,你连续执行两次同样的sql,尽管二级缓存已经暂存了数据,但第二次sql经过CachingExecutor时,它并不会把这个数据给你,那么自然,其他的会话也是无法看见这个数据的。

因此,我们说二级缓存的数据,只在本事务提交后才正式可见,在此之前,其他会话甚至本会话自己,都无法使用该二级缓存

3. 一二级缓存优先级

看上图,不难明白,如果开启了二级缓存,则先查的是二级缓存(mapper级别),这和我们一般的认知相悖,因为大多数缓存层级,都是优先查一级缓存,未命中再去查的二级缓存。除了上面因为可见性问题导致的,先查一级缓存,Mybatis里在一二级都开启的情况下,优先使用的是二级缓存的数据,因此这里需要特别注意。