MyBatis是纸老虎吗?(七)

在上篇文章中,我们对照手动编写jdbc的开发流程,对MyBatis进行了梳理。通过这次梳理我们发现了一些之前文章中从未见过的新知识,譬如BoundSql等。本节我想继续MyBatis这个主题,并探索一下MyBatis中的缓存机制。在正式开始梳理前,个人觉得弄清楚缓存是什么,为什么要有缓存是很重要的。那何为缓存?相信配置过计算机的诸位都知道,CPU中有一个名为缓存的部件,它的作用就是缓解高速运转CPU和低速运转磁盘之间数据处理速度不协调的问题。由此,我们可以给缓存一个这样的定义:所谓缓存就是具有缓冲作用的事物,这里的缓冲,即缓解,也就是让矛盾双方之间的矛盾弱化,甚至消失。譬如:在政治和军事上起到缓冲作用的朝鲜和韩国,在现代汽车中起到缓冲作用的安全气囊,在计算机上起到缓冲作用的内存以及CPS一二级缓存等等。如果这个定义可用,那为什么要用缓存这个问题就迎刃而解了:为了缓解矛盾双方的矛盾。按照这个思路去理解MyBatis中的缓存或许就不是什么难事了。

1 MyBatis缓存

在java知识体系中,MyBatis是一个非常常见的数据库访问组件,其可以帮开发者减少开发jdbc代码的烦恼,同时提供一些高效率的数据转换组件以减少开发者编写数据转换代码的痛苦。不过MyBatis不仅仅有这些好处,它还提供了更多实用的功能,譬如本篇文章要梳理的缓存。在MyBatis中引入缓存的主要目的就是缓解矛盾双方之间的矛盾,这里矛盾的双方,从微观角度看是程序和数据库,从宏观角度看是用户和互联网企业。如果刨除其他噪音,我们会发现用户和互联网企业之间的矛盾是程序和数据库之间的矛盾的延申,因为互联网公司是通过对外提供的APP或应用来为用户服务的,说白了用户购买的是互联网公司的服务。如果用户体验不好,那互联网公司就无法长久立足市场!不会吧,一个MyBatis缓存就能引发这么大的变故?这个暂且不说,根据上面的逻辑,MyBatis缓存是为了减少数据库压力,提高数据库性能而出现的。那MyBatis是如何实现这个缓存功能的呢?网络有位大神是这么讲的(原文:MyBatis缓存看这一篇就够了(一级缓存+二级缓存+缓存失效+缓存配置+工作模式+测试)):MyBatis缓存的原理是这样的,数据从数据库中查出来以后,被包装为相应对象,这个对象在使用完后不会被立即销毁,而是储存在MyBatis提供的一个缓冲区中,当再次使用时,直接从缓冲区把这个数据拿出来即可。这样直接从内存中获取数据,从而不再向数据库发送select执行命令的过程,可以减少数据库查询次数,从而提高了数据库的性能。在MyBatis中这个缓冲区(又可以被称为缓存)是通过Map集合实现的

在MyBatis中,缓存可以分为两类:一级缓存和二级缓存。其中一级缓存的作用域是同一个SqlSession,在同一个SqlSession中两次执行相同的sql语句,第一次执行完毕会将数据库查询的数据写到缓存(内存),第二次会从缓存中获取数据而不进行数据库查询,可以大大提高了数据查询效率。当一个SqlSession结束后该SqlSession中的一级缓存也就不复存在了。MyBtais默认是启动一级缓存的二级缓存是多个SqlSession共享的,其作用域是mapper的同一个namespace,不同的sqlSession两次执行相同namespace下的sql语句且向sql中传递的参数也相同时,第一次执行完毕会将数据库中查询到的数据写到缓存(内存),第二次会直接从缓存中获取,从而提高了查询效率。MyBatis默认不开启二级缓存,需要在MyBtais全局配置文件中进行setting配置开启二级缓存

2 MyBatis一级缓存

通过第一小节的概念性描述我们认识了MyBatis中的缓存,知道了在MyBatis中存在两种类别的缓存:一级缓存(一级缓存的生命周期和SqlSession是一个级别)和二级缓存(二级缓存的生命周期和Mapper是一个级别)。其中一级缓存默认是开启的(在MyBatis的配置文件中也可以添加一级缓存开启的配置,具体代码为:<setting name="localCacheScope" value="SESSION"/>),二级缓存需要配置才能开启(具体代码为:<setting name="cacheEnabled" value="true"/>)。通过代码跟踪我们也发现当同时执行两条相同的sql语句时,在第二次执行时,MyBatis会直接从缓存中提取数据。那MyBatis究竟是怎么实现一级缓存的呢?先看一下本节案例执行入口的代码,如下图所示:

其实这段代码和《MyBatis是纸老虎吗?(一)》这篇文章中的案例二基本类似,只不过最后两行代码不太一样。接下来开始执行这个案例,首先程序会在图中第一个断点处停下,之后进入DefaultSqlSession.selectList()方法即可。然后继续,进入DefaultSqlSession中接收四个参数的selectList()方法中,其参数为:String、Object、 RowBounds、ResultHandler。该方法的源码如下所示:

private <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds, ResultHandler handler) {
  try {
    MappedStatement ms = configuration.getMappedStatement(statement);
    dirty |= ms.isDirtySelect();
    return executor.query(ms, wrapCollection(parameter), rowBounds, handler);
  } catch (Exception e) {
    throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
  } finally {
    ErrorContext.instance().reset();
  }
}

这段代码很简单,唯一需要关注的就是Executor.query()这行代码。由于跟踪时,executor的实际类型为CachingExecutor,所以下面要看的方法是CachingExecutor中的query()方法,这段代码的运行时状态,如下图所示:

这段逻辑中,我们重点看createCacheKey()这行代码,从调用不难发现,这个被调用的方法位于CachingExecutor类中,源码为:

public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
  return delegate.createCacheKey(ms, parameterObject, rowBounds, boundSql);
}

由于目前正在被使用的CachingExecutor对象所持有的delegate属性的实际类型为SimpleExecutor,而这个类又继承了BaseExecutor(注意这个类又实现了Executor接口)抽象类。由于SimpleExecutor类中只有doUpdate()、doQuery()、doQueryCursor()、doFlushStatements()及prepareStatement()这几个方法,所以上述源码中的createCacheKey()方法位于BaseExecutor类中,这个方法的源码如下所示:

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());
  cacheKey.update(rowBounds.getOffset());
  cacheKey.update(rowBounds.getLimit());
  cacheKey.update(boundSql.getSql());
  List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
  TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
  // mimic DefaultParameterHandler logic
  MetaObject metaObject = null;
  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 {
        if (metaObject == null) {
          metaObject = configuration.newMetaObject(parameterObject);
        }
        value = metaObject.getValue(propertyName);
      }
      cacheKey.update(value);
    }
  }
  if (configuration.getEnvironment() != null) {
    // issue #176
    cacheKey.update(configuration.getEnvironment().getId());
  }
  return cacheKey;
}

这段代码的处理逻辑非常清晰,判断当前对象上的close属性是否为true,如果是就抛出异常,否则就创建CacheKey对象,并调用该对象上的update()方法。为了更加深入的了解这段代码的处理逻辑,我们来看一下CacheKey类的源码,如下所示:

public class CacheKey implements Cloneable, Serializable {

  private static final long serialVersionUID = 1146682552656046210L;

  public static final CacheKey NULL_CACHE_KEY = new CacheKey() {

    private static final long serialVersionUID = 1L;

    @Override
    public void update(Object object) {
      throw new CacheException("Not allowed to update a null cache key instance.");
    }

    @Override
    public void updateAll(Object[] objects) {
      throw new CacheException("Not allowed to update a null cache key instance.");
    }
  };

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

  private final int multiplier;
  private int hashcode;
  private long checksum;
  private int count;
  // 8/21/2017 - Sonarlint flags this as needing to be marked transient. While true if content is not serializable, this
  // is not always true and thus should not be marked transient.
  private List<Object> updateList;

  public CacheKey() {
    this.hashcode = DEFAULT_HASHCODE;
    this.multiplier = DEFAULT_MULTIPLIER;
    this.count = 0;
    this.updateList = new ArrayList<>();
  }

  public CacheKey(Object[] objects) {
    this();
    updateAll(objects);
  }

  public int getUpdateCount() {
    return updateList.size();
  }

  public void update(Object object) {
    int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);

    count++;
    checksum += baseHashCode;
    baseHashCode *= count;

    hashcode = multiplier * hashcode + baseHashCode;

    updateList.add(object);
  }

  public void updateAll(Object[] objects) {
    for (Object o : objects) {
      update(o);
    }
  }

  @Override
  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) || (checksum != cacheKey.checksum) || (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 (!ArrayUtil.equals(thisObject, thatObject)) {
        return false;
      }
    }
    return true;
  }

  @Override
  public int hashCode() {
    return hashcode;
  }

  @Override
  public String toString() {
    StringJoiner returnValue = new StringJoiner(":");
    returnValue.add(String.valueOf(hashcode));
    returnValue.add(String.valueOf(checksum));
    updateList.stream().map(ArrayUtil::toString).forEach(returnValue::add);
    return returnValue.toString();
  }

  @Override
  public CacheKey clone() throws CloneNotSupportedException {
    CacheKey clonedCacheKey = (CacheKey) super.clone();
    clonedCacheKey.updateList = new ArrayList<>(updateList);
    return clonedCacheKey;
  }

}

从源码可以看出这个类非常简单,拥有五个属性,它们分别为:int类型的multiplier,int类型的hashcode,long类型的checksum,int类型的count以及List<Object>类型的updateList。这个类拥有两个构造方法,一个无参构造方法,一个接收Object[]数组的有参构造方法。其中无参构造方法主要是给这个类的其中四个属性赋值,它们分别是:updateList(默认数据是空的ArrayList对象)、hashcode(默认值是17)、multiplier(默认值是37)、count(默认值是0)。有参构造方法会首先调用无参构造方法,然后再调用updatAll()方法。这个updateAll()方法的处理逻辑非常简单,就是遍历数据,然后分别调用update()方法。这个update()方法的处理逻辑很简单,就是计算传递进来的Object对象的hash值,然后做一些列运算,得出一个数据赋值给CacheKey的hashcode属性,最后传进本方法的object数据存到updateList数组对象中。这个类还有一个getUpdateCount(),其作用就是返回updateList数组中的数据量。还有这个类重写了Object类中的equals()方法和toString()方法,我们知道equals()方法是两个对象比较时常用的方法,所以重写这个方法的目的是重写这两个对象的比对逻辑,而重写toString()方法是为了以约定的方式向调用者展示自身数据。由于这个类实现了Cloneable接口,所以这个类又重写了Cloneable中的clone()方法。总体来说,CacheKey类是MyBatis框架提供的一个缓存key,目的是在我们缓存sql查询结果时使用。现在再回到BaseExecutor类的createCacheKey()方法中,此时再看这一系列的cacheKey.update()方法,我们似乎不再那么茫然了,这一系列操作的目的不过就是让CacheKey的hashcode值唯一。参与hashcode值计算的数据有:sql语句的id值、查询语句的分页偏移量、查询语句的分页条数、BoundSql中的sql属性、parameterMapping(如果有的话)、environmentId(如果有的话)等。待CacheKey创建完成后,持续向上返回,直到返回到CachingExecutor中的query()方法为止。此时可以看到该方法的最后一行代码,如下图所示:

可以发现这段代码调用了CachingExecutor类中接收六个参数的query()方法,这些参数分别为:MappedStatement、Object、RowBounds、ResultHandler、CacheKey、BoundSql,具体如下图所示:

这个方法在整个系列中被看了很多次,为了完整理解,这里再贴一下这个方法的源码,如下所示:

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler,
    CacheKey key, BoundSql boundSql) throws SQLException {
  Cache cache = ms.getCache();
  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) {
        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);
}

从源码可以看出,这段代码会首先从MappedStatement中拿一下Cache,如果缓存不为空则走if分支,否则继续调用SimpleExecutor类中的query()方法。我们跟踪的案例没有走if分支,所以这里直接看SimpleExecutor中的query()方法(从BaseExecutor类继承来的方法)的执行状态如下图所示:

先看图中list = resultHandler == null ? (List<E>) localCache.getObject(key) : null这段代码,这里会先判断resultHandler是否null,如果时,则直接执行localCache.getObject(key)这段代码,否则直接返回null。由于调用该方法时传递进来的ResultHandler对象为null,所以这里直接执行localCache.getObject(key)方法,最终返回了null,即承载数据库查询结果的List<T>对象的结果为null,接下了会直接执行BaseExecutor类中的queryFromDatabase()方法,执行情况如下图所示:

由图可以看出,这个方法首先会向localCache对象中放一个key(前面执行CachingExecutor中的query()方法时创建的CacheKey对象),其对应的数据为EXECUTION_PLACEHOLDER枚举。接下来该方法会直接调用BaseExecutor子类实现的doQuery()方法,这里的子类是SimpleExecutor类。紧接着finally块中会把localCache对象中的CacheKey移除。接着再向localCache对象中的存放CacheKey及其对应的数据doQuery()方法返回的结果。然后向上返回doQuery()方法的查询结果。直至最初调用者,即SpringTransactionApplication类的mybatis()方法中。

紧接着程序会继续调用同样的查询语句进行查询。下面用一章图片展示一下程序的运行时状态,具体如下图所示:

由于前面已经梳理过执行流程,所以这次会跳过非关键环节,直接看BaseExecutor类的query()方法的执行状态,如下图所示:

最终会执行list = resultHandler == null ? (List<E>) localCache.getObject(key) : null,跟前面执行逻辑一样。由于resultHandler是null,这里会直接执行localCache.getObject(key)这个逻辑,由于这里localCache中存在数据,具体如下图所示:

根据上面这张图片可以直到最终list对象不为空,会有二十五条数据,具体可以看下面这幅图片:

最终代码走到了handleLocallyCacheOutputParameters()方法中,这个方法位于BaseExecutor类中,这个方法的源码如下所示:

private void handleLocallyCachedOutputParameters(MappedStatement ms, CacheKey key, Object parameter,
    BoundSql boundSql) {
  if (ms.getStatementType() == StatementType.CALLABLE) {
    final Object cachedParameter = localOutputParameterCache.getObject(key);
    if (cachedParameter != null && parameter != null) {
      final MetaObject metaCachedParameter = configuration.newMetaObject(cachedParameter);
      final MetaObject metaParameter = configuration.newMetaObject(parameter);
      for (ParameterMapping parameterMapping : boundSql.getParameterMappings()) {
        if (parameterMapping.getMode() != ParameterMode.IN) {
          final String parameterName = parameterMapping.getProperty();
          final Object cachedValue = metaCachedParameter.getValue(parameterName);
          metaParameter.setValue(parameterName, cachedValue);
        }
      }
    }
  }
}

通过上面的梳理,我们可以看到MyBatis的一级缓存对象在Executor的实现类BaseExecutor中,其名字为localCache,该变量的类型为:PerpetualCache,并且查询缓存的时间点就在将要去查询数据库的时候,从代码层面看,这个逻辑位于BaseExecutor类的queryFromDatabase()方法中。下面让我们一起看一下PerpetualCache的源码吧:

public class PerpetualCache implements Cache {

  private final String id;
  private final Map<Object, Object> cache = new HashMap<>();

  public PerpetualCache(String id) {
    this.id = id;
  }

  @Override
  public String getId() {
    return id;
  }
  @Override
  public int getSize() {
    return cache.size();
  }
  @Override
  public void putObject(Object key, Object value) {
    cache.put(key, value);
  }
  @Override
  public Object getObject(Object key) {
    return cache.get(key);
  }
  @Override
  public Object removeObject(Object key) {
    return cache.remove(key);
  }
  @Override
  public void clear() {
    cache.clear();
  }
  @Override
  public boolean equals(Object o) {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    if (this == o) {
      return true;
    }
    if (!(o instanceof Cache)) {
      return false;
    }

    Cache otherCache = (Cache) o;
    return getId().equals(otherCache.getId());
  }
  @Override
  public int hashCode() {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    return getId().hashCode();
  }

}

public interface Cache {

  /**
   * @return The identifier of this cache
   */
  String getId();
  /**
   * @param key
   *          Can be any object but usually it is a {@link CacheKey}
   * @param value
   *          The result of a select.
   */
  void putObject(Object key, Object value);
  /**
   * @param key
   *          The key
   *
   * @return The object stored in the cache.
   */
  Object getObject(Object key);
  /**
   * As of 3.3.0 this method is only called during a rollback for any previous value that was missing in the cache. This
   * lets any blocking cache to release the lock that may have previously put on the key. A blocking cache puts a lock
   * when a value is null and releases it when the value is back again. This way other threads will wait for the value
   * to be available instead of hitting the database.
   *
   * @param key
   *          The key
   *
   * @return Not used
   */
  Object removeObject(Object key);
  /**
   * Clears this cache instance.
   */
  void clear();
  /**
   * Optional. This method is not called by the core.
   *
   * @return The number of elements stored in the cache (not its capacity).
   */
  int getSize();
  /**
   * Optional. As of 3.2.6 this method is no longer called by the core.
   * <p>
   * Any locking needed by the cache must be provided internally by the cache provider.
   *
   * @return A ReadWriteLock
   */
  default ReadWriteLock getReadWriteLock() {
    return null;
  }

}

从代码可以看出PerpetualCache类实现了Cache接口。Cache接口提供了一系列方法,这些方法有:

  1. String getId():获取当前Cache对象的id值
  2. void putObject(Object key, Object value) :向当前Cache对象中存放指定key,及其对应的数据
  3. Object getObject(Object key) :根据key,从当前Cache对象中提取数据
  4. Object removeObject(Object key) :从当前Cache对象中移除指定key的数据
  5. void clear():清空当前Cache对象中的数据
  6. int getSize():查询当前Cache对象中的数据总量

由于PerpetualCache实现了Cache接口,因此会对这些接口方法进行实现。同时因为PerpetualCache是通过Map<String, Object>类型的属性cache来缓存从数据库中查询出来的数据集合,所以这些实现方法的操作逻辑,本质上就是对Map的操作。梳理到这里我们还是一起回顾一下Executor的继承结构(注意Executor的实现类BaseExecutor中有一个PerpetualCache类型的localCache属性用于缓存数据),具体如下图所示:

3 MyBatis二级缓存

在前一小节开头我们提到了在MyBatis中启用二级缓存的方法是在MyBatis配置文件新增一句<setting name="cacheEnabled" value="true"/>代码即可。不过在后来的代码跟踪中,我发现这并没有达到预期效果。正当我一筹莫展之时,“在MyBatis的映射XML中配置cache或者cache-ref”这几个字引起了我的注意。我的天,怪不得当年阅读理解总是不及格。因此在项目中启用二级缓存,并非前一小节讲的那样,具体步骤为:

  1. 在MyBatis的配置文件中开启二级缓存,具体代码为:<setting name="cacheEnabled" value="true"/>
  2. 在MyBatis的xml映射文件中配置cache或者cache-ref,具体代码为:<cache/>或者<cache-ref namespace="mapper.StudentMapper"/>。注意:cache-ref代表引用别的命名空间的cache配置,两个命名空间的操作使用的是同一个Cache。另外cache标签还可以配置这样几个属性:type-cache使用的类型,默认是PerpetualCache,这在一级缓存中提到过;eviction-定义回收的策略,常见的有FIFO,LRU;flushInterval-配置一定时间自动刷新缓存,单位是毫秒;size-最多缓存对象的个数;readOnly-是否只读,若配置可读写,则需要对应的实体类能够序列化;blocking:若缓存中找不到对应的key,是否会一直blocking,直到有对应的数据进入缓存

下面就按照上面所说的步骤进行配置并跟踪一下代码,并据此梳理一下MyBatis二级缓存的实现过程。虽然本小节还是按照上一小节的案例跟踪,但为了展示完成的跟踪过程,这里还是会截下重要的执行步骤。启动程序后会在下图所示代码处停下

接着会进入到DefaultSqlSession的selectList(String statement, Object parameter)方法中,继续跟进会进入到selectList(String statement, Object parameter, RowBounds rowBounds, ResultHandler handler)方法中,具体如下图所示:

然后继续前进,进入到Executor实现类CachingExecutor的query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler)方法中,具体如下图所示:

接着继续跟踪会进入到CachingExecutor类的query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)方法中,具体如下图所示:

从图中可以看出,正在执行的方法的第一行的处理逻辑是从MappedStatement中拿到一个Cache对象,接下来会判断这个对象是否为空,如果不为空,则执行if分支,否则继续后面的查询操作,即将查询请求委托给SimpleExecutor类型的query()方法去处理。下面展示一下这个方法的源码:

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler,
    CacheKey key, BoundSql boundSql) throws SQLException {
  // 从MappedStatement中拿到Mapper级别的缓存
  Cache cache = ms.getCache();
  // 判断从MappedStatement中拿到Cache对象是否为null,如果不为null,则直接执行if分支,否则继续后面的执行,即调用SimpleExecutor对象上的query()方法
  if (cache != null) {
    // 判断是否需要刷新缓存,如果需要,则调用tcm对象上的clear()方法清空cache对象
    flushCacheIfRequired(ms);
    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;
    }
  }
  return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

上面我们梳理了query()方法的执行框架,不过在cache不为空情况下if分支的执行逻辑又是怎样的呢?首先看一下flushCacheIfRequired(MappedStatement对象)这行代码的处理逻辑,其源码如下所示:

private void flushCacheIfRequired(MappedStatement ms) {
  Cache cache = ms.getCache();
  if (cache != null && ms.isFlushCacheRequired()) {
    tcm.clear(cache);
  }
}

这段代码的处理逻辑非常清晰:1.从MappedStatement对象中取出Cache对象;2.如果Cache对象为空,同时MappedStatement对象上的flushCacheRequired属性为true,则执行tcm对象上的clear()方法,否则不执行任何处理。这里tcm的类型为TransactionalCacheManager。先来看一下这个类的源码:

public class TransactionalCacheManager {
  private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
  public void clear(Cache cache) {
    getTransactionalCache(cache).clear();
  }
  public Object getObject(Cache cache, CacheKey key) {
    return getTransactionalCache(cache).getObject(key);
  }
  public void putObject(Cache cache, CacheKey key, Object value) {
    getTransactionalCache(cache).putObject(key, value);
  }
  public void commit() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
      txCache.commit();
    }
  }
  public void rollback() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
      txCache.rollback();
    }
  }
  private TransactionalCache getTransactionalCache(Cache cache) {
    return MapUtil.computeIfAbsent(transactionalCaches, cache, TransactionalCache::new);
  }
}

由源码可知,TransactionalCacheManager类上有一个Map<Cache, TransactionalCache>类型的transactionalCaches属性。这个属性的key是Cache,也就是Mapper级别的缓存对象,value则是一个TransactionalCache对象。也就是说这个属性存储了Cache和用TransactionalCache包装后的Cache的映射关系。这里提到的TransactionalCache实现了Cache接口,CachingExecutor会默认使用它包装初始生成的Cache,作用是如果事务提交,对缓存的操作才会生效,如果事务回滚或者不提交事务,则不对缓存产生影响。在TransactionalCache的clear()方法中有以下两句。清空了需要在提交时加入缓存的列表,同时设定提交时清空缓存,代码如下所示:

public void clear() {
  clearOnCommit = true;
  entriesToAddOnCommit.clear();
}

接着再看一下ensureNoOutParams(ms, boundSql)这句代码,其用来处理存储过程。然后从tcm对象中获取缓存列表,具体代码为:List<E> list = (List<E>) tcm.getObject(cache, key)在getObject()方法中,会把获取值的职责一路传递,最终到PerpetualCache。如果没有查到,会把key加入Miss集合,这个主要是为了统计命中率(即entriesMissedInCache.add(key))。具体代码为:

public Object getObject(Cache cache, CacheKey key) {
  return getTransactionalCache(cache).getObject(key);
}

private TransactionalCache getTransactionalCache(Cache cache) {
  return MapUtil.computeIfAbsent(transactionalCaches, cache, TransactionalCache::new);
}

public Object getObject(Object key) {
  // issue #116
  Object object = delegate.getObject(key);
  if (object == null) {
    entriesMissedInCache.add(key);
  }
  // issue #146
  if (clearOnCommit) {
    return null;
  }
  return object;
}

然后再看一下if (list == null) {……}这个if分支处理逻辑,CachingExecutor继续往下走,如果查询到数据,则调用tcm.putObject方法,往缓存中放入值。这里需要注意:tcm的put方法并非直接操作缓存,只是把这次的数据和key放入待提交的Map中,即将数据放到entriesToAddOnCommit属性所表示的map中。下面看一下这个put()方法的源码:

public void putObject(Cache cache, CacheKey key, Object value) {
  getTransactionalCache(cache).putObject(key, value);
}
private TransactionalCache getTransactionalCache(Cache cache) {
  return MapUtil.computeIfAbsent(transactionalCaches, cache, TransactionalCache::new);
} 
public void putObject(Object key, Object object) {
  entriesToAddOnCommit.put(key, object);
}

不知道你们糊涂不糊涂,反正我是糊涂了。下面请允许我花点时间对这段代码的处理流程做个梳理:

  1. 从代码执行入口开始,然后向下跟踪。这个入口点为:SpringTransactionApplication中的session1.selectList("sql字符串", 查询条件对象)
  2. 进入到SqlSession子类DefaultSqlSession中的selectList(String, Object)方法,之后继续调用同类中同名的selectList(String, Object, RowBounds)方法,接着继续调用本类中同名的selectList()方法,这个方法接收了四个参数:String、Object、RowBounds、ResultHandler。紧接着这个方法就开始了正式的处理:从Configuration中拿到MappedStatement对象,调用MappedStatement对象上的isDirtySelect()方法,然后调用CachingExecutor类的query()方法
  3. 这里来到CachingExecutor类中的query(MappedStatement, Object, RowBounds, ResultHandler)方法,注意这个方法中会调用MappedStatment对象上的getBoundSql()方法,然后利用第二个参数Object创建一个BoundSql对象,紧接着会创建一个CacheKey对象。最后调用本类中与本方法同名的query(MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BounSql)方法。这个方法中就涉及到了二级缓存的操作,这个操作的具体过程可以参照前面的梳理。假设这里有二级缓存,那么就会进入到if分支,否则就会直接执行后面代码的逻辑,这个过程涉及到了一级缓存,其中的执行过程和知识点可以参照前一小节。

下面就第三步中的重点执行逻辑进行详细梳理。其实也没有什么复杂的,就是从缓存中拿数据,如果拿到,就直接返回上级调用者,否则就执行查询,并将数据存储到二级缓存中。具体代码如下所示:

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
}

这里的tcm实际类型就是TransactionalCacheManager,这是一个普通类,里面有一个名为transactionalCaches且类型为Map<Cache, TransactionalCache>的属性,这个属性用于存放Cache和TransacationCache之间的对应关系。从代码中很明显可以看出,我们传递给TransactionCacheManager对象的getObject()和putObject()方法的参数为Cache,然后这两个方法内部都会先调用getTransactionalCache(Cache)方法将传递进来的Cache对象转化为TransactionalCache对象(此时这个对象持有了Cache对象)。到这里我们不得不看一下Cache的继承体系,如下图所示:

从图中不难看出,Cache的实现类有很多,而我们跟踪的这段代码中的Cache对象的实际类型为SynchronizedCache,然后通过运行时图片,我们可以发现这个对象包装了多个Cache子类,它们分别为:LoggingCache、SerializedCache、LruCache、PerpetualCache。具体如下图所示:

从这张图,我们可以看出,每个Cache对象中都有一个delegate属性指向下一个Cache子对象,通过这种Cache嵌套的模式,我们可以为要使用的Cache对象赋予不同的功能。仔细看这个对象的组成,再想一下java流中的BufferedStream,我们是不是有点什么想法。这里用到了装饰器模式。通过这种装饰模式,就可以给一个Cache对象附上不同的功能,比如这个Cache:

  1. SynchronizedCache:同步Cache,实现比较简单,直接使用synchronized修饰方法
  2. LoggingCache:日志功能,装饰类,用于记录缓存的命中率,如果开启了DEBUG模式,则会输出命中率日志
  3. SerializedCache:序列化功能,将值序列化后存到缓存中。该功能用于缓存返回一份实例的Copy,用于保存线程安全
  4. LruCache:采用了Lru算法的Cache实现,移除最近最少使用的Key/Value
  5. PerpetualCache: 作为为最基础的缓存类,底层实现比较简单,直接使用了HashMap

好了,让我们继续回到TransactionalCacheManager类的getObject()方法中,这个方法通过getTransactionalCache()方法创建了一个TransactionalCache对象,然后继续调用该对象上的getObject()方法,该方法的源码前面罗列过,这里再重复一下:

public Object getObject(Object key) {
  // issue #116
  Object object = delegate.getObject(key);
  if (object == null) {
    entriesMissedInCache.add(key);
  }
  // issue #146
  if (clearOnCommit) {
    return null;
  }
  return object;
}

这个方法首先调用delegate中的getObject()方法,这里的delegate就是SynchronizedCache对象,也就是说这里是从SynchronizedCache对象中拿数据,继续跟下去会发现它会调其持有的缓存对象的getObject()方法,依次深入,最终会调到PerpetualCache中,也就是直接从PerpetualCache对象的HashMap中取数据。如果取到,会经过一系列判断(判断clearOnCommit是否为true,如果是则返回null),如果判断符合要求,就将这个数据返回,否则返回null。注意中间有个步骤前面说过,就是如果没有查到,会将这个key,即CacheKey放到entriesMissedInCache中。

上面我们看了从缓存中拿数据的过程,下面再看一下放数据的过程。与取数据的过程类似,拿数据的时候会先将传递进来的Cache对象转化为TransactionalCache对象,然后调用该对象上的putObject()方法,这个方法的源码为:

public void putObject(Object key, Object object) {
  entriesToAddOnCommit.put(key, object);
}

也就是说这个方法直接把数据放到了entriesToAddCommit对象中,这个对象就是一个普通的HashMap对象。这?取得时候是从Cache链中取,放得时候却只放在TransactionalCache对象中的entriesToAddOnCommit对象中,这是什么操作?这里先来看一下《聊聊MyBatis缓存机制》这篇文章的总结:当未调用sqlSession对象的commit()方法时,二级缓存并没有起到作用;当调用了sqlSession对象的commit()方法时,缓存的命中率是0.5。可以看下面这幅对比图:

既然是梳理,这里就多说一些:当调用update语句后,二级缓存会被刷新;二级缓存不适应用于映射文件中存在多表查询的情况,这样容器出现脏数据;为了解决多表问题,可以引入Cache ref标签来解决,这样两个映射文件对应的SQL操作都使用的是同一块缓存了,不过这样做的后果是,缓存的粒度变粗了,多个Mapper namespace下的所有操作都会对缓存使用造成影响。

先来看这篇文章梳理的前两个结论:当没有调用SqlSession上的commit()方法时,数据只会存储在TransactionalCache中的entriesToAddOnCommit属性中。当调用SqlSession上的commit()方法时,数据会从TransactionalCache中的entriesToAddOnCommit属性中转移到SynchronizedCache对象间接有的PerpetualCache对象上。也就是说commit()方法触发了数据的转移逻辑。这就要看一下SqlSession实现类的commit()方法了,具体如下所示:

// DefaultSqlSession类中的commit()方法
@Override
public void commit() {
  commit(false);
}

@Override
public void commit(boolean force) {
  try {
    executor.commit(isCommitOrRollbackRequired(force));
    dirty = false;
  } catch (Exception e) {
    throw ExceptionFactory.wrapException("Error committing transaction.  Cause: " + e, e);
  } finally {
    ErrorContext.instance().reset();
  }
}
// CachingExecutor 类上的commit()方法
@Override
public void commit(boolean required) throws SQLException {
  delegate.commit(required);
  tcm.commit();
}
// TransactionalCacheManager类上的commit()方法
public void commit() {
  for (TransactionalCache txCache : transactionalCaches.values()) {
    txCache.commit();
  }
}
// TransactionalCache中的commit()及flushPendingEntries()方法
// flushPendingEntries方法就是把TransactionalCache中entriesToAddOnCommit中的数据存放到Mapper持有的cache中
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);
    }
  }
}

这样我们就摸清楚了MyBatis中二级缓存的实现套路了。如果遇到insert|update|delete等语句,会统一进入CachingExecutor的update方法,其中调用了这个函数,代码如下所示:

private void flushCacheIfRequired(MappedStatement ms)

总之通过这一小节的梳理,我们对MyBatis的二级缓存有了具象的认识,也弄清楚了该缓存的实现思路。

4 总结

在总结前,我想对通过网络搜索到的两篇优质博文的创作者说声谢谢,这两篇博文分别是:《MyBatis缓存看这一篇就够了(一级缓存+二级缓存+缓存失效+缓存配置+工作模式+测试)》《聊聊MyBatis缓存机制》。由于他们出色得整理,我仅用了半天时间就搞懂了MyBatis缓存。下面就本篇梳理的知识点做一下总结:

首先通过梳理,我对缓存有了更加深刻的认识,并且知道了MyBatis添加缓存功能的原因:缓解矛盾双方的对立。这里对立的双方从宏观角度讲是用户和公司,从微观角度看是程序和数据库。

接着通过梳理,我对MyBatis一级缓存有了更加深刻的了解,并且通过梳理我知道了MyBatis中启用一级缓存的方式,一级缓存的实现原理及一级缓存的工作流程。在MyBatis中一级缓存的生命周期和SqlSession一致;一级缓存内部设计简单,只是一个没有容量限定的HashMap,在缓存的功能性上有所欠缺;一级缓存最大范围是SqlSession内部,如果有多个SqlSession或者在分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为Statement

然后通过梳理,我对MyBatis二级缓存有了不一样的认识:1. MyBatis的二级缓存是相对于一级缓存来说的,实现了多个SqlSession之间缓存数据的共享,同时粒度更加的细,能够到namespace级别,通过Cache接口实现类不同的组合,对Cache的可控性也更强。2. MyBatis在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用二级缓存的条件比较苛刻。3. 在分布式环境下,由于默认的MyBatis Cache实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将MyBatis的Cache接口实现,有一定的开发成本,直接使用Redis、Memcached等分布式缓存可能成本更低,安全性也更高。还有就是对于MyBatis缓存,建议在生产环境中关闭,当单纯作为一个ORM框架时使用缓存会更合适。另外MyBatis的一级、二级缓存的执行顺序为:先二级,再一级,关于MyBatis开启一二级缓存时的执行顺序可以看下面这幅图:

注意:上述总结很多都来源于《聊聊MyBatis缓存机制》这篇文章。

最后对于MyBatis二级缓存,我想用自己的理解来描述一番,毕竟梳理的目的是为了让自己明白。MyBatis的二级缓存是Mapper级别的,这里的Mapper就是我们通常见到的sql配置文件,比如:xxx.xml。因此,个人在这里马后炮一番,启用二级缓存不仅需要在MyBatis配置文件中添加配置,还要在sql配置文件中新增配置项。MyBatis为了实现这个缓存功能在sql配置文件对应MapperStatement类中新增了一个Cache属性,这个属性最终是一系列Cache实现类的组合,比如本例中的SynchronizedCache对象,其组成链条为:SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache。与一级缓存一样,二级缓存是用来存放数据的。那MyBatis是在什么时候向这个缓存中存放数据呢?在回答这个问题前我们需要熟悉一些关联知识,即:TransactionalCacheManager、TransactionalCache。其中前者是用来管理TransactionalCache的,通过其对象上的Map<Cache, TransactionalCache>属性对其进行管理。所谓管理无非就是新增、查询、删除。为此TransactionalCacheManager提供了对应的方法,比如getObject()、put Object()、commit()、clear()、rollback()等。通过这些方法名结合前面梳理时提到的一些结论,不难理解,MyBatis用事务的思路来处理缓存,也就是在事务提交的时候,将数据缓存起来,在事务回滚时,将数据回滚等。这样做的好处就是避免缓存无效的数据,以减少后续查询操作的准确性。TransactionalCache个人理解就是实际Cache前的一个中间状态,在数据真正写入Cache前,会将数据存储到TransactionalCache中,通过TransactionalCacheManager对象进行管理。如果事务正常结束,就直接将数据写到Cache对象中。这有点类似Redis的持久化,先将数据推到缓存,由系统调度然后将缓存中的数据刷到硬盘上。总之TransactionalCache会包装MapperStatement中的Cache属性。那二级缓存是不同SqlSession间的缓存这个说法又是咋回事呢?首先SqlSession及其实现类持有一个Configuration对象,这个对象包含了所有要用的数据库查询的操作(MapperStatement),这个对象在MyBatis中只有一个,所以无论创建多少个SqlSession对象,我们要用到的MapperStatement是唯一的,那其中的Cache就是唯一的,不同SqlSession对象最终操作的就是这个唯一的Cache,所以才会有二级缓存是SqlSession间的缓存的说法。总之,一级缓存是Executor级别的,二级缓存是Mapper级别的。这两者的执行顺序则是先二级,再一级。

  • 39
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

机器挖掘工

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值