1. 程式人生 > >MyBatis框架原理3:快取

MyBatis框架原理3:快取

上一篇[MyBatis框架原理2:SqlSession執行過程][1]介紹了MyBatis的工作流程,其中涉及到了MyBatis快取的使用,首先回顧一下工作流程圖:

如果開啟了二級快取,資料查詢執行過程就是首先從二級快取中查詢,如果未命中則從一級快取中查詢,如果也未命中則從資料庫中查詢。MyBatis的一級和二級快取都是基於Cache介面的實現,下面先來看看Cache介面和其各種實現類。

Cache介面及常用裝飾器

public interface Cache {
  String getId();
  //快取中新增資料,key為生成的CacheKey,value為查詢結果
  void putObject(Object key, Object value);
  //查詢
  Object getObject(Object key);
  //刪除
  Object removeObject(Object key);
  //清空快取
  void clear();
  //獲取快取數量
  int getSize();
  //獲取讀寫鎖
  ReadWriteLock getReadWriteLock();
}

Cache介面位於MyBatis的cache包下,定義了快取的基本方法,其實現類採用了裝飾器模式,通過實現類的組裝,可以實現操控快取的功能。cache包結構如下:

  • PerpetualCache是Cache介面的實現類,通過內部的HashMap來對快取進行基本的操作,通常配合裝飾器類一起使用。
  • BlockingCache裝飾器:保證只有一個執行緒到資料庫中查詢指定key的資料,如果該執行緒在BlockingCache中未查詢到資料,就獲取key對應的鎖,阻塞其他查詢這個key的執行緒,通過其內部ConcurrentHashMap來實現,原始碼如下:
public class BlockingCache implements Cache {

  //阻塞時長
  private long timeout;
  private final Cache delegate;
  //key和ReentrantLock物件一一對應
  private final ConcurrentHashMap<Object, ReentrantLock> locks;

  @Override
  public Object getObject(Object key) {
    //獲取key的鎖
    acquireLock(key);
    //根據key查詢
    Object value = delegate.getObject(key);
    //如果命中快取,釋放鎖,未命中則繼續持有鎖
    if (value != null) {
      releaseLock(key);
    }        
    return value;
  }
 @Override
  //從資料庫獲取結果後,將結果放入BlockingCache,然後釋放鎖
  public void putObject(Object key, Object value) {
    try {
      delegate.putObject(key, value);
    } finally {
      releaseLock(key);
    }
  }
...
  • FifoCache裝飾器: 先入先出規則刪除最早的快取,通過其內部的Deque實現。
  • LruCache裝飾器: 刪除最近使用最少的快取, 通過內部的LinkedHashMap實現。
  • SynchronizedCache裝飾器:同步Cache。
  • LoggingCache裝飾器: 提供日誌功能,記錄和輸出快取命中率。
  • SerializedCache裝飾器:序列化功能。

CacheKey

CacheKey物件是用來確認快取項的唯一標識,由其內部ArrayList新增的所有物件來確認兩個CacheKey是否相同,通常ArrayList內將新增MappedStatement的id,SQL語句,使用者傳遞給SQL語句的引數以及查詢結果集範圍RowBounds等,CacheKey原始碼如下:

public class CacheKey implements Cloneable, Serializable {
...
  private final int multiplier;
  private int hashcode;
  private long checksum;
  private int count;
  private List<Object> updateList;

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

  //向updateLis中新增物件
  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);
  }


  @Override
  //重寫equals方法判斷CacheKey是否相同
  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;
    }
    //比較updateList中每一項
    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;
  }
}

一級快取

一級快取是session級別快取,只存在當前會話中,在沒有任何配置下,MyBatis預設開啟一級快取,當一個SqlSession第一次執行SQL語句和引數查詢時,將生成的CacheKey和查詢結果放入快取中,下一次通過相同的SQL語句和引數查詢時,就會從快取中獲取,當進行更新或者插入操作時,一級快取會進行清空。在上一篇中說到,MayBatis進行一級快取查詢和寫入是由BaseExecutor執行的,原始碼如下:

  • 初始化快取: 一級快取是Cache介面的PerpetualCache實現類物件
public abstract class BaseExecutor implements Executor {

  ...
  protected PerpetualCache localCache;
  protected PerpetualCache localOutputParameterCache;
  protected Configuration configuration;

  protected int queryStack;
  private boolean closed;

  protected BaseExecutor(Configuration configuration, Transaction transaction) {
    this.transaction = transaction;
    this.deferredLoads = new ConcurrentLinkedQueue<DeferredLoad>();
    //一級快取初始化
    this.localCache = new PerpetualCache("LocalCache");
    this.localOutputParameterCache = new PerpetualCache("LocalOutputParameterCache");
    this.closed = false;
    this.configuration = configuration;
    this.wrapper = this;
  }
...
  • 生成CacheKey key在CachingExecutor中生成,CacheKey的updateList中放入了MappedStatement,傳入SQL的引數,結果集範圍rowBounds和boundSql:
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);
  }
  • 將查詢結果和CacheKey放入快取:
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    List<E> list;
    //快取中放入CacheKey和佔位符
    localCache.putObject(key, EXECUTION_PLACEHOLDER);
    try {
      //在資料庫中查詢操作
      list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
      localCache.removeObject(key);
    }
    //快取中放入CacheKey和結果集
    localCache.putObject(key, list);
    if (ms.getStatementType() == StatementType.CALLABLE) {
      localOutputParameterCache.putObject(key, parameter);
    }
    //返回結果
    return list;
  }
  • 再次執行相同查詢條件時從快取獲取結果:
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) {
      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
        clearLocalCache();
      }
    }
    return list;
  }
  • 更新操作時清空快取:
 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);
  }

通過以下程式碼驗證下,分別開兩個session進行相同的查詢,第一個session查詢兩次:

public void testSelect() {
        SqlSession sqlSession = sqlSessionFactory.openSession();
        User user = sqlSession.selectOne("findUserById", 1);
        System.out.println(user);
        User user2 = sqlSession.selectOne("findUserById", 1);
        System.out.println(user2);
        sqlSession.close();
        System.out.println("sqlSession closed!===================================");
        //新建會話
        SqlSession sqlSession2 = sqlSessionFactory.openSession();
        User user3 = sqlSession2.selectOne("findUserById", 1);
        System.out.println(user3);
        sqlSession2.close();
    }

把日誌設定為DEBUG級別得到執行日誌:

DEBUG [main] - ==>  Preparing: SELECT * FROM user WHERE id = ? 
DEBUG [main] - ==> Parameters: 1(Integer)
DEBUG [main] - <==      Total: 1
User [id=1, username=小明, birthday=null, sex=1, address=四川成都]
User [id=1, username=小明, birthday=null, sex=1, address=四川成都]
DEBUG [main] - Resetting autocommit to true on JDBC Connection [[email protected]]
DEBUG [main] - Closing JDBC Connection [[email protected]]
DEBUG [main] - Returned connection 369241501 to pool.
sqlSession closed!===================================
DEBUG [main] - Opening JDBC Connection
DEBUG [main] - Checked out connection 369241501 from pool.
DEBUG [main] - Setting autocommit to false on JDBC Connection [[email protected]]
DEBUG [main] - ==>  Preparing: SELECT * FROM user WHERE id = ? 
DEBUG [main] - ==> Parameters: 1(Integer)
DEBUG [main] - <==      Total: 1
User [id=1, username=小明, birthday=null, sex=1, address=四川成都]
DEBUG [main] - Resetting autocommit to true on JDBC Connection [[email protected]]
DEBUG [main] - Closing JDBC Connection [[email protected]]
DEBUG [main] - Returned connection 369241501 to pool.

第一次會話中,雖然查詢了兩次id為1的使用者,但是隻執行了一次SQL,關閉會話後開啟一次新的會話,再次查詢id為1的使用者,SQL再次執行,說明了一級快取只存在SqlSession中,不同SqlSession不能共享。

二級快取

二級快取是Mapper級別快取,也就是同一Mapper下不同的session共享二級快取區域。只需要在XML對映檔案中增加

  • blocking : 是否使用阻塞快取
  • readOnly : 是否只讀
  • eviction: 快取策略,可指定Cache介面下裝飾器類FifoCache、LruCache、SoftCache和WeakCache
  • flushInterval : 自動重新整理快取時間
  • size : 設定快取個數
  • type : 設定快取型別,用於自定義快取類,預設為PerpetualCache

二級快取是在MyBatis的解析配置檔案時初始化,在XMLMapperBuilder中將快取配置解析:

private void cacheElement(XNode context) throws Exception {
    if (context != null) {
      //指定預設型別為PerpetualCache
      String type = context.getStringAttribute("type", "PERPETUAL");
      Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
      //預設快取策略為LruCache
      String eviction = context.getStringAttribute("eviction", "LRU");
      Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
      Long flushInterval = context.getLongAttribute("flushInterval");
      Integer size = context.getIntAttribute("size");
      boolean readWrite = !context.getBooleanAttribute("readOnly", false);
      boolean blocking = context.getBooleanAttribute("blocking", false);
      Properties props = context.getChildrenAsProperties();
      //委託builderAssistant構建二級快取
      builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
    }
  }

構建過程:

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)
        //設定快取型別,預設為PerpetualCache
        .implementation(valueOrDefault(typeClass, PerpetualCache.class))
        //設定快取策略,預設使用LruCache裝飾器
        .addDecorator(valueOrDefault(evictionClass, LruCache.class))
        //設定重新整理時間
        .clearInterval(flushInterval)
        //設定大小
        .size(size)
        //設定是否只讀
        .readWrite(readWrite)
        .blocking(blocking)
        .properties(props)
        .build();
    configuration.addCache(cache);
    currentCache = cache;
    return cache;
  }

最終得到預設的二級快取物件結構為:

CachingExecutor將初始化的Cache物件用TransactionalCache包裝後放入TransactionalCacheManager的Map中,下面程式碼中的tmc就是TransactionalCacheManager物件,CachingExecutor執行二級快取操作過程:

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    //從Configuration的MappedStatement中獲取二級快取
    Cache cache = ms.getCache();
    if (cache != null) {
     //判斷是否需要重新整理快取,SELECT不重新整理,INSERT|UPDATE|DELETE重新整理快取
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, boundSql);
        @SuppressWarnings("unchecked")
        //從二級快取中獲取資料
        List<E> list = (List<E>) tcm.getObject(cache, key);
        if (list == null) {
          //委託BaseExecutor查詢
          list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          //查詢結果放入二級快取
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        return list;
      }
    }
    return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

通過之前一級快取的例子驗證二級快取,只需要在UserMapper對映檔案中加入

DEBUG [main] - ==>  Preparing: SELECT * FROM user WHERE id = ? 
DEBUG [main] - ==> Parameters: 1(Integer)
DEBUG [main] - <==      Total: 1
User [id=1, username=小明, birthday=null, sex=1, address=四川成都]
DEBUG [main] - Cache Hit Ratio [com.kkb.mybatis.mapper.UserMapper]: 0.0
User [id=1, username=小明, birthday=null, sex=1, address=四川成都]
DEBUG [main] - Resetting autocommit to true on JDBC Connection [[email protected]]
DEBUG [main] - Closing JDBC Connection [[email protected]]
DEBUG [main] - Returned connection 1543974463 to pool.
sqlSession closed!===================================
DEBUG [main] - Cache Hit Ratio [com.kkb.mybatis.mapper.UserMapper]: 0.3333333333333333
User [id=1, username=小明, birthday=null, sex=1, address=四川成都]

不同session查詢同一條記錄時,總共只執行了一次SQL語句,這時候不同session已經共享了二級快取區域。