(五)Mybatis-缓存解析

1、概述

Mybatis的缓存大体上分为一级缓存和二级缓存,我们先来说下一级缓存。

2、一级缓存

  当我们使用Mybatis对数据库进行一次查询操作的时候,会通过SqlSession来表示一次数据库会话。在每次会话中,可能会对数据库执行相同的SQL查询操作,而我们也知道,对数据库频繁操作是很耗费性能的,因为数据库中的数据是持久化再磁盘上的。Web工程最大的瓶颈就在于对磁盘文件的I/O操作,因为学过计算机的都了解,I/O操作比内存操作速度差了恐怕几个量级。

而为了避免相同sql的多次数据库查询操作,Mybatis提供了一个简单的缓存机制。将每次sql查询的结果缓存起来,下次相同sql执行的时候直接查询缓存。缓存中存在,从缓存中获取后直接返回,缓存中不存在,查询数据库将查询结果放入缓存并返回。我们把这种一次会话级别的缓存称为一级缓存。

2.1 实现

  一级缓存在Mybatis中是通过SqlSession中的Executor来维护的,上文我们已经了解过Executor了,这次不再详述了。在BaseExecutor中,维护了一个PerpetualCache的localCache,来实现一级缓存的功能。

2.1.1 首先,我们先来看下PerpetualCache的实现。

PerpetualCache的实现很简单,实现了Mybatis的Cache接口。Mybatis的Cache接口是用于缓存的接口,一般与缓存相关的类都应该实现这个接口。PerpetualCache内部维护了一个HashMap来实现缓存的功能:

public class PerpetualCache implements Cache {
  private String id;
  private Map cache = new HashMap();
}

id是一个名为LocalCache的字符串,而Map用来存储数据,key也就是接下来会说到的CacheKey,value则是查询到的数据。

2.1.2 然后我们来看下缓存的实现流程,我们从BaseExecutor的query方法看起。
public  List 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);
}

这里,比较重要的一点是缓存key的创建,如何确定相同sql的key值是相同的?这里会涉及到用于存储缓存key的CacheKey类。我们先来简单看下CacheKey,再来看下createCacheKey方法。

CacheKey

我们来看下CacheKey内部的实现:

public class CacheKey implements Cloneable, Serializable {
  ...
  public static final CacheKey NULL_CACHE_KEY = new NullCacheKey();

  private static final int DEFAULT_MULTIPLYER = 37;
  private static final int DEFAULT_HASHCODE = 17;

  // 这个是用于hashcode计算时的扩展因子,默认37
  private int multiplier;
  // 计算hashcode
  private int hashcode;
  // 生成key的各项参数的默认hashcode的总和
  private long checksum;
  // 计数
  private int count;
  // 生成key的各项参数
  private List updateList;
}
 
 

CacheKey内部有一些属性,用于生成cacheKey及获取时的校验。由于HashMap的get方法是先判断hashCode再equals进行判断,所以我们可以简单看下CacheKey中对hashcode的处理及equals方法。

private void doUpdate(Object object) {
    // 对象默认的hashcode
    int baseHashCode = object == null ? 1 : object.hashCode();
    // 计数
    count++;
    // 所有对象的hashcode相加
    checksum += baseHashCode;
    // 对象的hashcode扩大count倍
    baseHashCode *= count;
    // 根据扩展因子扩展,然后加上扩大后的对象的hashcode
    hashcode = multiplier * hashcode + baseHashCode;
    // 添加对象到list中
    updateList.add(object);
}

由于生成key的时候最终方法会调用到doUpdate,我们只需看下doUpdate方法,了解它的hashcode是如何生成的即可。我们再来看下equals方法:

public boolean equals(Object object) {
    if (this == object) {
      return true;
    }
    if (!(object instanceof CacheKey)) {
      return false;
    }

    final CacheKey cacheKey = (CacheKey) object;
    if (hashcode != cacheKey.hashcode) {
      return false;
    }
    if (checksum != cacheKey.checksum) {
      return false;
    }
    if (count != cacheKey.count) {
      return false;
    }

    for (int i = 0; i < updateList.size(); i++) {
      Object thisObject = updateList.get(i);
      Object thatObject = cacheKey.updateList.get(i);
      if (thisObject == null) {
        if (thatObject != null) {
          return false;
        }
      } else {
        if (!thisObject.equals(thatObject)) {
          return false;
        }
      }
    }
    return true;
}

equals方法会对CachKey的各个属性进行比较判断,并且会循环判断updateList中的每个元素,通过这种方式来保证key的唯一性。


createCacheKey相关

简单看了CacheKey后,我们再来看下createCacheKey方法,了解一下CacheKey的创建规则。

public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    CacheKey cacheKey = new CacheKey();
    // 1. MappedStatement的id
    cacheKey.update(ms.getId());
    // 2. 查询的分页参数 offset和limit
    cacheKey.update(Integer.valueOf(rowBounds.getOffset()));
    cacheKey.update(Integer.valueOf(rowBounds.getLimit()));
    // 3. sql语句
    cacheKey.update(boundSql.getSql());
    // 4. 传递给JDBC的参数
    List parameterMappings = boundSql.getParameterMappings();
    TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
    // mimic DefaultParameterHandler logic
    // 解析参数
    for (int i = 0; i < parameterMappings.size(); i++) {
      ParameterMapping parameterMapping = parameterMappings.get(i);
      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);
      }
    }
    // 5. 如果mybatis-config配置的environment不为空,取environment的id
    if (configuration.getEnvironment() != null) {
      // issue #176
      cacheKey.update(configuration.getEnvironment().getId());
    }
    return cacheKey;
}

createCacheKey方法表明了缓存key的生成规则,拿我们来看一下生成cacheKey的一些条件。

  1. MappedStatement的id。所谓id,即是Mybatis的映射文件中,每个select节点的namespace及名称,有了它,我们才能确定执行的是哪一条sql,我们拿上文的实例来看一下id:id="com.mapper.IStudentMapper.getAll"
  2. offset及limit。这里就与Mybatis的分页有关系了,Mybatis的分页功能是通过RowBounds来实现的,而RowBounds则是通过offset和limit属性来实现分页,而这种分页功能是基于查询结果的再过滤,而不是进行数据库的物理分页;
  3. sql语句。Mybatis的sql语句是通过BoundSql来实现的,这个就比较好理解了,sql语句不一样,那key肯定不会相同;
  4. 参数。也就是说,调用JDBC的时候,sql语句要一样,传递的参数也要完全一样,这样才是相同的sql。
  5. environment的id。这里大致说一点:这里配置的id是每个环境的id,可能开发,测试环境等。

针对environment的id,我们看下官网的解释就明白了:

MyBatis 可以配置成适应多种环境,这种机制有助于将 SQL 映射应用于多种数据库之中, 现实情况下有多种理由需要这么做。例如,开发、测试和生产环境需要有不同的配置;或者共享相同 Schema 的多个生产数据库, 想使用相同的 SQL 映射。许多类似的用例。
节选自:配置环境(environments)


query和queryFromDatabase方法

获取到缓存的key之后,接下来的操作就比较简单了。我们接着来看query方法:

public  List query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    ...
    // 如果mapper节点中配置了flushCache=true,就清空缓存
    // queryStack 参数应该是用于延迟加载用的
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
    List list;
    try {
      queryStack++;
      // 从缓存localCache中获取
      list = resultHandler == null ? (List) localCache.getObject(key) : null;
      // 如果获取到
      if (list != null) {
        // 针对存储过程的参数进行缓存
        handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
      } else {
        // 如果获取不到,查询数据库
        list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
      }
    } finally {
      queryStack--;
    }
    ...
    return list;
}

这里也说明了一点,如果我们不想从缓存里查询,只想查询数据库,那么只需要配置对应节点的flushCache=true即可了。


我们接着来看下queryFromDatabase方法,这个方法就是缓存数据库查询的结果并返回:

private  List queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    List list;
    localCache.putObject(key, EXECUTION_PLACEHOLDER);
    try {
      list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
      localCache.removeObject(key);
    }
    localCache.putObject(key, list);
    // 如果statementType类型是callable,则缓存存储过程的参数
    if (ms.getStatementType() == StatementType.CALLABLE) {
      localOutputParameterCache.putObject(key, parameter);
    }
    return list;
}

这里有一个问题,我以前就遇到过:

就是说 我们查询完成之后,localCache直接缓存我们查询的结果,并没有拷贝或者怎么处理,然后这个结果又被直接返回了。但是由于引用的关系,这里就会出现一种情况,就是外部修改了这个结果,缓存中的值也会跟着发生变化。这样的话,可能会出现我们意想不到的结果,所以这里可以注意一下。

2.1.3 我们再简单看下 insert,update,delete方法

我们随便看下这几个方法的实现,可以看到它们底层都是通过调用update方法来实现的,我们来看下BaseExecutor中update方法的实现:

// update方法实现
public int update(MappedStatement ms, Object parameter) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    // 清空缓存
    clearLocalCache();
    return doUpdate(ms, parameter);
}

在update方法中我们可以看到,每次进行insert,update,delete之后,就会进行清空缓存操作。

2.2 如何清除一级缓存或者说不使用一级缓存

其实,我们从query方法的源码中就可以找到解决方式,我们再来看下query源码:

public  List query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    ...
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
    ...
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        clearLocalCache();
      }
    }
    return list;
}

从这里,我们可以看到两种解决方式:

  1. 配置映射文件中节点的 flushCache属性,设置为true
  2. 配置mybatis-config.xml中的localCacheScopeSTATEMENT

针对第二种方式可以简单说下:
Mybatis一级缓存的范围有SESSION 和STATEMENT两种,默认是SESSION。我们配置为STATEMENT,这样每次执行完一个对应的Mapper方法后,就会将缓存清空:


2.3 适用场景
  1. 单从一级缓存来看,它只是对HashMap的操作,并且没有容量的大小限制,所以存在HashMap占用内存太大,导致内存溢出的可能;但一般情况下,每个SqlSession的生命周期很短,并且只要执行相应的update方法,缓存就会被清空,当然我们也可以手动清空缓存,所以正常情况下一般不会出现缓存过大,内存溢出的情况;
  1. 所以我们在使用一级缓存的时候还是要注意下:对于时效性很高的数据,我们要控制好SqlSession的生存时间,SqlSession的生存时间越长,它其中缓存的数据有可能就越旧,从而造成和真实数据库的误差。对于这种情况,我们可以适时的手动清空缓存;对于特定的查询,我们也可以配置flushCache属性,对该条SQL语句不适用一级缓存;
2.4 一级缓存总结

到这里,一级缓存的学习就差不多结束了,我们来总结下,然后开始学习二级缓存。

  1. Mybatis的一级缓存是SqlSession级别的,而缓存的维护则是通过Executor来实现的,当一次会话结束(比如调用了close方法)后,相应的一级缓存也会被清除;
  2. 对于一级缓存中的数据,由于引用的关系,如果外部修改了这个结果,那缓存中的值也会跟着发生变化,注意下这种情况;
  3. 如果不想使用一级缓存,可以配置映射文件中节点的flushCache属性为true或者配置全局文件的localCacheScopeSTATEMENT

3、二级缓存

我们现在来开始一下二级缓存。二级缓存是Application级别的缓存,默认是开启的,我们可以通过配置cacheEnabled参数来关闭二级缓存:


Mybatis中的二级缓存适用的是Executor接口的另一个实现类:CachingExecutor。前文已经学习过如何获取CachingExecutor,现在再来简单看一下:

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);
    }
    // 配置cacheEnabled
    if (cacheEnabled) {
      executor = new CachingExecutor(executor);
    }
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
  }

在Configuration的newExecutor方法中,我们通过cacheEnabled参数来判断是否开启了二级缓存,如果开启的话,Mybatis就将通过CachingExecutor来完成操作,而CachingExecutor通过适用装饰者模式,在内部包装了一个Executor的实例来进行实际的操作:

// 包装的实际执行器
private Executor delegate;
// 事务缓存数据
private TransactionalCacheManager tcm = new TransactionalCacheManager();

而对于实际用于缓存数据的 TransactionalCacheManager 类,其实底层也是通过HashMap来实现的。其中map的key是每个节点的Cache对象,value是TransactionalCache对象,感兴趣的童鞋可以看下该类:

public class TransactionalCacheManager {
  private Map transactionalCaches = new HashMap();
}
public class TransactionalCache implements Cache {
  private static final Log log = LogFactory.getLog(TransactionalCache.class);
    
  // 包装的缓存对象
  private Cache delegate;
  private boolean clearOnCommit;
  private Map entriesToAddOnCommit;
  private Set entriesMissedInCache;
}
 
 
3.1 二级缓存配置及条件

对整个Application而言,Mybatis二级缓存并不是只有一份,对每个Mapper文件都会有一个节点,只要配置了这个节点的话,那这个mapper文件就会对应一个Cache对象。

当然,我们也可以对多个Mapper文件公用一个Cache,需要配置一下节点,指定它的namespace属性;



当然,如果我们同时配置了cache和cache-ref节点的话,那么Mybatis中cache节点的优先级是高于cache-ref的,所以Mybatis会选择cache节点。

  1. Mybatis的二级缓存的粒度很细,它可以指定某一条查询语句是否可以使用二级缓存。
  2. 虽然在Mapper中配置了,并且为此Mapper分配了Cache对象,这并不表示这个Mapper中的任一条sql语句查到的结果都会放置到Cache对象之中,只有指定了`useCache="true"的 SELECT * FROM Student

    也就是说,如果要使某个