手把手帶你閱讀Mybatis原始碼(三)快取篇
前言
大家好,這一篇文章是MyBatis系列的最後一篇文章,前面兩篇文章:手把手帶你閱讀Mybatis原始碼(一)構造篇 和 手把手帶你閱讀Mybatis原始碼(二)執行篇,主要說明了MyBatis是如何將我們的xml配置檔案構建為其內部的Configuration物件和MappedStatement物件的,然後在第二篇我們說了構建完成後MyBatis是如何一步一步地執行我們的SQL語句並且對結果集進行封裝的。
那麼這篇作為MyBatis系列的最後一篇,自然是要來聊聊MyBatis中的一個不可忽視的功能,一級快取和二級快取。
何謂快取?
雖然這篇說的是MyBatis的快取,但是我希望正在學習計算機的小夥伴即使還沒有使用過MyBatis框架也能看明白今天這篇文章。
快取是什麼?我來說說個人的理解,最後再上比較官方的概念。
快取(Cache),顧名思義,有臨時儲存的意思。計算機中的快取,我們可以直接理解為,儲存在記憶體中的資料的容器,這與物理儲存是有差別的,由於記憶體的讀寫速度比物理儲存高出幾個數量級,所以程式直接從記憶體中取資料和從物理硬碟中取資料的效率是不同的,所以有一些經常需要讀取的資料,設計師們通常會將其放在快取中,以便於程式對其進行讀取。
但是,快取是有代價的,剛才我們說過,快取就是在記憶體中的資料的容器,一條64G的記憶體條,通常可以買3-4塊1T-2T的機械硬碟了,所以快取不能無節制地使用,這樣成本會劇增,所以一般快取中的資料都是需要頻繁查詢,但是又不常修改的資料。
而在一般業務中,查詢通常會經過如下步驟。
讀操作 --> 查詢快取中已經存在資料 -->如果不存在則查詢資料庫,如果存在則直接查詢快取-->資料庫查詢返回資料的同時,寫入快取中。
寫操作 --> 清空快取資料 -->寫入資料庫
快取流程
比較官方的概念:
☞ 快取就是資料交換的緩衝區(稱作:Cache),當某一硬體要讀取資料時,會首先從快取彙總查詢資料,有則直接執行,不存在時從記憶體中獲取。由於快取的資料比記憶體快的多,所以快取的作用就是幫助硬體更快的執行。
☞ 快取往往使用的是RAM(斷電既掉的非永久儲存),所以在用完後還是會把檔案送到硬碟等儲存器中永久儲存。電腦中最大快取就是記憶體條,硬碟上也有16M或者32M的快取。
☞ 快取記憶體是用來協調CPU與主存之間存取速度的差異而設定的。一般CPU工作速度高,但記憶體的工作速度相對較低,為了解決這個問題,通常使用快取記憶體,快取記憶體的存取速度介於CPU與主存之間。系統將一些CPU在最近幾個時間段經常訪問的內容存在快取記憶體,這樣就在一定程度上緩解了由於主存速度低造成的CPU“停工待料”的情況。
☞ 快取就是把一些外存上的資料儲存在記憶體上而已,為什麼儲存在記憶體上,我們執行的所有程式裡面的變數都是存放在記憶體中的,所以如果想將值放入記憶體上,可以通過變數的方式儲存。在JAVA中一些快取一般都是通過Map集合來實現的。
MyBatis的快取
在說MyBatis的快取之前,先了解一下Java中的快取一般都是怎麼實現的,我們通常會使用Java中的Map,來實現快取,所以在之後的快取這個概念,就可以把它直接理解為一個Map,存的就是鍵值對。
一級快取簡介
MyBatis中的一級快取,是預設開啟且無法關閉的,一級快取預設的作用域是一個SqlSession,解釋一下,就是當SqlSession被構建了之後,快取就存在了,只要這個SqlSession不關閉,這個快取就會一直存在,換言之,只要SqlSession不關閉,那麼這個SqlSession處理的同一條SQL就不會被呼叫兩次,只有當會話結束了之後,這個快取才會一併被釋放。
雖說我們不能關閉一級快取,但是作用域是可以修改的,比如可以修改為某個Mapper。
一級快取的生命週期:
1、如果SqlSession呼叫了close()方法,會釋放掉一級快取PerpetualCache物件,一級快取將不可用。
2、如果SqlSession呼叫了clearCache(),會清空PerpetualCache物件中的資料,但是該物件仍可使用。
3、SqlSession中執行了任何一個update操作(update()、delete()、insert()) ,都會清空PerpetualCache物件的資料,但是該物件可以繼續使用。
節選自:https://www.cnblogs.com/happyflyingpig/p/7739749.html
MyBatis一級快取簡單示意圖
二級快取簡介
MyBatis的二級快取是預設關閉的,如果要開啟有兩種方式:
1.在mybatis-config.xml中加入如下配置片段
<!-- 全域性配置引數,需要時再設定 -->
<settings>
<!-- 開啟二級快取 預設值為true -->
<setting name="cacheEnabled" value="true"/>
</settings>
2.在mapper.xml中開啟
<!--開啟本mapper的namespace下的二級快取-->
<!--
eviction:代表的是快取回收策略,目前MyBatis提供以下策略。
(1) LRU,最近最少使用的,一處最長時間不用的物件
(2) FIFO,先進先出,按物件進入快取的順序來移除他們
(3) SOFT,軟引用,移除基於垃圾回收器狀態和軟引用規則的物件
(4) WEAK,弱引用,更積極的移除基於垃圾收集器狀態和弱引用規則的物件。
這裡採用的是LRU, 移除最長時間不用的對形象
flushInterval:重新整理間隔時間,單位為毫秒,如果你不配置它,那麼當
SQL被執行的時候才會去重新整理快取。
size:引用數目,一個正整數,代表快取最多可以儲存多少個物件,不宜設定過大。設定過大會導致記憶體溢位。
這裡配置的是1024個物件
readOnly:只讀,意味著快取資料只能讀取而不能修改,這樣設定的好處是我們可以快速讀取快取,缺點是我們沒有
辦法修改快取,他的預設值是false,不允許我們修改
-->
<cache eviction="回收策略" type="快取類"/>
二級快取的作用域與一級快取不同,一級快取的作用域是一個SqlSession,但是二級快取的作用域是一個namespace,什麼意思呢,你可以把它理解為一個mapper,在這個mapper中操作的所有SqlSession都可以共享這個二級快取。但是假設有兩條相同的SQL,寫在不同的namespace下,那這個SQL就會被執行兩次,並且產生兩份value相同的快取。
MyBatis快取的執行流程
依舊是用前兩篇的測試用例,我們從原始碼的角度看看快取是如何執行的。
public static void main(String[] args) throws Exception {
String resource = "mybatis.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
SqlSession sqlSession = sqlSessionFactory.openSession();
//從呼叫者角度來講 與資料庫打交道的物件 SqlSession
DemoMapper mapper = sqlSession.getMapper(DemoMapper.class);
Map<String,Object> map = new HashMap<>();
map.put("id","2121");
//執行這個方法實際上會走到invoke
System.out.println(mapper.selectAll(map));
sqlSession.close();
sqlSession.commit();
}
這裡會執行到query()方法:
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
//二級快取的Cache,通過MappedStatement獲取
Cache cache = ms.getCache();
if (cache != null) {
//是否需要重新整理快取
//在<select>標籤中也可以配置flushCache屬性來設定是否查詢前要重新整理快取,預設增刪改重新整理快取查詢不重新整理
flushCacheIfRequired(ms);
//判斷這個mapper是否開啟了二級快取
if (ms.isUseCache() && resultHandler == null) {
//不管
ensureNoOutParams(ms, boundSql);
@SuppressWarnings("unchecked")
//先從快取拿
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
//如果快取等於空,那麼查詢一級快取
list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
//查詢完畢後將資料放入二級快取
tcm.putObject(cache, key, list); // issue #578 and #116
}
//返回
return list;
}
}
//如果二級快取為null,那麼直接查詢一級快取
return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
可以看到首先MyBatis在查詢資料時會先看看這個mapper是否開啟了二級快取,如果開啟了,會先查詢二級快取,如果快取中存在我們需要的資料,那麼直接就從快取返回資料,如果不存在,則繼續往下走查詢邏輯。
接著往下走,如果二級快取不存在,那麼就直接查詢資料了嗎?答案是否定的,二級快取如果不存在,MyBatis會再查詢一次一級快取,接著往下看。
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++;
//查詢一級快取(localCache)
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的邏輯
* //先往快取中put一個佔位符
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
localCache.removeObject(key);
}
//往一級快取中put真實資料
localCache.putObject(key, list);
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
*/
}
} 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;
}
一級快取和二級快取的查詢邏輯其實差不多,都是先查詢快取,如果沒有則進行下一步查詢,只不過一級快取中如果沒有結果,那麼就直接查詢資料庫,然後回寫一級快取。
講到這裡其實一級快取和二級快取的執行流程就說完了,快取的邏輯其實都差不多,MyBatis的快取是先查詢一級快取再查詢二級快取。
但是文章到這裡並沒有結束,還有一些快取相關的問題可以聊。
快取事務問題
不知道這個問題大家有沒有想過,假設有這麼一個場景,這裡用二級快取舉例,因為二級快取是跨事務的。
假設我們在查詢之前開啟了事務,並且進行資料庫操作:
1.往資料庫中插入一條資料(INSERT)
2.在同一個事務內查詢資料(SELECT)
3.提交事務(COMMIT)
4.提交事務失敗(ROLLBACK)
我們來分析一下這個場景,首先SqlSession先執行了一個INSERT操作,很顯然,在我們剛才分析的邏輯基礎上,此時快取一定會被清空,然後在同一個事務下查詢資料,資料又從資料庫中被載入到了快取中,此時提交事務,然後事務提交失敗了。
考慮一下此時會出現什麼情況,相信已經有人想到了,事務提交失敗之後,事務會進行回滾,那麼執行INSERT插入的這條資料就被回滾了,但是我們在插入之後進行了一次查詢,這個資料已經放到了快取中,下一次查詢必然是直接查詢快取而不會再去查詢資料庫了,可是此時快取和資料庫之間已經存在了資料不一致的問題。
問題的根本原因就在於,資料庫提交事務失敗了可以進行回滾,但是快取不能進行回滾。
我們來看看MyBatis是如何解決這個問題的。
TransactionalCacheManager
這個類是MyBatis用於快取事務管理的類,我們可以看看其資料結構。
public class TransactionalCacheManager {
//事務快取
private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
public void clear(Cache cache) {
getTransactionalCache(cache).clear();
}
public Object getObject(Cache cache, CacheKey key) {
return getTransactionalCache(cache).getObject(key);
}
public void putObject(Cache cache, CacheKey key, Object value) {
getTransactionalCache(cache).putObject(key, value);
}
public void commit() {
for (TransactionalCache txCache : transactionalCaches.values()) {
txCache.commit();
}
}
public void rollback() {
for (TransactionalCache txCache : transactionalCaches.values()) {
txCache.rollback();
}
}
private TransactionalCache getTransactionalCache(Cache cache) {
return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);
}
}
TransactionalCacheManager中封裝了一個Map,用於將事務快取物件快取起來,這個Map的Key是我們的二級快取物件,而Value是一個叫做TransactionalCache,顧名思義,這個快取就是事務快取,我們來看看其內部的實現。
public class TransactionalCache implements Cache {
private static final Log log = LogFactory.getLog(TransactionalCache.class);
//真實快取物件
private final Cache delegate;
//是否需要清空提交空間的標識
private boolean clearOnCommit;
//所有待提交的快取
private final Map<Object, Object> entriesToAddOnCommit;
//未命中的快取集合,防止擊穿快取,並且如果查詢到的資料為null,說明要通過資料庫查詢,有可能存在資料不一致,都記錄到這個地方
private final Set<Object> entriesMissedInCache;
public TransactionalCache(Cache delegate) {
this.delegate = delegate;
this.clearOnCommit = false;
this.entriesToAddOnCommit = new HashMap<>();
this.entriesMissedInCache = new HashSet<>();
}
@Override
public String getId() {
return delegate.getId();
}
@Override
public int getSize() {
return delegate.getSize();
}
@Override
public Object getObject(Object key) {
// issue #116
Object object = delegate.getObject(key);
if (object == null) {
//如果取出的是空,那麼放到未命中快取,並且在查詢資料庫之後putObject中將本應該放到真實快取中的鍵值對放到待提交事務快取
entriesMissedInCache.add(key);
}
//如果不為空
// issue #146
//檢視快取清空標識是否為false,如果事務提交了就為true,事務提交了會更新快取,所以返回null。
if (clearOnCommit) {
return null;
} else {
//如果事務沒有提交,那麼返回原先快取中的資料,
return object;
}
}
@Override
public void putObject(Object key, Object object) {
//如果返回的資料為null,那麼有可能到資料庫查詢,查詢到的資料先放置到待提交事務的快取中
//本來應該put到快取中,現在put到待提交事務的快取中去。
entriesToAddOnCommit.put(key, object);
}
@Override
public Object removeObject(Object key) {
return null;
}
@Override
public void clear() {
//如果事務提交了,那麼將清空快取提交標識設定為true
clearOnCommit = true;
//清空entriesToAddOnCommit
entriesToAddOnCommit.clear();
}
public void commit() {
if (clearOnCommit) {
//如果為true,那麼就清空快取。
delegate.clear();
}
//把本地快取重新整理到真實快取。
flushPendingEntries();
//然後將所有值復位。
reset();
}
public void rollback() {
//事務回滾
unlockMissedEntries();
reset();
}
private void reset() {
//復位操作。
clearOnCommit = false;
entriesToAddOnCommit.clear();
entriesMissedInCache.clear();
}
private void flushPendingEntries() {
//遍歷事務管理器中待提交的快取
for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
//寫入到真實的快取中。
delegate.putObject(entry.getKey(), entry.getValue());
}
for (Object entry : entriesMissedInCache) {
//把未命中的一起put
if (!entriesToAddOnCommit.containsKey(entry)) {
delegate.putObject(entry, null);
}
}
}
private void unlockMissedEntries() {
for (Object entry : entriesMissedInCache) {
//清空真實快取區中未命中的快取。
try {
delegate.removeObject(entry);
} catch (Exception e) {
log.warn("Unexpected exception while notifiying a rollback to the cache adapter."
+ "Consider upgrading your cache adapter to the latest version. Cause: " + e);
}
}
}
}
在TransactionalCache中有一個真實快取物件Cache,這個真實快取物件就是我們真正的二級快取,還有一個 entriesToAddOnCommit,這個Map物件中存放的是所有待提交事務的快取。
我們在二級快取執行的程式碼中,看到在快取中get或者put結果時,都是叫tcm的物件呼叫了getObject()方法和putObject()方法,這個物件實際上就是TransactionalCacheManager的實體物件,而這個物件實際上是呼叫了TransactionalCache的方法,我們來看看這兩個方法是如何實現的。
@Override
public Object getObject(Object key) {
// issue #116
Object object = delegate.getObject(key);
if (object == null) {
//如果取出的是空,那麼放到未命中快取,並且在查詢資料庫之後putObject中將本應該放到真實快取中的鍵值對放到待提交事務快取
entriesMissedInCache.add(key);
}
//如果不為空
// issue #146
//檢視快取清空標識是否為false,如果事務提交了就為true,事務提交了會更新快取,所以返回null。
if (clearOnCommit) {
return null;
} else {
//如果事務沒有提交,那麼返回原先快取中的資料,
return object;
}
}
@Override
public void putObject(Object key, Object object) {
//如果返回的資料為null,那麼有可能到資料庫查詢,查詢到的資料先放置到待提交事務的快取中
//本來應該put到快取中,現在put到待提交事務的快取中去。
entriesToAddOnCommit.put(key, object);
}
在getObject()方法中存在兩個分支:
如果發現快取中取出的資料為null,那麼會把這個key放到entriesMissedInCache中,這個物件的主要作用就是將我們未命中的key全都儲存下來,防止快取被擊穿,並且當我們在快取中無法查詢到資料,那麼就有可能到一級快取和資料庫中查詢,那麼查詢過後會呼叫putObject()方法,這個方法本應該將我們查詢到的資料put到真是快取中,但是現在由於存在事務,所以暫時先放到entriesToAddOnCommit中。
如果發現快取中取出的資料不為null,那麼會檢視事務提交標識(clearOnCommit)是否為true,如果為true,代表事務已經提交了,之後快取會被清空,所以返回null,如果為false,那麼由於事務還沒有被提交,所以返回當前快取中存的資料。
那麼當事務提交成功或提交失敗,又會是什麼狀況呢?不妨看看commit和rollback方法。
public void commit() {
if (clearOnCommit) {
//如果為true,那麼就清空快取。
delegate.clear();
}
//把本地快取重新整理到真實快取。
flushPendingEntries();
//然後將所有值復位。
reset();
}
public void rollback() {
//事務回滾
unlockMissedEntries();
reset();
}
先分析事務提交成功的情況,如果事務正常提交了,那麼會有這麼幾步操作:
-
清空真實快取。
-
將本地快取(未提交的事務快取 entriesToAddOnCommit)重新整理到真實快取。
-
將所有值復位。
我們來看看程式碼是如何實現的:
private void flushPendingEntries() {
//遍歷事務管理器中待提交的快取
for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
//寫入到真實的快取中。
delegate.putObject(entry.getKey(), entry.getValue());
}
for (Object entry : entriesMissedInCache) {
//把未命中的一起put
if (!entriesToAddOnCommit.containsKey(entry)) {
delegate.putObject(entry, null);
}
}
}
private void reset() {
//復位操作。
clearOnCommit = false;
entriesToAddOnCommit.clear();
entriesMissedInCache.clear();
}
public void clear() {
//如果事務提交了,那麼將清空快取提交標識設定為true
clearOnCommit = true;
//清空事務提交快取
entriesToAddOnCommit.clear();
}
清空真實快取就不說了,就是Map呼叫clear方法,清空所有的鍵值對。
將未提交事務快取重新整理到真實快取,首先會遍歷entriesToAddOnCommit,然後呼叫真實快取的putObject方法,將entriesToAddOnCommit中的鍵值對put到真實快取中,這步完成後,還會將未命中快取中的資料一起put進去,值設定為null。
最後進行復位,將提交事務標識設為false,未命中快取、未提交事務快取中的所有資料全都清空。
如果事務沒有正常提交,那麼就會發生回滾,再來看看回滾是什麼流程:
-
清空真實快取中未命中的快取。
-
將所有值復位
public void rollback() {
//事務回滾
unlockMissedEntries();
reset();
}
private void unlockMissedEntries() {
for (Object entry : entriesMissedInCache) {
//清空真實快取區中未命中的快取。
try {
delegate.removeObject(entry);
} catch (Exception e) {
log.warn("Unexpected exception while notifiying a rollback to the cache adapter."
+ "Consider upgrading your cache adapter to the latest version. Cause: " + e);
}
}
}
由於凡是在快取中未命中的key,都會被記錄到entriesMissedInCache這個快取中,所以這個快取中包含了所有查詢資料庫的key,所以最終只需要在真實快取中把這部分key和對應的value給刪除即可。
快取事務總結
簡而言之,快取事務的控制主要是通過TransactionalCacheManager控制TransactionCache完成的,關鍵就在於TransactionCache中的entriesToAddCommit和entriesMissedInCache這兩個物件,entriesToAddCommit在事務開啟到提交期間作為真實快取的替代品,將從資料庫中查詢到的資料先放到這個Map中,待事務提交後,再將這個物件中的資料重新整理到真實快取中,如果事務提交失敗了,則清空這個快取中的資料即可,並不會影響到真實的快取。
entriesMissedInCache主要是用來儲存在查詢過程中在快取中沒有命中的key,由於沒有命中,說明需要到資料庫中查詢,那麼查詢過後會儲存到entriesToAddCommit中,那麼假設在事務提交過程中失敗了,而此時entriesToAddCommit的資料又都重新整理到快取中了,那麼此時呼叫rollback就會通過entriesMissedInCache中儲存的key,來清理真實快取,這樣就可以保證在事務中快取資料與資料庫的資料保持一致。
快取事務
一些使用快取的經驗
二級快取不能存在一直增多的資料
由於二級快取的影響範圍不是SqlSession而是namespace,所以二級快取會在你的應用啟動時一直存在直到應用關閉,所以二級快取中不能存在隨著時間資料量越來越大的資料,這樣有可能會造成記憶體空間被佔滿。
二級快取有可能存在髒讀的問題(可避免)
由於二級快取的作用域為namespace,那麼就可以假設這麼一個場景,有兩個namespace操作一張表,第一個namespace查詢該表並回寫到記憶體中,第二個namespace往表中插一條資料,那麼第一個namespace的二級快取是不會清空這個快取的內容的,在下一次查詢中,還會通過快取去查詢,這樣會造成資料的不一致。
所以當專案裡有多個名稱空間操作同一張表的時候,最好不要用二級快取,或者使用二級快取時避免用兩個namespace操作一張表。
Spring整合MyBatis快取失效問題
一級快取的作用域是SqlSession,而使用者可以自定義SqlSession什麼時候出現什麼時候銷燬,在這段期間一級快取都是存在的。
當使用者呼叫close()方法之後,就會銷燬一級快取。
但是,我們在和Spring整合之後,Spring幫我們跳過了SqlSessionFactory這一步,我們可以直接呼叫Mapper,導致在操作完資料庫之後,Spring就將SqlSession就銷燬了,一級快取就隨之銷燬了,所以一級快取就失效了。
那麼怎麼能讓快取生效呢?
-
開啟事務,因為一旦開啟事務,Spring就不會在執行完SQL之後就銷燬SqlSession,因為SqlSession一旦關閉,事務就沒了,一旦我們開啟事務,在事務期間內,快取會一直存在。
-
使用二級快取。
結語
Hello wo