聊聊MyBatis緩存機(jī)制

原文:https://tech.meituan.com/mybatis_cache.html

前言

MyBatis是常見(jiàn)的Java數(shù)據(jù)庫(kù)訪問(wèn)層框架。在日常工作中,開(kāi)發(fā)人員多數(shù)情況下是使用MyBatis的默認(rèn)緩存配置,但是MyBatis緩存機(jī)制有一些不足之處,在使用中容易引起臟數(shù)據(jù),形成一些潛在的隱患。個(gè)人在業(yè)務(wù)開(kāi)發(fā)中也處理過(guò)一些由于MyBatis緩存引發(fā)的開(kāi)發(fā)問(wèn)題,帶著個(gè)人的興趣,希望從應(yīng)用及源碼的角度為讀者梳理MyBatis緩存機(jī)制。
本次分析中涉及到的代碼和數(shù)據(jù)庫(kù)表均放在GitHub上,地址: mybatis-cache-demo 。

目錄

本文按照以下順序展開(kāi)。

  • 一級(jí)緩存介紹及相關(guān)配置。
  • 一級(jí)緩存工作流程及源碼分析。
  • 一級(jí)緩存總結(jié)。
  • 二級(jí)緩存介紹及相關(guān)配置。
  • 二級(jí)緩存源碼分析。
  • 二級(jí)緩存總結(jié)。
  • 全文總結(jié)。

一級(jí)緩存

一級(jí)緩存介紹

在應(yīng)用運(yùn)行過(guò)程中,我們有可能在一次數(shù)據(jù)庫(kù)會(huì)話中,執(zhí)行多次查詢條件完全相同的SQL,MyBatis提供了一級(jí)緩存的方案優(yōu)化這部分場(chǎng)景,如果是相同的SQL語(yǔ)句,會(huì)優(yōu)先命中一級(jí)緩存,避免直接對(duì)數(shù)據(jù)庫(kù)進(jìn)行查詢,提高性能。具體執(zhí)行過(guò)程如下圖所示。


image

每個(gè)SqlSession中持有了Executor,每個(gè)Executor中有一個(gè)LocalCache。當(dāng)用戶發(fā)起查詢時(shí),MyBatis根據(jù)當(dāng)前執(zhí)行的語(yǔ)句生成MappedStatement,在Local Cache進(jìn)行查詢,如果緩存命中的話,直接返回結(jié)果給用戶,如果緩存沒(méi)有命中的話,查詢數(shù)據(jù)庫(kù),結(jié)果寫入Local Cache,最后返回結(jié)果給用戶。具體實(shí)現(xiàn)類的類關(guān)系圖如下圖所示。


image

一級(jí)緩存配置

我們來(lái)看看如何使用MyBatis一級(jí)緩存。開(kāi)發(fā)者只需在MyBatis的配置文件中,添加如下語(yǔ)句,就可以使用一級(jí)緩存。共有兩個(gè)選項(xiàng),SESSION或者STATEMENT,默認(rèn)是SESSION級(jí)別,即在一個(gè)MyBatis會(huì)話中執(zhí)行的所有語(yǔ)句,都會(huì)共享這一個(gè)緩存。一種是STATEMENT級(jí)別,可以理解為緩存只對(duì)當(dāng)前執(zhí)行的這一個(gè)Statement有效。

<setting name="localCacheScope" value="SESSION"/>

一級(jí)緩存實(shí)驗(yàn)

接下來(lái)通過(guò)實(shí)驗(yàn),了解MyBatis一級(jí)緩存的效果,每個(gè)單元測(cè)試后都請(qǐng)恢復(fù)被修改的數(shù)據(jù)。
首先是創(chuàng)建示例表student,創(chuàng)建對(duì)應(yīng)的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;

在以下實(shí)驗(yàn)中,id為1的學(xué)生名稱是凱倫。

實(shí)驗(yàn)1

開(kāi)啟一級(jí)緩存,范圍為會(huì)話級(jí)別,調(diào)用三次getStudentById,代碼如下所示:

public void getStudentById() throws Exception {
        SqlSession sqlSession = factory.openSession(true); // 自動(dòng)提交事務(wù)
        StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
        System.out.println(studentMapper.getStudentById(1));
        System.out.println(studentMapper.getStudentById(1));
        System.out.println(studentMapper.getStudentById(1));
    }

執(zhí)行結(jié)果:


image

我們可以看到,只有第一次真正查詢了數(shù)據(jù)庫(kù),后續(xù)的查詢使用了一級(jí)緩存。

實(shí)驗(yàn)2

增加了對(duì)數(shù)據(jù)庫(kù)的修改操作,驗(yàn)證在一次數(shù)據(jù)庫(kù)會(huì)話中,如果對(duì)數(shù)據(jù)庫(kù)發(fā)生了修改操作,一級(jí)緩存是否會(huì)失效。

@Test
public void addStudent() throws Exception {
        SqlSession sqlSession = factory.openSession(true); // 自動(dòng)提交事務(wù)
        StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
        System.out.println(studentMapper.getStudentById(1));
        System.out.println("增加了" + studentMapper.addStudent(buildStudent()) + "個(gè)學(xué)生");
        System.out.println(studentMapper.getStudentById(1));
        sqlSession.close();
}

執(zhí)行結(jié)果:


image

我們可以看到,在修改操作后執(zhí)行的相同查詢,查詢了數(shù)據(jù)庫(kù),一級(jí)緩存失效

實(shí)驗(yàn)3

開(kāi)啟兩個(gè)SqlSession,在sqlSession1中查詢數(shù)據(jù),使一級(jí)緩存生效,在sqlSession2中更新數(shù)據(jù)庫(kù),驗(yàn)證一級(jí)緩存只在數(shù)據(jù)庫(kù)會(huì)話內(nèi)部共享。

@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讀取數(shù)據(jù): " + studentMapper.getStudentById(1));
        System.out.println("studentMapper讀取數(shù)據(jù): " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2更新了" + studentMapper2.updateStudentName("小岑",1) + "個(gè)學(xué)生的數(shù)據(jù)");
        System.out.println("studentMapper讀取數(shù)據(jù): " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2讀取數(shù)據(jù): " + studentMapper2.getStudentById(1));
}

image

sqlSession2更新了id為1的學(xué)生的姓名,從凱倫改為了小岑,但session1之后的查詢中,id為1的學(xué)生的名字還是凱倫,出現(xiàn)了臟數(shù)據(jù),也證明了之前的設(shè)想,一級(jí)緩存只在數(shù)據(jù)庫(kù)會(huì)話內(nèi)部共享。

一級(jí)緩存工作流程&源碼分析

那么,一級(jí)緩存的工作流程是怎樣的呢?我們從源碼層面來(lái)學(xué)習(xí)一下。

工作流程

一級(jí)緩存執(zhí)行的時(shí)序圖,如下圖所示。


image
源碼分析

接下來(lái)將對(duì)MyBatis查詢相關(guān)的核心類和一級(jí)緩存的源碼進(jìn)行走讀。這對(duì)后面學(xué)習(xí)二級(jí)緩存也有幫助。
SqlSession: 對(duì)外提供了用戶和數(shù)據(jù)庫(kù)之間交互需要的所有方法,隱藏了底層的細(xì)節(jié)。默認(rèn)實(shí)現(xiàn)類是DefaultSqlSession。

image

Executor: SqlSession向用戶提供操作數(shù)據(jù)庫(kù)的方法,但和數(shù)據(jù)庫(kù)操作有關(guān)的職責(zé)都會(huì)委托給Executor。

image

如下圖所示,Executor有若干個(gè)實(shí)現(xiàn)類,為Executor賦予了不同的能力,大家可以根據(jù)類名,自行學(xué)習(xí)每個(gè)類的基本作用。


image

在一級(jí)緩存的源碼分析中,主要學(xué)習(xí)BaseExecutor的內(nèi)部實(shí)現(xiàn)。
BaseExecutor: BaseExecutor是一個(gè)實(shí)現(xiàn)了Executor接口的抽象類,定義若干抽象方法,在執(zhí)行的時(shí)候,把具體的操作委托給子類進(jìn)行執(zhí)行。

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;

在一級(jí)緩存的介紹中提到對(duì)Local Cache的查詢和寫入是在Executor內(nèi)部完成的。在閱讀BaseExecutor的代碼后發(fā)現(xiàn)Local Cache是BaseExecutor內(nèi)部的一個(gè)成員變量,如下代碼所示。

public abstract class BaseExecutor implements Executor {
protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
protected PerpetualCache localCache;

Cache: MyBatis中的Cache接口,提供了和緩存相關(guān)的最基本的操作,如下圖所示。

image

有若干個(gè)實(shí)現(xiàn)類,使用裝飾器模式互相組裝,提供豐富的操控緩存的能力,部分實(shí)現(xiàn)類如下圖所示。


image

BaseExecutor成員變量之一的PerpetualCache,是對(duì)Cache接口最基本的實(shí)現(xiàn),其實(shí)現(xiàn)非常簡(jiǎn)單,內(nèi)部持有HashMap,對(duì)一級(jí)緩存的操作實(shí)則是對(duì)HashMap的操作。如下代碼所示。

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

在閱讀相關(guān)核心類代碼后,從源代碼層面對(duì)一級(jí)緩存工作中涉及到的相關(guān)代碼,出于篇幅的考慮,對(duì)源碼做適當(dāng)刪減,讀者朋友可以結(jié)合本文,后續(xù)進(jìn)行更詳細(xì)的學(xué)習(xí)。
為執(zhí)行和數(shù)據(jù)庫(kù)的交互,首先需要初始化SqlSession,通過(guò)DefaultSqlSessionFactory開(kāi)啟SqlSession:

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

在初始化SqlSesion時(shí),會(huì)使用Configuration類創(chuàng)建一個(gè)全新的Executor,作為DefaultSqlSession構(gòu)造函數(shù)的參數(shù),創(chuàng)建Executor代碼如下所示:

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);
    }
    // 尤其可以注意這里,如果二級(jí)緩存開(kāi)關(guān)開(kāi)啟的話,是使用CahingExecutor裝飾BaseExecutor的子類
    if (cacheEnabled) {
      executor = new CachingExecutor(executor);                      
    }
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
}

SqlSession創(chuàng)建完畢后,根據(jù)Statment的不同類型,會(huì)進(jìn)入SqlSession的不同方法中,如果是Select語(yǔ)句的話,最后會(huì)執(zhí)行到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把具體的查詢職責(zé)委托給了Executor。如果只開(kāi)啟了一級(jí)緩存的話,首先會(huì)進(jìn)入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);
}

在上述代碼中,會(huì)先根據(jù)傳入的參數(shù)生成CacheKey,進(jìn)入該方法查看CacheKey是如何生成的,代碼如下所示:

CacheKey cacheKey = new CacheKey();
cacheKey.update(ms.getId());
cacheKey.update(rowBounds.getOffset());
cacheKey.update(rowBounds.getLimit());
cacheKey.update(boundSql.getSql());
//后面是update了sql中帶的參數(shù)
cacheKey.update(value);

在上述的代碼中,將MappedStatement的Id、sql的offset、Sql的limit、Sql本身以及Sql中的參數(shù)傳入了CacheKey這個(gè)類,最終構(gòu)成CacheKey。以下是這個(gè)類的內(nèi)部結(jié)構(gòu):

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

首先是成員變量和構(gòu)造函數(shù),有一個(gè)初始的hachcode和乘數(shù),同時(shí)維護(hù)了一個(gè)內(nèi)部的updatelist。在CacheKey的update方法中,會(huì)進(jìn)行一個(gè)hashcode和checksum的計(jì)算,同時(shí)把傳入的參數(shù)添加進(jìn)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);
}

同時(shí)重寫了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中的元素一一對(duì)應(yīng)相等,那么就可以認(rèn)為是CacheKey相等。只要兩條SQL的下列五個(gè)值相同,即可以認(rèn)為是相同的SQL。

Statement Id + Offset + Limmit + Sql + Params

BaseExecutor的query方法繼續(xù)往下走,代碼如下所示:

list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
    // 這個(gè)主要是處理存儲(chǔ)過(guò)程用的。
    handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
    } else {
    list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

如果查不到的話,就從數(shù)據(jù)庫(kù)查,在queryFromDatabase中,會(huì)對(duì)localcache進(jìn)行寫入。
在query方法執(zhí)行的最后,會(huì)判斷一級(jí)緩存級(jí)別是否是STATEMENT級(jí)別,如果是的話,就清空緩存,這也就是STATEMENT級(jí)別的一級(jí)緩存無(wú)法共享localCache的原因。代碼如下所示:

if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        clearLocalCache();
}

在源碼分析的最后,我們確認(rèn)一下,如果是insert/delete/update方法,緩存就會(huì)刷新的原因。
SqlSession的insert方法和delete方法,都會(huì)統(tǒng)一走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執(zhí)行。BaseExecutor的執(zhí)行方法如下所示。

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

每次執(zhí)行update前都會(huì)清空l(shuí)ocalCache。

至此,一級(jí)緩存的工作流程講解以及源碼分析完畢。

總結(jié)

  1. MyBatis一級(jí)緩存的生命周期和SqlSession一致。
  2. MyBatis一級(jí)緩存內(nèi)部設(shè)計(jì)簡(jiǎn)單,只是一個(gè)沒(méi)有容量限定的HashMap,在緩存的功能性上有所欠缺。
  3. MyBatis的一級(jí)緩存最大范圍是SqlSession內(nèi)部,有多個(gè)SqlSession或者分布式的環(huán)境下,數(shù)據(jù)庫(kù)寫操作會(huì)引起臟數(shù)據(jù),建議設(shè)定緩存級(jí)別為Statement。

二級(jí)緩存

二級(jí)緩存介紹

在上文中提到的一級(jí)緩存中,其最大的共享范圍就是一個(gè)SqlSession內(nèi)部,如果多個(gè)SqlSession之間需要共享緩存,則需要使用到二級(jí)緩存。開(kāi)啟二級(jí)緩存后,會(huì)使用CachingExecutor裝飾Executor,進(jìn)入一級(jí)緩存的查詢流程前,先在CachingExecutor進(jìn)行二級(jí)緩存的查詢,具體的工作流程如下所示。


image

二級(jí)緩存開(kāi)啟后,同一個(gè)namespace下的所有操作語(yǔ)句,都影響著同一個(gè)Cache,即二級(jí)緩存被多個(gè)SqlSession共享,是一個(gè)全局的變量。
當(dāng)開(kāi)啟緩存后,數(shù)據(jù)的查詢執(zhí)行的流程就是 二級(jí)緩存 -> 一級(jí)緩存 -> 數(shù)據(jù)庫(kù)。

二級(jí)緩存配置

要正確的使用二級(jí)緩存,需完成如下配置的。

  1. 在MyBatis的配置文件中開(kāi)啟二級(jí)緩存。

    <setting name="cacheEnabled" value="true"/>
    
    
  2. 在MyBatis的映射XML中配置cache或者 cache-ref 。

cache標(biāo)簽用于聲明這個(gè)namespace使用二級(jí)緩存,并且可以自定義配置。

<cache/>

  • type:cache使用的類型,默認(rèn)是PerpetualCache,這在一級(jí)緩存中提到過(guò)。
  • eviction: 定義回收的策略,常見(jiàn)的有FIFO,LRU。
  • flushInterval: 配置一定時(shí)間自動(dòng)刷新緩存,單位是毫秒。
  • size: 最多緩存對(duì)象的個(gè)數(shù)。
  • readOnly: 是否只讀,若配置可讀寫,則需要對(duì)應(yīng)的實(shí)體類能夠序列化。
  • blocking: 若緩存中找不到對(duì)應(yīng)的key,是否會(huì)一直blocking,直到有對(duì)應(yīng)的數(shù)據(jù)進(jìn)入緩存。

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

<cache-ref namespace="mapper.StudentMapper"/>

二級(jí)緩存實(shí)驗(yàn)

接下來(lái)我們通過(guò)實(shí)驗(yàn),了解MyBatis二級(jí)緩存在使用上的一些特點(diǎn)。
在本實(shí)驗(yàn)中,id為1的學(xué)生名稱初始化為點(diǎn)點(diǎn)。

實(shí)驗(yàn)1

測(cè)試二級(jí)緩存效果,不提交事務(wù),sqlSession1查詢完數(shù)據(jù)后,sqlSession2相同的查詢是否會(huì)從緩存中獲取數(shù)據(jù)。

@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讀取數(shù)據(jù): " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2讀取數(shù)據(jù): " + studentMapper2.getStudentById(1));
}

執(zhí)行結(jié)果:


image

我們可以看到,當(dāng)sqlsession沒(méi)有調(diào)用commit()方法時(shí),二級(jí)緩存并沒(méi)有起到作用。

實(shí)驗(yàn)2

測(cè)試二級(jí)緩存效果,當(dāng)提交事務(wù)時(shí),sqlSession1查詢完數(shù)據(jù)后,sqlSession2相同的查詢是否會(huì)從緩存中獲取數(shù)據(jù)。

@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讀取數(shù)據(jù): " + studentMapper.getStudentById(1));
        sqlSession1.commit();
        System.out.println("studentMapper2讀取數(shù)據(jù): " + studentMapper2.getStudentById(1));
}

image

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

實(shí)驗(yàn)3

測(cè)試update操作是否會(huì)刷新該namespace下的二級(jí)緩存。

@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讀取數(shù)據(jù): " + studentMapper.getStudentById(1));
        sqlSession1.commit();
        System.out.println("studentMapper2讀取數(shù)據(jù): " + studentMapper2.getStudentById(1));

        studentMapper3.updateStudentName("方方",1);
        sqlSession3.commit();
        System.out.println("studentMapper2讀取數(shù)據(jù): " + studentMapper2.getStudentById(1));
}

image

我們可以看到,在sqlSession3更新數(shù)據(jù)庫(kù),并提交事務(wù)后,sqlsession2的StudentMapper namespace下的查詢走了數(shù)據(jù)庫(kù),沒(méi)有走Cache。

實(shí)驗(yàn)4

驗(yàn)證MyBatis的二級(jí)緩存不適應(yīng)用于映射文件中存在多表查詢的情況。
通常我們會(huì)為每個(gè)單表創(chuàng)建單獨(dú)的映射文件,由于MyBatis的二級(jí)緩存是基于namespace的,多表查詢語(yǔ)句所在的namspace無(wú)法感應(yīng)到其他namespace中的語(yǔ)句對(duì)多表查詢中涉及的表進(jìn)行的修改,引發(fā)臟數(shù)據(jù)問(wèn)題。

@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讀取數(shù)據(jù): " + studentMapper.getStudentByIdWithClassInfo(1));
        sqlSession1.close();
        System.out.println("studentMapper2讀取數(shù)據(jù): " + studentMapper2.getStudentByIdWithClassInfo(1));

        classMapper.updateClassName("特色一班",1);
        sqlSession3.commit();
        System.out.println("studentMapper2讀取數(shù)據(jù): " + studentMapper2.getStudentByIdWithClassInfo(1));
}

執(zhí)行結(jié)果:


image

在這個(gè)實(shí)驗(yàn)中,我們引入了兩張新的表,一張class,一張classroom。class中保存了班級(jí)的id和班級(jí)名,classroom中保存了班級(jí)id和學(xué)生id。我們?cè)赟tudentMapper中增加了一個(gè)查詢方法getStudentByIdWithClassInfo,用于查詢學(xué)生所在的班級(jí),涉及到多表查詢。在ClassMapper中添加了updateClassName,根據(jù)班級(jí)id更新班級(jí)名的操作。
當(dāng)sqlsession1的studentmapper查詢數(shù)據(jù)后,二級(jí)緩存生效。保存在StudentMapper的namespace下的cache中。當(dāng)sqlSession3的classMapper的updateClassName方法對(duì)class表進(jìn)行更新時(shí),updateClassName不屬于StudentMapper的namespace,所以StudentMapper下的cache沒(méi)有感應(yīng)到變化,沒(méi)有刷新緩存。當(dāng)StudentMapper中同樣的查詢?cè)俅伟l(fā)起時(shí),從緩存中讀取了臟數(shù)據(jù)。

實(shí)驗(yàn)5

為了解決實(shí)驗(yàn)4的問(wèn)題呢,可以使用Cache ref,讓ClassMapper引用StudenMapper命名空間,這樣兩個(gè)映射文件對(duì)應(yīng)的Sql操作都使用的是同一塊緩存了。
執(zhí)行結(jié)果:


image

不過(guò)這樣做的后果是,緩存的粒度變粗了,多個(gè)Mapper namespace下的所有操作都會(huì)對(duì)緩存使用造成影響。

二級(jí)緩存源碼分析

MyBatis二級(jí)緩存的工作流程和前文提到的一級(jí)緩存類似,只是在一級(jí)緩存處理前,用CachingExecutor裝飾了BaseExecutor的子類,在委托具體職責(zé)給delegate之前,實(shí)現(xiàn)了二級(jí)緩存的查詢和寫入功能,具體類關(guān)系圖如下圖所示。


image
源碼分析

源碼分析從CachingExecutor的query方法展開(kāi),源代碼走讀過(guò)程中涉及到的知識(shí)點(diǎn)較多,不能一一詳細(xì)講解,讀者朋友可以自行查詢相關(guān)資料來(lái)學(xué)習(xí)。
CachingExecutor的query方法,首先會(huì)從MappedStatement中獲得在配置初始化時(shí)賦予的Cache。

Cache cache = ms.getCache();

本質(zhì)上是裝飾器模式的使用,具體的裝飾鏈?zhǔn)?/p>

SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache。

image

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

  • SynchronizedCache: 同步Cache,實(shí)現(xiàn)比較簡(jiǎn)單,直接使用synchronized修飾方法。
  • LoggingCache: 日志功能,裝飾類,用于記錄緩存的命中率,如果開(kāi)啟了DEBUG模式,則會(huì)輸出命中率日志。
  • SerializedCache: 序列化功能,將值序列化后存到緩存中。該功能用于緩存返回一份實(shí)例的Copy,用于保存線程安全。
  • LruCache: 采用了Lru算法的Cache實(shí)現(xiàn),移除最近最少使用的key/value。
  • PerpetualCache: 作為為最基礎(chǔ)的緩存類,底層實(shí)現(xiàn)比較簡(jiǎn)單,直接使用了HashMap。

然后是判斷是否需要刷新緩存,代碼如下所示:

flushCacheIfRequired(ms);

在默認(rèn)的設(shè)置中SELECT語(yǔ)句不會(huì)刷新緩存,insert/update/delte會(huì)刷新緩存。進(jìn)入該方法。代碼如下所示:

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

MyBatis的CachingExecutor持有了TransactionalCacheManager,即上述代碼中的tcm。
TransactionalCacheManager中持有了一個(gè)Map,代碼如下所示:

private Map<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();

這個(gè)Map保存了Cache和用TransactionalCache包裝后的Cache的映射關(guān)系。
TransactionalCache實(shí)現(xiàn)了Cache接口,CachingExecutor會(huì)默認(rèn)使用他包裝初始生成的Cache,作用是如果事務(wù)提交,對(duì)緩存的操作才會(huì)生效,如果事務(wù)回滾或者不提交事務(wù),則不對(duì)緩存產(chǎn)生影響。
在TransactionalCache的clear,有以下兩句。清空了需要在提交時(shí)加入緩存的列表,同時(shí)設(shè)定提交時(shí)清空緩存,代碼如下所示:

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

CachingExecutor繼續(xù)往下走,ensureNoOutParams主要是用來(lái)處理存儲(chǔ)過(guò)程的,暫時(shí)不用考慮。

if (ms.isUseCache() && resultHandler == null) {
    ensureNoOutParams(ms, parameterObject, boundSql);

之后會(huì)嘗試從tcm中獲取緩存的列表。

List<E> list = (List<E>) tcm.getObject(cache, key);

在getObject方法中,會(huì)把獲取值的職責(zé)一路傳遞,最終到PerpetualCache。如果沒(méi)有查到,會(huì)把key加入Miss集合,這個(gè)主要是為了統(tǒng)計(jì)命中率。

Object object = delegate.getObject(key);
if (object == null) {
    entriesMissedInCache.add(key);
}

CachingExecutor繼續(xù)往下走,如果查詢到數(shù)據(jù),則調(diào)用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方法也不是直接操作緩存,只是在把這次的數(shù)據(jù)和key放入待提交的Map中。

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

從以上的代碼分析中,我們可以明白,如果不調(diào)用commit方法的話,由于TranscationalCache的作用,并不會(huì)對(duì)二級(jí)緩存造成直接的影響。因此我們看看Sqlsession的commit方法中做了什么。代碼如下所示:

@Override
public void commit(boolean force) {
    try {
      executor.commit(isCommitOrRollbackRequired(force));

因?yàn)槲覀兪褂昧薈achingExecutor,首先會(huì)進(jìn)入CachingExecutor實(shí)現(xiàn)的commit方法。

@Override
public void commit(boolean required) throws SQLException {
    delegate.commit(required);
    tcm.commit();
}

會(huì)把具體commit的職責(zé)委托給包裝的Executor。主要是看下tcm.commit(),tcm最終又會(huì)調(diào)用到TrancationalCache。

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

看到這里的clearOnCommit就想起剛才TrancationalCache的clear方法設(shè)置的標(biāo)志位,真正的清理Cache是放到這里來(lái)進(jìn)行的。具體清理的職責(zé)委托給了包裝的Cache類。之后進(jìn)入flushPendingEntries方法。代碼如下所示:

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

在flushPendingEntries中,將待提交的Map進(jìn)行循環(huán)處理,委托給包裝的Cache類,進(jìn)行putObject的操作。
后續(xù)的查詢操作會(huì)重復(fù)執(zhí)行這套流程。如果是insert|update|delete的話,會(huì)統(tǒng)一進(jìn)入CachingExecutor的update方法,其中調(diào)用了這個(gè)函數(shù),代碼如下所示:

private void flushCacheIfRequired(MappedStatement ms)

在二級(jí)緩存執(zhí)行流程后就會(huì)進(jìn)入一級(jí)緩存的執(zhí)行流程,因此不再贅述。

總結(jié)

  1. MyBatis的二級(jí)緩存相對(duì)于一級(jí)緩存來(lái)說(shuō),實(shí)現(xiàn)了SqlSession之間緩存數(shù)據(jù)的共享,同時(shí)粒度更加的細(xì),能夠到namespace級(jí)別,通過(guò)Cache接口實(shí)現(xiàn)類不同的組合,對(duì)Cache的可控性也更強(qiáng)。
  2. MyBatis在多表查詢時(shí),極大可能會(huì)出現(xiàn)臟數(shù)據(jù),有設(shè)計(jì)上的缺陷,安全使用二級(jí)緩存的條件比較苛刻。
  3. 在分布式環(huán)境下,由于默認(rèn)的MyBatis Cache實(shí)現(xiàn)都是基于本地的,分布式環(huán)境下必然會(huì)出現(xiàn)讀取到臟數(shù)據(jù),需要使用集中式緩存將MyBatis的Cache接口實(shí)現(xiàn),有一定的開(kāi)發(fā)成本,直接使用Redis,Memcached等分布式緩存可能成本更低,安全性也更高。

全文總結(jié)

本文對(duì)介紹了MyBatis一二級(jí)緩存的基本概念,并從應(yīng)用及源碼的角度對(duì)MyBatis的緩存機(jī)制進(jìn)行了分析。最后對(duì)MyBatis緩存機(jī)制做了一定的總結(jié),個(gè)人建議MyBatis緩存特性在生產(chǎn)環(huán)境中進(jìn)行關(guān)閉,單純作為一個(gè)ORM框架使用可能更為合適。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容