mybatis原始碼學習:一級快取和二級快取分析

天喬巴夏丶發表於2020-04-25

前文傳送門:mybatis原始碼學習:從SqlSessionFactory到代理物件的生成

零、一級快取和二級快取的流程

以這裡的查詢語句為例。

一級快取總結

  • 以下兩種情況會直接在一級快取中查詢資料

    • 主配置檔案或對映檔案沒有配置二級快取開啟。
    • 二級快取中不存在資料。
  • 根據statetment,生成一個CacheKey。

  • 判斷是否需要清空本地快取。

  • 根據cachekey從localCache中獲取資料。

  • 如果快取未命中,走接下來三步並向下

    • 從資料庫查詢結果。
    • 將cachekey:資料存入localcache中。
    • 將資料返回。
  • 如果快取命中,直接從快取中獲取資料。

  • localCache的範圍如果為statement,清空一級快取。

二級快取總結

  • 判斷主配置檔案是否設定了enabledCache,預設是開啟的,建立CachingExecutor。

  • 根據statetment,生成一個CacheKey。

  • 判斷對映檔案中是否有cache標籤,如果沒有則跳過以下針對二級快取的操作,從一級快取中查,查不到就從資料庫中查。

  • 否則即開啟了二級快取,獲取cache。

  • 判斷是否需要清空二級快取。

  • 判斷該語句是否需要使用二級快取isUserCache。

  • 如果二級快取命中,則直接返回該資料。

  • 如果二級快取未命中,則將cachekey存入未命中set,然後進行一下的操作:

    • 從一級快取中查,如果命中就返回,沒有命中就從資料庫中查。
    • 將查到的資料返回,並將cachekey和資料(物件的拷貝)存入待加入二級快取的map中。
  • 最後commit和close操作都會使二級快取真正地更新。

一、快取介面Cache及其實現類

快取類的頂級介面Cache,裡面定義了加入資料到快取,從快取中獲取資料,清楚快取等操作,通常mybatis會將namespace作為id,將CacheKey作為Map中的鍵,而map中的值也就是儲存在快取中的物件。

在這裡插入圖片描述

而通過裝飾器設計模式,將Cache的功能進行加強,在它的實現類中有著明顯的體現:

在這裡插入圖片描述

PerpetualCache:是最基礎的快取類,採用HashMap實現,同時一級快取使用的localCache就是該型別。

LruCache:Lru(least recently used),採用Lru演算法可以實現移除最長時間沒有使用的key/value。

SerializedCache:提供了序列化功能,將值序列化後存入快取,用於快取返回一份例項的Copy,保證執行緒安全。

LoggingCache:提供日誌功能,如果開啟debugEnabled為true,則列印快取命中日誌。

SynchronizedCache:同步的Cache,用synchronized關鍵字修飾所有方法。

下圖可以得知其執行鏈:SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache

在這裡插入圖片描述

二、cache標籤解析原始碼

XMLMapperBuilder中的configurationElement負責解析mappers對映檔案中的標籤元素,其中有個cacheElement方法,負責解析cache標籤。

  private void cacheElement(XNode context) throws Exception {
    if (context != null) {
      //獲取type屬性,預設為perpetual
      String type = context.getStringAttribute("type", "PERPETUAL");
      //獲取type類物件
      Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
      //獲取eviction策略,預設為lru,即最近最少使用,移除最長時間不被使用的物件
      String eviction = context.getStringAttribute("eviction", "LRU");
      Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
      //獲取flushInterval重新整理間隔
      Long flushInterval = context.getLongAttribute("flushInterval");
      //獲取size引用數目
      Integer size = context.getIntAttribute("size");
      //獲取是否只讀
      boolean readWrite = !context.getBooleanAttribute("readOnly", false);
      //獲取是否blocking
      boolean blocking = context.getBooleanAttribute("blocking", false);
      //這一步是另外一種設定cache的方式,即cache子元素中用property,name,value定義
      Properties props = context.getChildrenAsProperties();
      builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
    }
  }

getStringAttribute方法,這個方法的作用就是獲取指定的屬性值,如果沒有設定的話,就採用預設的值:

  public String getStringAttribute(String name, String def) {
    //獲取name引數對應的屬性
    String value = attributes.getProperty(name);
    if (value == null) {
      //如果沒有設定,預設為def
      return def;
    } else {
      return value;
    }
  }

resolveAlias方法,從原始碼中我們就可以猜測,我們之前通過</typeAliases>起別名其實也就是將裡面的內容解析,並存入map之中,而每次處理型別的時候,都比較的是小寫的形式,這也是我們起別名之後不用關心大小寫的原因。

  // throws class cast exception as well if types cannot be assigned
  public <T> Class<T> resolveAlias(String string) {
    try {
      if (string == null) {
        return null;
      }
      //首先將傳入的引數轉換為小寫形式
      String key = string.toLowerCase(Locale.ENGLISH);
      Class<T> value;
      //到TypeAliasRegistry維護的Map,TYPE_ALIASES中找有無對應的鍵
      if (TYPE_ALIASES.containsKey(key)) {
        //找到就直接返回:class類物件
        value = (Class<T>) TYPE_ALIASES.get(key);
      } else {
        //找不到就通過反射獲取一個
        value = (Class<T>) Resources.classForName(string);
      }
      return value;
    } catch (ClassNotFoundException e) {
      throw new TypeException("Could not resolve type alias '" + string + "'.  Cause: " + e, e);
    }
  }

根據獲取的屬性,通過裝飾器模式,層層裝飾,最後建立了一個SynchronizedCache,並新增到configuration中。因此我們可以知道,一旦我們在對映檔案中設定了<cache>,就會建立一個SynchronizedCache快取物件。

  public Cache useNewCache(Class<? extends Cache> typeClass,
      Class<? extends Cache> evictionClass,
      Long flushInterval,
      Integer size,
      boolean readWrite,
      boolean blocking,
      Properties props) {
    //把當前的namespace當作快取的id
    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();
    //將cache加入configuration
    configuration.addCache(cache);
    currentCache = cache;
    return cache;
  }

三、CacheKey快取項的key

預設情況下,enabledCache的全域性設定是開啟的,所以Executor會建立一個CachingExecutor,以查詢為例,當執行Executor實現類的時候,會獲取boundsql,並根據當前資訊建立快取項的key。

  @Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    //從MappedStatement中獲取boundsql
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    //Cachekey類表示快取項的key
    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }  

每一個SqlSession中持有了自己的Executor,每一個Executor中有一個Local Cache。當使用者發起查詢時,Mybatis會根據當前執行的MappedStatement生成一個key,去Local Cache中查詢,如果快取命中的話,返回。如果快取沒有命中的話,則寫入Local Cache,最後返回結果給使用者。

boundsql物件的詳細資訊:

在這裡插入圖片描述

CacheKey物件的CreateKey操作:

  • 首先建立一個cachekey,預設hashcode=17,multiplier=37,count=0,updateList初始化。
  • update操作:count++,對checksum,hashcode進行賦值,最後將引數新增到updatelist中。
  //根據傳入資訊,建立chachekey
  @Override
  public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    //執行器關閉就丟擲異常
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    //建立一個cachekey,預設hashcode=17,multiplier=37,count=0,updateList初始化
    CacheKey cacheKey = new CacheKey();
    //新增操作:sql的id,邏輯分頁偏移量,邏輯分頁起始量,sql語句。
    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
    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);
      }
    }
    //新增environment的id名,如果它不為空的話
    if (configuration.getEnvironment() != null) {
      // issue #176
      cacheKey.update(configuration.getEnvironment().getId());
    }
    //返回cachekey
    return cacheKey;
  }

所以快取項的key最後表示為:hashcode:checknum:遍歷updateList,以:間隔

2020122321:657338105:com.smday.dao.IUserDao.findById:0:2147483647:select * from user where id = ?:41:mysql


接著,呼叫同類中的query方法,針對是否開啟二級快取做不同的決斷。(需要注意的是,這一部分是建立在cacheEnabled設定為true的前提下,當然預設是true。如果為false,Executor將會建立BaseExecutor,並不會判斷mappers對映檔案中二級快取是否存在,而是直接執行delegate.<E> 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();
    //對映檔案配置已經開啟二級快取
    if (cache != null) {
      //如果cache不為空,且需要清快取的話(insert|update|delete),執行tcm.clear(cache);
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, parameterObject, boundSql);
        @SuppressWarnings("unchecked")
        //從快取中獲取
        List<E> list = (List<E>) tcm.getObject(cache, key);
        if (list == null) {
          //快取中沒有就執行查詢,BaseExecutor的query
          list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          //存入快取
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        //如果快取中有,就直接返回
        return list;
      }
    }
    //對映檔案沒有開啟二級快取,需要進行查詢,delegate其實還是Executor物件
    return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

除了select操作之外,其他的的操作都會清空二級快取。XMLStatementBuilder中配置屬性的時候:boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);

  private void flushCacheIfRequired(MappedStatement ms) {
    Cache cache = ms.getCache();
    if (cache != null && ms.isFlushCacheRequired()) {
      //tcm後面會總結,清空二級快取
      tcm.clear(cache);
    }
  }

四、二級快取TransactionCache

這裡學習一下二級快取涉及的快取類:TransactionCache,同樣也是基於裝飾者設計模式,對傳入的Cache進行裝飾,構建二級快取事務緩衝區:

在這裡插入圖片描述

CachingExecutor維護了一個TransactionCacheManager,即tcm,而這個tcm其實維護的就是一個key為Cache,value為TransactionCache包裝過的Cache。而tcm.getObject(cache, key)的意思我們可以通過以下原始碼得知:

  public Object getObject(Cache cache, CacheKey key) {
    //將傳入的cache包裝為TransactionalCache,並根據key獲取值
    return getTransactionalCache(cache).getObject(key);
  }

需要注意的是,getObject方法中將會把獲取值的職責一路向後傳遞,直到最基礎的perpetualCache,根據cachekey獲取。

在這裡插入圖片描述

最終獲取到的值,如果為null,就需要把key加入未命中條目的快取。

  @Override
  public Object getObject(Object key) {
    //根據職責一路向後傳遞
    Object object = delegate.getObject(key);
    if (object == null) {
      //沒找到值就將key存入未命中的set
      entriesMissedInCache.add(key);
    }
    // issue #146
    if (clearOnCommit) {
      return null;
    } else {
      return object;
    }
  }

如果快取中沒有找到,將會從資料庫中查詢,查詢到之後,將會進行新增操作,也就是:tcm.putObject(cache, key, list);。我們可以發現,其實它並沒有直接將資料加入快取,而是將資料新增進待提交的map中。

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

也就是說,一定需要某種手段才能讓他真正地存入快取,沒錯了,commit是可以的:

  //CachingExecutor.java
  @Override
  public void commit(boolean required) throws SQLException {
    //清除本地快取
    delegate.commit(required);
    //呼叫tcm.commit
    tcm.commit();
  }

最終呼叫的是TransactionCache的commit方法:

  public void commit() {
    if (clearOnCommit) {
      delegate.clear();
    }
    flushPendingEntries();
    reset();
  }

最後的最後,我們可以看到將剛才的未命中和待提交的資料都進行了相應的處理,這才是最終影響二級快取中資料的操作,當然這中間也存在著職責鏈,就不贅述了。

在這裡插入圖片描述

當然,除了commit,close也是一樣的,因為最終呼叫的其實都是commit方法,同樣也會操作快取。

五、二級快取測試

    <!-- 開啟全域性配置 -->
    <settings>
        <!--全域性開啟快取配置,是預設開啟的-->
        <setting name="cacheEnabled" value="true"/>
    </settings>

    <!-- 對映配置檔案 -->
    <!--開啟user支援二級快取-->
    <cache></cache>

    <select id="findById" resultType="user" useCache="true" >
        select * from user where id = #{id}
    </select>

    /**
     * 測試二級快取
     */
    @Test
    public void testFirstLevelCache2(){
        SqlSession sqlSession1 = factory.openSession();
        IUserDao userDao1 = sqlSession1.getMapper(IUserDao.class);
        User user1 = userDao1.findById(41);
        System.out.printf("==> %s\n", user1);
        sqlSession1.commit();
        //sqlSession1.close();
        
        SqlSession sqlSession2 = factory.openSession();
        IUserDao userDao2 = sqlSession2.getMapper(IUserDao.class);
        User user2 = userDao2.findById(41);
        System.out.printf("==> %s\n", user2);
        sqlSession2.close();
        System.out.println("user1 == user2:"+(user1 == user2));
        
        SqlSession sqlSession3 = factory.openSession();
        IUserDao userDao3 = sqlSession3.getMapper(IUserDao.class);
        User user3 = userDao3.findById(41);
        System.out.printf("==> %s\n", user3);
        sqlSession2.close();
        System.out.println("user2 == user3:"+(user2 == user3));
    }

在這裡插入圖片描述

二級快取實現了SqlSession之間快取資料的共享,是mapper對映級別的快取。

有時快取也會帶來資料讀取正確性的問題,如果資料更新頻繁,會導致從快取中讀取到的資料並不是最新的,可以關閉二級快取。

六、一級快取原始碼解析

主配置檔案或對映檔案沒有配置二級快取開啟,或者二級快取中不存在資料,最終都會執行BaseExecutor的query方法,如果queryStack為空或者不是select語句,就會先清空本地的快取。

    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }

檢視本地快取(一級快取)是否有資料,如果有直接返回,如果沒有,則呼叫queryFromDatabase從資料庫中查詢。

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);
}

判斷本地快取的級別是否為STATEMENT級別,如果是的話,清空快取,因此STATEMENT級別的一級快取無法共享localCache。

      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        clearLocalCache();
      }

七、測試一級快取

    /**
     * 測試一級快取
     */
    @Test
    public void testFirstLevelCache1(){
        SqlSession sqlSession1 = factory.openSession();
        IUserDao userDao1 = sqlSession1.getMapper(IUserDao.class);
        User user1 = userDao1.findById(41);
        System.out.printf("==> %s\n", user1);

        IUserDao userDao2 = sqlSession1.getMapper(IUserDao.class);
        User user2 = userDao2.findById(41);
        System.out.printf("==> %s\n", user2);
        sqlSession1.close();
        System.out.println("user1 == user2:"+(user1 == user2));
    }

在這裡插入圖片描述

一級快取預設是sqlSession級別地快取,insert|delete|update|commit()和close()的操作的執行都會清空一級快取。

怎麼說呢,分析原始碼的過程讓我對Mybatis有了更加深刻的認識,可能有些理解還是沒有很到位,或許是經驗不足,很多東西還是浮於表面,但一翻debug下來,看到自己之前一個又一個的迷惑被非常確切地解開,真的爽!

https://www.jianshu.com/p/c553169c5921

相關文章