为什么 MyBatis 要缓存
缓存在互联网系统中是非常重要的, 其主要作用是将数据保存到内存中, 当用户查询数据时, 优先从缓存容器中获取数据,而不是频繁地从数据库中查询数据,从而提高查询性能。而在 ORM 框架中引入缓存的目的就是为了减少读取数据库的次数,从而提升查询的效率。
在 MyBatis 中存在两种缓存,一个在事务内部使用的一级缓存,另一个可以全局使用的二级缓存。
一级缓存
一级缓存也叫本地缓存,在MyBatis中,一级缓存是在会话(SqlSession)层面实现的,这就说明一级缓存作用范围只能在同一个 SqlSession 中,跨 SqlSession 是无效的。
需要注意的是:MyBatis 中一级缓存是默认开启的,不需要任何的配置。
既然一级缓存的作用域只对同一个 SqlSession 有效,那么一级缓存应该存储在哪里比较合适是呢?
当然是 SqlSession 内是最合适的,下面我们看看 SqlSession 的唯一实现类 DefaultSqlSession,如下图所示:
我们知道,SqlSession 只提供对外接口,实际执行 sql 的就是 Executor。
下面是查询的执行流程:
每个 SqlSession 实例中都有一个 Executor 对象,Executor 对象执行查询操作。
Executor有多个实现,无论哪种实现,查询的时候都会执行父类 BaseExecutor 的 query 方法。
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
//获取SQL
BoundSql boundSql = ms.getBoundSql(parameter);
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);//创建一级缓存
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
从这里我们可以看到,在查询之前就会去 localCache 中根据 CacheKey 对象来获取缓存,获取不到才会调用下面的 query 方法中的 queryFromDatabase 方法。
接下来看一下缓存的 key 是怎么创建的。
缓存的 key 使用 CacheKey 表示。MyBatis 使用 createCacheKey 方法创建 CacheKey 对象,如下所示:
@Override
public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
if (closed) {
throw new ExecutorException("Executor was closed.");
}
CacheKey cacheKey = new CacheKey();
cacheKey.update(ms.getId());//Mapper接口的全限定类名+方法名
cacheKey.update(rowBounds.getOffset());//查询数据的偏移量
cacheKey.update(rowBounds.getLimit());//查询条数
cacheKey.update(boundSql.getSql());//原始SQL语句
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
// mimic DefaultParameterHandler logic
for (ParameterMapping parameterMapping : parameterMappings) {
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
if (boundSql.hasAdditionalParameter(propertyName)) {
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
MetaObject metaObject = configuration.newMetaObject(parameterObject);
value = metaObject.getValue(propertyName);
}
cacheKey.update(value);
}
}
if (configuration.getEnvironment() != null) {
// issue #176
cacheKey.update(configuration.getEnvironment().getId());
}
return cacheKey;
}
创建缓存 key 会经过一系列的 update 方法,update 方法由一个 createKey 对象来执行,这个 update 方法最终由 updateList 的 list 来把五个值存进去,对照上面的代码和下面的图示,我们可以理解这五个值是什么了,如下图:
CacheKey 集合了 SQL 语句、SQL 参数、调用的 Mapper 方法名、SQL 分页参数、Environment 对象信息。当比较两个 CacheKey 是否相等时,上述这些数据都要参与比较,而且 CacheKey 对象哈希值也是基于这些数据生成的。因此 CacheKey 重写了 hashCode(),equals()。
下面看一下 cacheKey.update 方法就可以明白 hashCode(),equals() 是如何做的了。
//每次向CacheKey添加数据时,都要调用该方法
public void update(Object object) {
//计算入参对象的hash值
int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);
count++;
checksum += baseHashCode;
baseHashCode *= count;
//计算CacheKey对象哈希值
//hashCode()方法返回的就是hashcode值
//multiplier = 37
hashcode = multiplier * hashcode + baseHashCode;
//updateList是List对象,用于保存原始数据,比较两个CacheKey是否相等,要比较updateList中的数据
updateList.add(object);
}
创建完 CacheKey 之后,我们继续进入 query 方法:
@SuppressWarnings("unchecked")
@Override
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) {//检测当前Executor是否已经被关闭
throw new ExecutorException("Executor was closed.");
}
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();
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
// issue #482
//如果设置localCacheScope=STATEMENT,则清空缓存
clearLocalCache();
}
}
return list;
}
在 query 方法中,如果查不到的话,就去数据库查询,在 queryFromDatabase 中会对 localCache 进行写入,localCache 对象的 put 方法最终会交给 Map 进行存放,如下所示:
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
//在缓存中添加占位符
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
//移除占位符
localCache.removeObject(key);
}
//将结果缓存
localCache.putObject(key, list);
//如果是存储过程则需要处理输出参数
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}
@Override
public void putObject(Object key, Object value) {
cache.put(key, value);
}
二级缓存
一级缓存因为只能在同一个 SqlSession 中共享,所以会有一个问题,在分布式或者多线程的环境下,不同会话之间对于相同的数据可能会产生不同的结果,因为跨会话修改了数据是不能互相感知的,所以就有可能存在脏数据的问题,正因为一级缓存存在这种不足,所以我们需要一种作用域更大的缓存,这就是二级缓存。
二级缓存默认是关闭的,开启二级缓存需要三步操作,如下:
1、在 mybatis-config 中有一个全局配置属性,这个不配置也行,因为默认就是 true。
<setting name="cacheEnabled" value="true"/>
2、在 Mapper 映射文件内需要配置缓存标签:
<cache/>
或
<cache-ref namespace="com.lonelyWolf.mybatis.mapper.UserAddressMapper"/>
3、在select查询语句标签上配置useCache属性,如下:
<select id="selectUserAndJob" resultMap="JobResultMap2" useCache="true">
select * from lw_user
</select>
以上配置第1点是默认开启的,也就是说我们只要配置第 2 点就可以打开二级缓存了,而第 3 点是当我们需要针对某一条语句来配置二级缓存时候则可以使用。
不过开启二级缓存的时候有两点需要注意:
1、需要 commit 事务之后才会生效
2、如果使用的是默认缓存,那么结果集对象需要实现序列化接口(Serializable)
如果不实现序列化接口则会报错误。
二级缓存是通过 CachingExecutor 对象来实现的,接下来我们来看看这个对象都有些什么。
我们看到 CachingExecutor 中只有 2 个属性,Executor 就不用说了,因为 CachingExecutor 本身就是 Executor 的包装器,所以属性 TransactionalCacheManager 可以确定就是用来管理二级缓存的,我们再进去看看 TransactionalCacheManager 对象是如何管理缓存的:
TransactionalCacheManager 中维护了一个 HashMap 来存储缓存。HashMap 中的 value 是一个TransactionalCache 对象,继承了 Cache,我们在进去看看。
public class TransactionalCache implements Cache {
private static final Log log = LogFactory.getLog(TransactionalCache.class);
private final Cache delegate;
private boolean clearOnCommit;
private final Map<Object, Object> entriesToAddOnCommit;
private final Set<Object> entriesMissedInCache;
我们可以看到其中也维护了一个 Map, 这个 Map 是暂存区真正用来暂存数据的地方, 而 delegate 属性代表的便是真正的缓存区, 有了与缓存区之间的关联, 在提交事务的时候, 就可以方便的把暂存区的数据刷新到缓存区了。
介绍完事务管理器, 暂存区, 缓存区之间的结构关系, 我们来通过源码看下二级缓存进行查询的过程,如下:
@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameterObject);
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 {
Cache cache = ms.getCache();//获取二级缓存
//全局配置文件默认开启,假如 Mapper.xml 文件没有开启二级缓存,这里就会是 null 值
if (cache != null) {
flushCacheIfRequired(ms);
if (ms.isUseCache() && resultHandler == null) {
//确保没有输出参数,这是因为二级缓存不能缓存输出类型参数
ensureNoOutParams(ms, boundSql);
@SuppressWarnings("unchecked")
//获取二级缓存
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
//如果二级缓存没有获取到,就回去执行原来的 Executor 中的 query 方法,也就是会再去读取一级缓存
list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
//二级缓存存储时会先保存在一个临时的属性中,直到事务提交才会保存到二级缓存
//存储二级缓存
tcm.putObject(cache, key, list); // issue #578 and #116
}
return list;
}
}
return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
需要注意的是在事务提交之前,并不会真正存储到二级缓存,而是先存储到一个临时属性,等事务提交之后才会真正存储到二级缓存。这么做的目的就是防止脏读。因为假如你在一个事务中修改了数据,然后去查询,这时候直接缓存了,那么假如事务回滚了呢?所以这里会先临时存储一下。所以我们看一下 commit 方法:
在提交的方法中, 我们会把暂存区中的所有内容刷新到缓存区中。
在我们调用 sqlSession.commit() 方法的时候, 也会调用当前会话持有的缓存执行器的 commit() 方法, 缓存执行器会执行事务缓存管理器的 commit() 方法。看一下事务缓存管理器的提交的源码, 在事务缓存管理器的 commit() 方法中, 会调用事务缓存管理器所有暂存区 (TransactionalCache) 的 commit() 方法。
在 TransactionalCache 的 commit() 方法中, 如果有未提交的更新操作( clearOnCommit 为 true), 则要清空缓存区, 因为更新后, 缓存区的数据便是不准确的了。随后调用 flushPendingEntries 和 reset 两个方法, flushPendingEntries 方法负责把所有暂存区的内容刷新到缓存中。而 reset 方法则负责把本地暂存区清空, 同时把 clearOnCommit 置为 false。
public class TransactionalCache implements Cache:
private final Cache delegate;//指向缓存区(链条式的 Cache 实现类)
private boolean clearOnCommit;//执行更新后 clearOnCommit 将变为 true
private final Map<Object, Object> entriesToAddOnCommit;//本地暂存
public void commit() {
if (clearOnCommit) {
delegate.clear();
}
flushPendingEntries();
reset();
}
private void flushPendingEntries() {
for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
delegate.putObject(entry.getKey(), entry.getValue());
}
for (Object entry : entriesMissedInCache) {
if (!entriesToAddOnCommit.containsKey(entry)) {
delegate.putObject(entry, null);
}
}
}
private void reset() {
clearOnCommit = false;
entriesToAddOnCommit.clear();
entriesMissedInCache.clear();
}