【Mybatis系列】從原始碼角度深度理解Mybatis的快取特性

凱倫說_美團點評發表於2017-09-25

前言

基於個人的興趣,為大家分享Mybatis的一級快取以及二級快取的特性。

  1. Mybatis是什麼。
  2. Mybatis一級和二級快取如何配置使用。
  3. Mybatis一級和二級快取的工作流程及原始碼分析。

本次分析中涉及到的程式碼和資料庫表均放在Github上,地址: mybatis-cache-demo

目錄

為達到以上三個目的,本文按照以下順序展開。

  1. Mybatis的基礎概念。
  2. 一級快取介紹及相關配置。
  3. 一級快取工作流程及原始碼分析。
  4. 一級快取總結。
  5. 二級快取介紹及相關配置。
  6. 二級快取原始碼分析。
  7. 二級快取總結。
  8. 全文總結。

Mybatis的基礎概念

本章節會對Mybatis進行大體的介紹,分為官方定義和核心元件介紹。
首先是Mybatis官方定義,如下所示。

MyBatis是支援定製化SQL、儲存過程以及高階對映的優秀的持久層框架。MyBatis避免了幾乎所有的JDBC程式碼和手動設定引數以及獲取結果集。MyBatis可以對配置和原生Map使用簡單的XML或註解,將介面和Java 的POJOs(Plain Old Java Objects,普通的 Java物件)對映成資料庫中的記錄。

其次是Mybatis的幾個核心概念。

  1. SqlSession : 代表和資料庫的一次會話,向使用者提供了運算元據庫的方法。
  2. MappedStatement: 代表要發往資料庫執行的指令,可以理解為是Sql的抽象表示。
  3. Executor: 具體用來和資料庫互動的執行器,接受MappedStatement作為引數。
  4. 對映介面: 在介面中會要執行的Sql用一個方法來表示,具體的Sql寫在對映檔案中。
  5. 對映檔案: 可以理解為是Mybatis編寫Sql的地方,通常來說每一張單表都會對應著一個對映檔案,在該檔案中會定義Sql語句入參和出參的形式。

下圖就是一個針對Student表操作的介面檔案StudentMapper,在StudentMapper中,我們可以若干方法,這個方法背後就是代表著要執行的Sql的意義。


通常也可以把涉及多表查詢的方法定義在StudentMapper中,如果查詢的主體仍然是Student表的資訊。也可以將涉及多表查詢的語句單獨抽出一個獨立的介面檔案。
在定義完介面檔案後,我們會開發一個Sql對映檔案,主要由mapper元素和select|insert|update|delete元素構成,如下圖所示。

mapper元素代表這個檔案是一個對映檔案,使用namespace和具體的對映介面繫結起來,namespace的值就是這個介面的全限定類名。select|insert|update|delete代表的是Sql語句,對映介面中定義的每一個方法也會和對映檔案中的語句通過id的方式繫結起來,方法名就是語句的id,同時會定義語句的入參和出參,用於完成和Java物件之間的轉換。

在Mybatis初始化的時候,每一個語句都會使用對應的MappedStatement代表,使用namespace+語句本身的id來代表這個語句。如下程式碼所示,使用mapper.StudentMapper.getStudentById代表其對應的Sql。

SELECT id,name,age FROM student WHERE id = #{id}複製程式碼

在Mybatis執行時,會進入對應介面的方法,通過類名加上方法名的組合生成id,找到需要的MappedStatement,交給執行器使用。
至此,Mybatis的基礎概念介紹完畢。

一級快取

一級快取介紹

在系統程式碼的執行中,我們可能會在一個資料庫會話中,執行多次查詢條件完全相同的Sql,鑑於日常應用的大部分場景都是讀多寫少,這重複的查詢會帶來一定的網路開銷,同時select查詢的量比較大的話,對資料庫的效能是有比較大的影響的。

如果是Mysql資料庫的話,在服務端和Jdbc端都開啟預編譯支援的話,可以在本地JVM端快取Statement,可以在Mysql服務端直接執行Sql,省去編譯Sql的步驟,但也無法避免和資料庫之間的重複互動。關於Jdbc和Mysql預編譯快取的事情,可以看我的這篇部落格JDBC和Mysql那些事

Mybatis提供了一級快取的方案來優化在資料庫會話間重複查詢的問題。實現的方式是每一個SqlSession中都持有了自己的快取,一種是SESSION級別,即在一個Mybatis會話中執行的所有語句,都會共享這一個快取。一種是STATEMENT級別,可以理解為快取只對當前執行的這一個statement有效。如果用一張圖來代表一級查詢的查詢過程的話,可以用下圖表示。


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

一級快取配置

上文介紹了一級快取的實現方式,解決了什麼問題。在這個章節,我們學習如何使用Mybatis的一級快取。只需要在Mybatis的配置檔案中,新增如下語句,就可以使用一級快取。共有兩個選項,SESSION或者STATEMENT,預設是SESSION級別。

<setting name="localCacheScope" value="SESSION"/>複製程式碼

一級快取實驗

配置完畢後,通過實驗的方式瞭解Mybatis一級快取的效果。每一個單元測試後都請恢復被修改的資料。
首先是建立了一個示例表student,為其建立了對應的POJO類和增改的方法,具體可以在entity包和Mapper包中檢視。

CREATE TABLE `student` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(200) COLLATE utf8_bin DEFAULT NULL,
  `age` tinyint(3) unsigned DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;複製程式碼

在以下實驗中,id為1的學生名稱是凱倫。

實驗1

開啟一級快取,範圍為會話級別,呼叫三次getStudentById,程式碼如下所示:

public void getStudentById() throws Exception {
        SqlSession sqlSession = factory.openSession(true); // 自動提交事務
        StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
        System.out.println(studentMapper.getStudentById(1));
        System.out.println(studentMapper.getStudentById(1));
        System.out.println(studentMapper.getStudentById(1));
    }複製程式碼

執行結果:


我們可以看到,只有第一次真正查詢了資料庫,後續的查詢使用了一級快取。

實驗2

在這次的試驗中,我們增加了對資料庫的修改操作,驗證在一次資料庫會話中,對資料庫發生了修改操作,一級快取是否會失效。

@Test
public void addStudent() throws Exception {
        SqlSession sqlSession = factory.openSession(true); // 自動提交事務
        StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
        System.out.println(studentMapper.getStudentById(1));
        System.out.println("增加了" + studentMapper.addStudent(buildStudent()) + "個學生");
        System.out.println(studentMapper.getStudentById(1));
        sqlSession.close();
}複製程式碼

執行結果:


我們可以看到,在修改操作後執行的相同查詢,查詢了資料庫,一級快取失效

實驗3

開啟兩個SqlSession,在sqlSession1中查詢資料,使一級快取生效,在sqlSession2中更新資料庫,驗證一級快取只在資料庫會話內部共享。

@Test
public void testLocalCacheScope() throws Exception {
        SqlSession sqlSession1 = factory.openSession(true); 
        SqlSession sqlSession2 = factory.openSession(true); 

       StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
       StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);

        System.out.println("studentMapper讀取資料: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper讀取資料: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2更新了" + studentMapper2.updateStudentName("小岑",1) + "個學生的資料");
        System.out.println("studentMapper讀取資料: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2讀取資料: " + studentMapper2.getStudentById(1));
}複製程式碼


我們可以看到,sqlSession2更新了id為1的學生的姓名,從凱倫改為了小岑,但session1之後的查詢中,id為1的學生的名字還是凱倫,出現了髒資料,也證明了我們之前就得到的結論,一級快取只存在於只在資料庫會話內部共享。

一級快取工作流程&原始碼分析

這一章節主要從一級快取的工作流程和原始碼層面對一級快取進行學習。

工作流程

根據一級快取的工作流程,我們繪製出一級快取執行的時序圖,如下圖所示。


主要步驟如下:

  1. 對於某個Select Statement,根據該Statement生成key。
  2. 判斷在Local Cache中,該key是否用對應的資料存在。
  3. 如果命中,則跳過查詢資料庫,繼續往下走。
  4. 如果沒命中:
     4.1  去資料庫中查詢資料,得到查詢結果;
     4.2  將key和查詢到的結果作為key和value,放入Local Cache中。
     4.3. 將查詢結果返回;複製程式碼
  5. 判斷快取級別是否為STATEMENT級別,如果是的話,清空本地快取。
    原始碼分析
    瞭解具體的工作流程後,我們隊Mybatis查詢相關的核心類和一級快取的原始碼進行走讀。這對於之後學習二級快取時也有幫助。
    SqlSession: 對外提供了使用者和資料庫之間互動需要的所有方法,隱藏了底層的細節。它的一個預設實現類是DefaultSqlSession。

    Executor: SqlSession向使用者提供運算元據庫的方法,但和資料庫操作有關的職責都會委託給Executor。

    如下圖所示,Executor有若干個實現類,為Executor賦予了不同的能力,大家可以根據類名,自行私下學習每個類的基本作用。

    在一級快取章節,我們主要學習BaseExecutor。
    BaseExecutor: BaseExecutor是一個實現了Executor介面的抽象類,定義若干抽象方法,在執行的時候,把具體的操作委託給子類進行執行。
    protected abstract int doUpdate(MappedStatement ms, Object parameter) throws SQLException;
    protected abstract List<BatchResult> doFlushStatements(boolean isRollback) throws SQLException;
    protected abstract <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException;
    protected abstract <E> Cursor<E> doQueryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds, BoundSql boundSql) throws SQLException;複製程式碼
    在一級快取的介紹中,我們提到對Local Cache的查詢和寫入是在Executor內部完成的。在閱讀BaseExecutor的程式碼後,我們也發現Local Cache就是它內部的一個成員變數,如下程式碼所示。
    public abstract class BaseExecutor implements Executor {
    protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
    protected PerpetualCache localCache;複製程式碼
    Cache: Mybatis中的Cache介面,提供了和快取相關的最基本的操作,有若干個實現類,使用裝飾器模式互相組裝,提供豐富的操控快取的能力。


BaseExecutor成員變數之一的PerpetualCache,就是對Cache介面最基本的實現,其實現非常的簡內部持有了hashmap,對一級快取的操作其實就是對這個hashmap的操作。如下程式碼所示。

public class PerpetualCache implements Cache {
  private String id;
  private Map<Object, Object> cache = new HashMap<Object, Object>();複製程式碼

在閱讀相關核心類程式碼後,從原始碼層面對一級快取工作中涉及到的相關程式碼,出於篇幅的考慮,對原始碼做適當刪減,讀者朋友可以結合本文,後續進行更詳細的學習。
為了執行和資料庫的互動,首先會通過DefaultSqlSessionFactory開啟一個SqlSession,在建立SqlSession的過程中,會通過Configuration類建立一個全新的Executor,作為DefaultSqlSession建構函式的引數,程式碼如下所示。

private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
      ............
    final Executor executor = configuration.newExecutor(tx, execType);     
    return new DefaultSqlSession(configuration, executor, autoCommit);
}複製程式碼

如果使用者不進行制定的話,Configuration在建立Executor時,預設建立的型別就是SimpleExecutor,它是一個簡單的執行類,只是單純執行Sql。以下是具體用來建立的程式碼。

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);
    }
    // 尤其可以注意這裡,如果二級快取開關開啟的話,是使用CahingExecutor裝飾BaseExecutor的子類
    if (cacheEnabled) {
      executor = new CachingExecutor(executor);                      
    }
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
}複製程式碼

在SqlSession建立完畢後,根據Statment的不同型別,會進入SqlSession的不同方法中,如果是Select語句的話,最後會執行到SqlSession的selectList,程式碼如下所示。

@Override
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
      MappedStatement ms = configuration.getMappedStatement(statement);
      return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
}複製程式碼

在上文的程式碼中,SqlSession把具體的查詢職責委託給了Executor。如果只開啟了一級快取的話,首先會進入BaseExecutor的query方法。程式碼如下所示。

@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameter);
    CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
    return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}複製程式碼

在上述程式碼中,會先根據傳入的引數生成CacheKey,進入該方法檢視CacheKey是如何生成的,程式碼如下所示。

CacheKey cacheKey = new CacheKey();
cacheKey.update(ms.getId());
cacheKey.update(rowBounds.getOffset());
cacheKey.update(rowBounds.getLimit());
cacheKey.update(boundSql.getSql());
//後面是update了sql中帶的引數
cacheKey.update(value);複製程式碼

在上述的程式碼中,我們可以看到它將MappedStatement的Id、sql的offset、Sql的limit、Sql本身以及Sql中的引數傳入了CacheKey這個類,最終生成了CacheKey。我們看一下這個類的結構。

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

private 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>();
}複製程式碼

首先是它的成員變數和建構函式,有一個初始的hachcode和乘數,同時維護了一個內部的updatelist。在CacheKey的update方法中,會進行一個hashcode和checksum的計算,同時把傳入的引數新增進updatelist中。如下程式碼所示。

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);
}複製程式碼

我們是如何判斷CacheKey相等的呢,在CacheKey的equals方法中給了我們答案,程式碼如下所示。

@Override
public boolean equals(Object object) {
    .............
    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;
}複製程式碼

除去hashcode,checksum和count的比較外,只要updatelist中的元素一一對應相等,那麼就可以認為是CacheKey相等。只要兩條Sql的下列五個值相同,即可以認為是相同的Sql。

Statement Id + Offset + Limmit + Sql + Params

BaseExecutor的query方法繼續往下走,程式碼如下所示。

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);
}複製程式碼

如果查不到的話,就從資料庫查,在queryFromDatabase中,會對localcache進行寫入。
在query方法執行的最後,會判斷一級快取級別是否是STATEMENT級別,如果是的話,就清空快取,這也就是STATEMENT級別的一級快取無法共享localCache的原因。程式碼如下所示。

if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        clearLocalCache();
}複製程式碼

在原始碼分析的最後,我們確認一下,如果是insert/delete/update方法,快取就會重新整理的原因。
SqlSession的insert方法和delete方法,都會統一走update的流程,程式碼如下所示。

@Override
public int insert(String statement, Object parameter) {
    return update(statement, parameter);
  }
   @Override
  public int delete(String statement) {
    return update(statement, null);
}複製程式碼

update方法也是委託給了Executor執行。BaseExecutor的執行方法如下所示。

@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);
}複製程式碼

每次執行update前都會清空localCache。

至此,一級快取的工作流程講解以及原始碼分析完畢。

總結

  1. Mybatis一級快取的生命週期和SqlSession一致。
  2. Mybatis的快取是一個粗粒度的快取,沒有更新快取和快取過期的概念,同時只是使用了預設的hashmap,也沒有做容量上的限定。
  3. Mybatis的一級快取最大範圍是SqlSession內部,有多個SqlSession或者分散式的環境下,有運算元據庫寫的話,會引起髒資料,建議是把一級快取的預設級別設定為Statement,即不使用一級快取。

二級快取

二級快取介紹

在上文中提到的一級快取中,其最大的共享範圍就是一個SqlSession內部,那麼如何讓多個SqlSession之間也可以共享快取呢,答案是二級快取。
當開啟二級快取後,會使用CachingExecutor裝飾Executor,在進入後續執行前,先在CachingExecutor進行二級快取的查詢,具體的工作流程如下所示。


在二級快取的使用中,一個namespace下的所有操作語句,都影響著同一個Cache,即二級快取是被多個SqlSession共享著的,是一個全域性的變數。
當開啟快取後,資料的查詢執行的流程就是 二級快取 -> 一級快取 -> 資料庫。

二級快取配置

要正確的使用二級快取,需完成如下配置的。
1 在Mybatis的配置檔案中開啟二級快取。

<setting name="cacheEnabled" value="true"/>複製程式碼

2 在Mybatis的對映XML中配置cache或者 cache-ref 。

<cache/>複製程式碼

cache標籤用於宣告這個namespace使用二級快取,並且可以自定義配置。

  • type: cache使用的型別,預設是PerpetualCache,這在一級快取中提到過。
  • eviction: 定義回收的策略,常見的有FIFO,LRU。
  • flushInterval: 配置一定時間自動重新整理快取,單位是毫秒
  • size: 最多快取物件的個數
  • readOnly: 是否只讀,若配置可讀寫,則需要對應的實體類能夠序列化。
  • blocking: 若快取中找不到對應的key,是否會一直blocking,直到有對應的資料進入快取。
<cache-ref namespace="mapper.StudentMapper"/>複製程式碼

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

二級快取實驗

在本章節,通過實驗,瞭解Mybatis二級快取在使用上的一些特點。
在本實驗中,id為1的學生名稱初始化為點點。

實驗1

測試二級快取效果,不提交事務,sqlSession1查詢完資料後,sqlSession2相同的查詢是否會從快取中獲取資料。

@Test
public void testCacheWithoutCommitOrClose() throws Exception {
        SqlSession sqlSession1 = factory.openSession(true); 
        SqlSession sqlSession2 = factory.openSession(true); 

        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);

        System.out.println("studentMapper讀取資料: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2讀取資料: " + studentMapper2.getStudentById(1));
}複製程式碼

執行結果:


我們可以看到,當sqlsession沒有呼叫commit()方法時,二級快取並沒有起到作用。

實驗2

測試二級快取效果,當提交事務時,sqlSession1查詢完資料後,sqlSession2相同的查詢是否會從快取中獲取資料。

@Test
public void testCacheWithCommitOrClose() throws Exception {
        SqlSession sqlSession1 = factory.openSession(true); 
        SqlSession sqlSession2 = factory.openSession(true); 

        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);

        System.out.println("studentMapper讀取資料: " + studentMapper.getStudentById(1));
        sqlSession1.commit();
        System.out.println("studentMapper2讀取資料: " + studentMapper2.getStudentById(1));
}複製程式碼


從圖上可知,sqlsession2的查詢,使用了快取,快取的命中率是0.5。

實驗3

測試update操作是否會重新整理該namespace下的二級快取。

@Test
public void testCacheWithUpdate() throws Exception {
        SqlSession sqlSession1 = factory.openSession(true); 
        SqlSession sqlSession2 = factory.openSession(true); 
        SqlSession sqlSession3 = factory.openSession(true); 

        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
        StudentMapper studentMapper3 = sqlSession3.getMapper(StudentMapper.class);

        System.out.println("studentMapper讀取資料: " + studentMapper.getStudentById(1));
        sqlSession1.commit();
        System.out.println("studentMapper2讀取資料: " + studentMapper2.getStudentById(1));

        studentMapper3.updateStudentName("方方",1);
        sqlSession3.commit();
        System.out.println("studentMapper2讀取資料: " + studentMapper2.getStudentById(1));
}複製程式碼


我們可以看到,在sqlSession3更新資料庫,並提交事務後,sqlsession2的StudentMapper namespace下的查詢走了資料庫,沒有走Cache。

實驗4

驗證Mybatis的二級快取不適應用於對映檔案中存在多表查詢的情況。一般來說,我們會為每一個單表建立一個單獨的對映檔案,如果存在涉及多個表的查詢的話,由於Mybatis的二級快取是基於namespace的,多表查詢語句所在的namspace無法感應到其他namespace中的語句對多表查詢中涉及的表進行了修改,引發髒資料問題。

@Test
public void testCacheWithDiffererntNamespace() throws Exception {
        SqlSession sqlSession1 = factory.openSession(true); 
        SqlSession sqlSession2 = factory.openSession(true); 
        SqlSession sqlSession3 = factory.openSession(true); 

        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
        ClassMapper classMapper = sqlSession3.getMapper(ClassMapper.class);

        System.out.println("studentMapper讀取資料: " + studentMapper.getStudentByIdWithClassInfo(1));
        sqlSession1.close();
        System.out.println("studentMapper2讀取資料: " + studentMapper2.getStudentByIdWithClassInfo(1));

        classMapper.updateClassName("特色一班",1);
        sqlSession3.commit();
        System.out.println("studentMapper2讀取資料: " + studentMapper2.getStudentByIdWithClassInfo(1));
}複製程式碼

執行結果:


在這個實驗中,我們引入了兩張新的表,一張class,一張classroom。class中儲存了班級的id和班級名,classroom中儲存了班級id和學生id。我們在StudentMapper中增加了一個查詢方法getStudentByIdWithClassInfo,用於查詢學生所在的班級,涉及到多表查詢。在ClassMapper中新增了updateClassName,根據班級id更新班級名的操作。
當sqlsession1的studentmapper查詢資料後,二級快取生效。儲存在StudentMapper的namespace下的cache中。當sqlSession3的classMapper的updateClassName方法對class表進行更新時,updateClassName不屬於StudentMapper的namespace,所以StudentMapper下的cache沒有感應到變化,沒有重新整理快取。當StudentMapper中同樣的查詢再次發起時,從快取中讀取了髒資料。

實驗5

為了解決實驗4的問題呢,可以使用Cache ref,讓ClassMapper引用StudenMapper名稱空間,這樣兩個對映檔案對應的Sql操作都使用的是同一塊快取了。
執行結果:


不過這樣做的後果是,快取的粒度變粗了,多個Mapper namespace下的所有操作都會對快取使用造成影響,其實這個快取存在的意義已經不大了。

二級快取原始碼分析

Mybatis二級快取的工作流程和前文提到的一級快取類似,只是在一級快取處理前,用CachingExecutor裝飾了BaseExecutor的子類,實現了快取的查詢和寫入功能,所以二級快取直接從原始碼開始分析。

原始碼分析

原始碼分析從CachingExecutor的query方法展開,原始碼走讀過程中涉及到的知識點較多,不能一一詳細講解,可以在文後留言,我會在交流環節更詳細的表示出來。
CachingExecutor的query方法,首先會從MappedStatement中獲得在配置初始化時賦予的cache。

Cache cache = ms.getCache();複製程式碼

本質上是裝飾器模式的使用,具體的執行鏈是
SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache。


以下是具體這些Cache實現類的介紹,他們的組合為Cache賦予了不同的能力。

  • SynchronizedCache: 同步Cache,實現比較簡單,直接使用synchronized修飾方法。
  • LoggingCache: 日誌功能,裝飾類,用於記錄快取的命中率,如果開啟了DEBUG模式,則會輸出命中率日誌。
  • SerializedCache: 序列化功能,將值序列化後存到快取中。該功能用於快取返回一份例項的Copy,用於儲存執行緒安全。
  • LruCache: 採用了Lru演算法的Cache實現,移除最近最少使用的key/value。
  • PerpetualCache: 作為為最基礎的快取類,底層實現比較簡單,直接使用了HashMap。

然後是判斷是否需要重新整理快取,程式碼如下所示。

flushCacheIfRequired(ms);複製程式碼

在預設的設定中SELECT語句不會重新整理快取,insert/update/delte會重新整理快取。進入該方法。程式碼如下所示。

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

Mybatis的CachingExecutor持有了TransactionalCacheManager,即上述程式碼中的tcm。
TransactionalCacheManager中持有了一個Map,程式碼如下所示。

private Map<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();複製程式碼

這個Map儲存了Cache和用TransactionalCache包裝後的Cache的對映關係。
TransactionalCache實現了Cache介面,CachingExecutor會預設使用他包裝初始生成的Cache,作用是如果事務提交,對快取的操作才會生效,如果事務回滾或者不提交事務,則不對快取產生影響。
在TransactionalCache的clear,有以下兩句。清空了需要在提交時加入快取的列表,同時設定提交時清空快取,程式碼如下所示。

@Override
public void clear() {
    clearOnCommit = true;
    entriesToAddOnCommit.clear();
}複製程式碼

CachingExecutor繼續往下走,ensureNoOutParams主要是用來處理儲存過程的,暫時不用考慮。

if (ms.isUseCache() && resultHandler == null) {
    ensureNoOutParams(ms, parameterObject, boundSql);複製程式碼

之後會嘗試從tcm中獲取快取的列表。

List<E> list = (List<E>) tcm.getObject(cache, key);複製程式碼

在getObject方法中,會把獲取值的職責一路向後傳,最終到PerpetualCache。如果沒有查到,會把key加入Miss集合,這個主要是為了統計命中率。

Object object = delegate.getObject(key);
if (object == null) {
    entriesMissedInCache.add(key);
}複製程式碼

CachingExecutor繼續往下走,如果查詢到資料,則呼叫tcm.putObject方法,往快取中放入值。

if (list == null) {
    list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    tcm.putObject(cache, key, list); // issue #578 and #116
}複製程式碼

tcm的put方法也不是直接操作快取,只是在把這次的資料和key放入待提交的Map中。

@Override
public void putObject(Object key, Object object) {
    entriesToAddOnCommit.put(key, object);
}複製程式碼

從以上的程式碼分析中,我們可以明白,如果不呼叫commit方法的話,由於TranscationalCache的作用,並不會對二級快取造成直接的影響。因此我們看看Sqlsession的commit方法中做了什麼。程式碼如下所示。

@Override
public void commit(boolean force) {
    try {
      executor.commit(isCommitOrRollbackRequired(force));複製程式碼

因為我們使用了CachingExecutor,首先會進入CachingExecutor實現的commit方法。

@Override
public void commit(boolean required) throws SQLException {
    delegate.commit(required);
    tcm.commit();
}複製程式碼

會把具體commit的職責委託給包裝的Executor。主要是看下tcm.commit(),tcm最終又會呼叫到TrancationalCache。

public void commit() {
    if (clearOnCommit) {
      delegate.clear();
    }
    flushPendingEntries();
    reset();
}複製程式碼

看到這裡的clearOnCommit就想起剛才TrancationalCache的clear方法設定的標誌位,真正的清理Cache是放到這裡來進行的。具體清理的職責委託給了包裝的Cache類。之後進入flushPendingEntries方法。程式碼如下所示。

private void flushPendingEntries() {
    for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
      delegate.putObject(entry.getKey(), entry.getValue());
    }
    ................
}複製程式碼

在flushPendingEntries中,就把待提交的Map迴圈後,委託給包裝的Cache類,進行putObject的操作。
後續的查詢操作會重複執行這套流程。如果是insert|update|delete的話,會統一進入CachingExecutor的update方法,其中呼叫了這個函式,程式碼如下所示,因此不再贅述。

private void flushCacheIfRequired(MappedStatement ms)複製程式碼

總結

  1. Mybatis的二級快取相對於一級快取來說,實現了SqlSession之間快取資料的共享,同時粒度更加的細,能夠到Mapper級別,通過Cache介面實現類不同的組合,對Cache的可控性也更強。
  2. Mybatis在多表查詢時,極大可能會出現髒資料,有設計上的缺陷,安全使用的條件比較苛刻。
  3. 在分散式環境下,由於預設的Mybatis Cache實現都是基於本地的,分散式環境下必然會出現讀取到髒資料,需要使用集中式快取將Mybatis的Cache介面實現,有一定的開發成本,不如直接用Redis,Memcache實現業務上的快取就好了。

    全文總結

    本文介紹了Mybatis的基礎概念,Mybatis一二級快取的使用及原始碼分析,並對於一二級快取進行了一定程度上的總結。
    最終的結論是Mybatis的快取機制設計的不是很完善,在使用上容易引起髒資料問題,個人建議不要使用Mybatis快取,在業務層面上使用其他機制實現需要的快取功能,讓Mybatis老老實實做它的ORM框架就好了哈哈。

相關文章