原文: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ò)程如下圖所示。

每個(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)系圖如下圖所示。

一級(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é)果:

我們可以看到,只有第一次真正查詢了數(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é)果:

我們可以看到,在修改操作后執(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));
}

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í)序圖,如下圖所示。

源碼分析
接下來(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。

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

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

在一級(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)的最基本的操作,如下圖所示。

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

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é)
- MyBatis一級(jí)緩存的生命周期和SqlSession一致。
- MyBatis一級(jí)緩存內(nèi)部設(shè)計(jì)簡(jiǎn)單,只是一個(gè)沒(méi)有容量限定的HashMap,在緩存的功能性上有所欠缺。
- 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í)緩存的查詢,具體的工作流程如下所示。

二級(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í)緩存,需完成如下配置的。
-
在MyBatis的配置文件中開(kāi)啟二級(jí)緩存。
<setting name="cacheEnabled" value="true"/> 在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é)果:

我們可以看到,當(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));
}

從圖上可知,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));
}

我們可以看到,在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é)果:

在這個(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é)果:

不過(guò)這樣做的后果是,緩存的粒度變粗了,多個(gè)Mapper namespace下的所有操作都會(huì)對(duì)緩存使用造成影響。
二級(jí)緩存源碼分析
MyBatis二級(jí)緩存的工作流程和前文提到的一級(jí)緩存類似,只是在一級(jí)緩存處理前,用CachingExecutor裝飾了BaseExecutor的子類,在委托具體職責(zé)給delegate之前,實(shí)現(xiàn)了二級(jí)緩存的查詢和寫入功能,具體類關(guān)系圖如下圖所示。

源碼分析
源碼分析從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。

以下是具體這些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é)
- 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)。
- MyBatis在多表查詢時(shí),極大可能會(huì)出現(xiàn)臟數(shù)據(jù),有設(shè)計(jì)上的缺陷,安全使用二級(jí)緩存的條件比較苛刻。
- 在分布式環(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框架使用可能更為合適。