MyBatis特快取性詳解

程式設計師自由之路發表於2020-06-03

快取簡介

一般我們在系統中使用快取技術是為了提升資料查詢的效率。當我們從資料庫中查詢到一批資料後將其放入到混存中(簡單理解就是一塊記憶體區域),下次再查詢相同資料的時候就直接從快取中獲取資料就行了。這樣少了一步和資料庫的互動,可以提升查詢的效率。

但是一個硬幣都具有兩面性,快取在帶來效能提升的同時也“悄悄”引入了很多問題,比如快取同步、快取失效、快取雪崩等等。當然這些問題不是本文討論的重點。

本文主要討論MyBatis快取這個比較雞肋的功能。雖然說MyBatis的快取功能比較雞肋,但是為了全面瞭解MyBatis這個框架,學習下快取這個功能還是挺有必要的。MyBatis的快取分為一級快取和二級快取,下面就分別來介紹下這兩個特性。

一級快取

在應用執行過程中,我們有可能在一次資料庫會話中,執行多次查詢條件完全相同的SQL,MyBatis提供了一級快取的方案優化這部分場景,如果是相同的SQL語句,會優先命中一級快取,避免直接對資料庫進行查詢,提高效能。

什麼是MyBatis一級快取

一級快取是 SqlSession級別 的快取。在運算元據庫時需要構造 sqlSession 物件,在物件中有一個(記憶體區域)資料結構(HashMap)用於儲存快取資料。不同的 sqlSession 之間的快取資料區域(HashMap)是互相不影響的。

在應用執行過程中,我們有可能在一次資料庫會話中,執行多次查詢條件完全相同的SQL,MyBatis 提供了一級快取的方案優化這部分場景,如果是相同的SQL語句,會優先命中一級快取,避免直接對資料庫進行查詢,提高效能。

怎麼開啟一級快取

MyBatis中一級快取預設是開啟的,不需要我們做額外的操作。

如果你需要關閉一級快取的話,可以在Mapper對映檔案中將flushCache屬性設定為true,這種做法只會針對單個SQL操作生效

<select id="selectByPrimaryKey" parameterType="java.lang.String" resultMap="BaseResultMap" flushCache="true">
    select 
    <include refid="Base_Column_List" />
    from cbondissuer
    where OBJECT_ID = #{objectId,jdbcType=VARCHAR}
  </select>
> 還有一種做法是在MyBatis的主配置檔案中,關閉所有的一級快取
> ```xml
>   預設是SESSION,也就是開啟一級快取
>   <setting name="localCacheScope" value="STATEMENT"/>
> ```

下面我們來寫程式碼驗證下MyBatis的一級快取。

```java
String id = "123";
SqlSession sqlSession1 = sqlSessionFactory.openSession();
SqlSession sqlSession2 = sqlSessionFactory.openSession();
//同一個sqlSession建立的Mapper
CbondissuerMapper cbondissuerMapper10 = sqlSession1.getMapper(CbondissuerMapper.class);
CbondissuerMapper cbondissuerMapper11 = sqlSession1.getMapper(CbondissuerMapper.class);
//另外一個sqlSession建立的Mapper
CbondissuerMapper cbondissuerMapper20 = sqlSession2.getMapper(CbondissuerMapper.class);

//同一個Mapper,同樣的SQL查了兩次
Cbondissuer cbondissuer10 = cbondissuerMapper10.selectByPrimaryKey(id);
Cbondissuer cbondissuer101 = cbondissuerMapper10.selectByPrimaryKey(id);
//同一個sqlSession建立的Mapper,又查詢了一次同樣的SQL
Cbondissuer cbondissuer11 = cbondissuerMapper11.selectByPrimaryKey(id);
//不一樣的sqlSession建立的Mapper查詢了一次同樣的SQL
Cbondissuer cbondissuer20 = cbondissuerMapper20.selectByPrimaryKey(id);

System.out.println("cbondissuer10 equals cbondissuer101 :"+(cbondissuer10==cbondissuer101));
System.out.println("cbondissuer10 equals cbondissuer11 :"+(cbondissuer10==cbondissuer11));
System.out.println("cbondissuer10 equals cbondissuer21 :"+(cbondissuer10==cbondissuer20));

sqlSession1.close();
sqlSession2.close();
System.out.println("end...");

上面進行了四次查詢,如果你觀察日誌的話。會發現只進行了兩個資料庫查詢。因為第二和第三次的查詢都查詢了一級快取,查出的其實是快取中的結果。所以輸出的結果是

cbondissuer10 equals cbondissuer101 :true
cbondissuer10 equals cbondissuer11 :true
cbondissuer10 equals cbondissuer21 :false

哪些因素會使一級快取失效

上面的一級快取初探讓我們感受到了 MyBatis 中一級快取的存在,那麼現在你或許就會有疑問了,那麼什麼時候快取失效呢?

  • 通過同一個SqlSession執行更新操作時,這個更新操作不僅僅指代update操作,還指插入和刪除操作;
  • 事務提交時會刪除一級快取;
  • 事務回滾時也會刪除一級快取;

一級快取原始碼解析

其實MyBatis一級快取的實質就是一個Executor的一個類似Map的屬性,分析原始碼的方法就是看在哪些地方從這個Map中查詢了快取,又是在哪些清空了這些快取。

1. 查詢時使用快取分析

public abstract class BaseExecutor implements Executor {

  private static final Log log = LogFactory.getLog(BaseExecutor.class);

  protected Transaction transaction;
  protected Executor wrapper;

  protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
  //這個localCache變數就是一級快取變數
  protected PerpetualCache localCache;
  protected PerpetualCache localOutputParameterCache;
  protected Configuration configuration;
  //..省略下面程式碼
}

全域性搜尋程式碼中哪些地方使用了這個變數,很容易找到BaseExecutor.query方法使用了這個快取:

public abstract class BaseExecutor implements Executor {

// 省略其他程式碼
 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;
  }
  //..省略下面程式碼
}

上面的程式碼展示了,BaseExecutor的query方法使用快取的過程。需要注意的是查詢快取時是根據cacheKey進行查詢的,我們可以將這個key簡單的
理解為sql語句,不同的sql語句能查出不同的快取。(注意sql語句中的引數不同也會被認為是不同的sql語句)。

2. 導致一級快取失效的程式碼分析
檢視BaseExecutor的程式碼,我們很容易發現是下面的方法清空了一級快取。(不要問我是怎麼發現這個程式碼的,看程式碼能力需要自己慢慢提升)

@Override
public void clearLocalCache() {
    if (!closed) {
        localCache.clear();
        localOutputParameterCache.clear();
    }
}

那麼我們只要檢視哪些地方呼叫了這個方法就知道哪些情況下會導致一級快取失效了。跟蹤下來,最後發現下面三處地方會使得一級快取失效

BaseExecutor的update方法,使用MyBatis的介面進行增、刪、改操作都會呼叫到這個方法,這個也印證了上面的說法。

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

BaseExecutor的commit方法,事務提交會導致一級快取失敗。如果我們使用Spring的話,一般事務都是自動提交的,所以好像MyBatis的一級快取一直沒怎麼被考慮過

@Override
  public void commit(boolean required) throws SQLException {
    if (closed) {
      throw new ExecutorException("Cannot commit, transaction is already closed");
    }
    clearLocalCache();
    flushStatements();
    if (required) {
      transaction.commit();
    }
  }

BaseExecutor的rollback方法,事務回滾也會導致一級快取失效。

@Override
  public void rollback(boolean required) throws SQLException {
    if (!closed) {
      try {
        clearLocalCache();
        flushStatements(true);
      } finally {
        if (required) {
          transaction.rollback();
        }
      }
    }
  }

一級快取使用建議

平時使用MyBatis時都是和Spring結合使用的,在整個Spring容器中一般只有一個SqlSession實現類。而Spring一般都是主動提交事務的,所以說一級快取經常失效。

還有就是我們也很少在一個事務範圍內執行同一個SQL兩遍,上面的這些原因導致我們在開發過程中很少注意到MyBatis一級快取的存在。

不怎麼用並不是說不用,作為一個合格的開發者需要對這些心知肚明,要清楚的知道MyBatis一級快取的工作流程。

二級快取

什麼是MyBatis二級快取

MyBatis 一級快取最大的共享範圍就是一個SqlSession內部,那麼如果多個 SqlSession 需要共享快取,則需要開啟二級快取,開啟二級快取後,會使用 CachingExecutor 裝飾 Executor,
進入一級快取的查詢流程前,先在CachingExecutor 進行二級快取的查詢,具體的工作流程如下所示:

當二級快取開啟後,同一個名稱空間(namespace) 所有的操作語句,都影響著一個 共同的 cache(一個Mapper對映檔案對應一個Cache),也就是二級快取被多個 SqlSession 共享,是一個全域性的變數。當開啟快取後,資料的查詢執行的流程就是 二級快取 -> 一級快取 -> 資料庫。

從上面的圖可以看出,MyBatis的二級快取實現可以有很多種,可以是MemCache、Ehcache等。也可以是Redis等,但是需要額外的Jar包。

怎麼開啟二級快取

二級快取預設是不開啟的,需要手動開啟二級快取,實現二級快取的時候,MyBatis要求返回的POJO必須是可序列化的。開啟二級快取的條件也是比較簡單,

step1:通過直接在 MyBatis 配置檔案中通過

<settings>  
	<setting name = "cacheEnabled" value = "true" />
</settings>

step2: 在 Mapper 的xml 配置檔案中加入 標籤

cache標籤下面有下面幾種可選項

  • eviction: 快取回收策略,支援的策略有下面幾種

    • LRU - 最近最少回收,移除最長時間不被使用的物件(預設是這個策略)
    • FIFO - 先進先出,按照快取進入的順序來移除它們
    • SOFT - 軟引用,移除基於垃圾回收器狀態和軟引用規則的物件
    • WEAK - 弱引用,更積極的移除基於垃圾收集器和弱引用規則的物件
  • flushinterval:快取重新整理間隔,快取多長時間重新整理一次,預設不清空,設定一個毫秒值;

  • readOnly: 是否只讀;true 只讀 ,MyBatis 認為所有從快取中獲取資料的操作都是隻讀操作,不會修改資料。MyBatis 為了加快獲取資料,直接就會將資料在快取中的引用交給使用者。不安全,速度快。讀寫(預設):MyBatis 覺得資料可能會被修改

  • size : 快取存放多少個元素

  • type: 指定自定義快取的全類名(實現Cache 介面即可)

  • blocking:若快取中找不到對應的key,是否會一直blocking,直到有對應的資料進入快取。

cache-ref代表引用別的名稱空間的Cache配置,兩個名稱空間的操作使用的是同一個Cache。

哪些因素會使二級快取失效

從上面的介紹可以知道MyBatis的二級快取主要是為了SqlSession之間共享快取設計的。但是我們平時開發過程中都是結合Spring來進行MyBatis的開發。在Spring環境下一般也只有一個SqlSession例項,所以二級快取使用到的機會不多。所以下面就簡單描述下Mybatis的二級快取。

還是以上面的列子為列

String id = "{0003CCCA-AEA9-4A1E-A3CC-06D884BA3906}";
SqlSession sqlSession1 = sqlSessionFactory.openSession();
SqlSession sqlSession2 = sqlSessionFactory.openSession();
//同一個sqlSession建立的Mapper
CbondissuerMapper cbondissuerMapper10 = sqlSession1.getMapper(CbondissuerMapper.class);
CbondissuerMapper cbondissuerMapper11 = sqlSession1.getMapper(CbondissuerMapper.class);
//另外一個sqlSession建立的Mapper
CbondissuerMapper cbondissuerMapper20 = sqlSession2.getMapper(CbondissuerMapper.class);

//同一個Mapper,同樣的SQL查了兩次
Cbondissuer cbondissuer10 = cbondissuerMapper10.selectByPrimaryKey(id);
Cbondissuer cbondissuer101 = cbondissuerMapper10.selectByPrimaryKey(id);
//同一個sqlSession建立的Mapper,又查詢了一次同樣的SQL
Cbondissuer cbondissuer11 = cbondissuerMapper11.selectByPrimaryKey(id);
//這邊需要提交事務才能讓二級快取生效
sqlSession1.commit();
//不一樣的sqlSession建立的Mapper查詢了一次同樣的SQL
Cbondissuer cbondissuer20 = cbondissuerMapper20.selectByPrimaryKey(id);

System.out.println("cbondissuer10 equals cbondissuer101 :"+(cbondissuer10==cbondissuer101));
System.out.println("cbondissuer10 equals cbondissuer11 :"+(cbondissuer10==cbondissuer11));
System.out.println("cbondissuer10 equals cbondissuer21 :"+(cbondissuer10==cbondissuer20));
  • 二級快取是以namespace(Mapper)為單位的,不同namespace下的操作互不影響。
  • insert,update,delete操作會清空所在namespace下的全部快取。
  • 多表操作一定不要使用二級快取,因為多表操作進行更新操作,一定會產生髒資料。

二級快取使用建議

個人覺得MyBatis的二級快取實用性不是很大。一個原因就是Spring環境下,一本只有一個SqlSession,不存在sqlSession之間共享快取;還有就是
MyBatis的快取都不能做到分散式,所以對於MyBatis的二級快取以瞭解為主。

簡單總結

一級快取

  • 一級快取的本質是Executor的一個類似Map的屬性;
  • 一級快取預設開啟,將flushCache設定成true或者將全域性配置localCacheScope設定成Statement可以關閉一級快取;
  • 在一級快取開啟的情況下,查詢操作會先查詢一級快取,再查詢資料庫;
  • 增刪改操作和事務提交回滾操作會導致一級快取失效;
  • 由於Spring中事務是自動提交的,因此Spring下的MyBatis一級快取經常失效。(但是並不表示不生效,除非你手動關閉一級快取)
  • 不能實現分散式。

二級快取

  • namesapce級別的快取(Mapper級別或者叫做表級別的快取),設計的主要目的是實現sqlSession之間的快取共享;
  • 開啟二級快取後,查詢的邏輯是二級快取->已經快取->資料庫;
  • insert,update,delete操作會清空所在namespace下的全部快取;
  • 多表查詢一定不要使用二級快取,因為多表操作進行更新操作,可能會產生髒資料。

總體來說,MyBatis的快取功能比較雞肋。想要使用快取的話還是建議使用spring-cache等框架。

參考

相關文章