前言
大家好,這一篇文章是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 world.