深入Guava Cache的refresh和expire重新整理機制
Guava Cache是本地快取的不二之選,用起來真不錯呵,可是你真的知道怎麼使用才能滿足需求?今天我們深入探討一下Expire和Refresh。(廢話少說)
一、思考和猜想
首先看一下三種基於時間的清理或重新整理快取資料的方式:
expireAfterAccess: 當快取項在指定的時間段內沒有被讀或寫就會被回收。
expireAfterWrite:當快取項在指定的時間段內沒有更新就會被回收。
refreshAfterWrite:當快取項上一次更新操作之後的多久會被重新整理。
考慮到時效性,我們可以使用expireAfterWrite,使每次更新之後的指定時間讓快取失效,然後重新載入快取。guava cache會嚴格限制只有1個載入操作,這樣會很好地防止快取失效的瞬間大量請求穿透到後端引起雪崩效應。
然而,通過分析原始碼,guava cache在限制只有1個載入操作時進行加鎖,其他請求必須阻塞等待這個載入操作完成;而且,在載入完成之後,其他請求的執行緒會逐一獲得鎖,去判斷是否已被載入完成,每個執行緒必須輪流地走一個“”獲得鎖,獲得值,釋放鎖“”的過程,這樣效能會有一些損耗。這裡由於我們計劃本地快取1秒,所以頻繁的過期和載入,鎖等待等過程會讓效能有較大的損耗。
因此我們考慮使用refreshAfterWrite。refreshAfterWrite的特點是,在refresh的過程中,嚴格限制只有1個重新載入操作,而其他查詢先返回舊值,這樣有效地可以減少等待和鎖爭用,所以refreshAfterWrite會比expireAfterWrite效能好。但是它也有一個缺點,因為到達指定時間後,它不能嚴格保證所有的查詢都獲取到新值。瞭解過guava cache的定時失效(或重新整理)原來的同學都知道,guava cache並沒使用額外的執行緒去做定時清理和載入的功能,而是依賴於查詢請求。在查詢的時候去比對上次更新的時間,如超過指定時間則進行載入或重新整理。所以,如果使用refreshAfterWrite,在吞吐量很低的情況下,如很長一段時間內沒有查詢之後,發生的查詢有可能會得到一箇舊值(這個舊值可能來自於很長時間之前),這將會引發問題。
可以看出refreshAfterWrite和expireAfterWrite兩種方式各有優缺點,各有使用場景。那麼能否在refreshAfterWrite和expireAfterWrite找到一個折中?比如說控制快取每1s進行refresh,如果超過2s沒有訪問,那麼則讓快取失效,下次訪問時不會得到舊值,而是必須得待新值載入。由於guava官方文件沒有給出一個詳細的解釋,查閱一些網上資料也沒有得到答案,因此只能對原始碼進行分析,尋找答案。經過分析,當同時使用兩者的時候,可以達到預想的效果,這真是一個好訊息吶!
二、原始碼分析
通過追蹤LoadingCache的get方法原始碼,發現最終會呼叫以下核心方法,下面貼出原始碼:
com.google.common.cache.LocalCache.Segment.get方法:
這個緩衝的get方法,編號1是判斷是否有存活值,即根據expireAfterAccess和expireAfterWrite進行判斷是否過期,如果過期,則value為null,執行編號3,。編號2指不過期的情況下,根據refreshAfterWrite判斷是否需要refresh。而編號3是需要進行載入(load而非reload),原因是沒有存活值,可能因為過期,可能根本就沒有過該值。
從段程式碼來看,在get的時候,是先判斷過期,再判斷refresh,所以我們可以通過設定refreshAfterWrite為1s,將expireAfterWrite 設為2s,當訪問頻繁的時候,會在每秒都進行refresh,而當超過2s沒有訪問,下一次訪問必須load新值。
我們繼續順藤摸瓜,順帶看看load和refresh分別都做了什麼事情,驗證以下上面說的理論。
下面看看 com.google.common.cache.LocalCache.Segment.lockedGetOrLoad方法:
這個方法有點長,限於篇幅,沒有貼出全部程式碼,關鍵步驟有7步。
1.獲得鎖
2.獲得key對應的valueReference
3.判斷是否該快取值正在loading,如果loading,則不再進行load操作(通過設定createNewEntry為false),後續會等待獲取新值。
4.如果不是在loading,判斷是否已經有新值了(被其他請求load完了),如果是則返回新值
5.準備loading,設定為loadingValueReference。loadingValueReference 會使其他請求在步驟3的時候會發現正在loding。
6。釋放鎖。
7.如果真的需要load,則進行load操作。
通過分析發現,只會有1個load操作,其他get會先阻塞住,驗證了之前的理論。
下面看看com.google.common.cache.LocalCache.Segment.scheduleRefresh方法:
1.判斷是否需要refresh,且當前非loading狀態,如果是則進行refresh操作,並返回新值。
2.步驟2是我加上去的,為後面的測試做準備。如果需要refresh,但是有其他執行緒正在對該值進行refreshing,則列印,最終會返回舊值。
繼續深入步驟1中呼叫的refresh方法:
1.插入loadingValueReference,表示該值正在loading,其他請求根據此判斷是需要進行refresh還是返回舊值。insertLoadingValueReference裡有加鎖操作,確保只有1個refresh穿透到後端。限於篇幅,這裡不再展開。但是,這裡加鎖的範圍比load時候加鎖的範圍要小,在expire->load的過程,所有的get一旦知道expire,則需要獲得鎖,直到得到新值為止,阻塞的影響範圍會是從expire到load到新值為止;而refresh->reload的過程,一旦get發現需要refresh,會先判斷是否有loading,再去獲得鎖,然後釋放鎖之後再去reload,阻塞的範圍只是insertLoadingValueReference的一個小物件的new和set操作,幾乎可以忽略不計,所以這是之前說refresh比expire高效的原因之一。
2.進行refresh操作,這裡不對loadAsync進行展開,它呼叫了CacheLoader的reload方法,reload方法支援過載去實現非同步的載入,而當前執行緒返回舊值,這樣效能會更好,其預設是同步地呼叫了CacheLoader的load方法實現。
到這裡,我們知道了refresh和expire的區別了吧!refresh執行reload,而expire後會重新執行load,和初始化時一樣。
三、測試和驗證
在上面貼出的原始碼,大家應該注意到一些System.out.println語句,這些是我加上去的,便於後續進行測試驗證。現在就來對剛剛的分析進行程式驗證。
貼出測試的原始碼:
-
packagecom.example.demo;
-
importjava.util.concurrent.CountDownLatch;
-
importjava.util.concurrent.CyclicBarrier;
-
importjava.util.concurrent.ExecutionException;
-
importjava.util.concurrent.TimeUnit;
-
importcom.google.common.cache.CacheBuilder;
-
importcom.google.common.cache.CacheLoader;
-
importcom.google.common.cache.LoadingCache ;
-
importcom.google.common.util.concurrent.Futures;
-
importcom.google.common.util.concurrent.ListenableFuture;
-
publicclassConcurrentTest {
-
privatestaticfinal int CONCURRENT_NUM = 10;//併發數
-
privatevolatilestatic int value = 1;
-
privatestaticLoadingCache <String, String> cache = CacheBuilder.newBuilder().maximumSize(1000)
-
.expireAfterWrite(5, TimeUnit. SECONDS)
-
.refreshAfterWrite(1, TimeUnit. SECONDS)
-
.build(newCacheLoader<String, String>() {
-
publicString load(String key) throwsInterruptedException {
-
System. out.println( "load by " + Thread.currentThread().getName());
-
returncreateValue(key);
-
}
-
@Override
-
publicListenableFuture<String> reload(String key, String oldValue)
-
throwsException {
-
System. out.println( "reload by " + Thread.currentThread().getName());
-
returnFutures.immediateFuture(createValue(key ));
-
}
-
}
-
);
-
//建立value
-
privatestaticString createValue(String key) throwsInterruptedException{
-
Thread. sleep(1000L);//讓當前執行緒sleep 1秒,是為了測試load和reload時候的併發特性
-
returnString. valueOf(value++);
-
}
-
publicstaticvoid main(String[] args) throwsInterruptedException, ExecutionException {
-
CyclicBarrier barrier = newCyclicBarrier(CONCURRENT_NUM );
-
CountDownLatch latch = newCountDownLatch(CONCURRENT_NUM );
-
for(inti = 0; i < CONCURRENT_NUM; i++) {
-
finalClientRunnable runnable = newClientRunnable(barrier, latch );
-
Thread thread = newThread( runnable, "client-"+ i);
-
thread.start();
-
}
-
//測試一段時間不訪問後是否執行expire而不是refresh
-
latch.await();
-
Thread. sleep(5100L);
-
System. out.println( "\n超過expire時間未讀之後...");
-
System. out.println(Thread. currentThread().getName() + ",val:"+ cache .get("key"));
-
}
-
staticclassClientRunnable implementsRunnable{
-
CyclicBarrier barrier;
-
CountDownLatch latch;
-
publicClientRunnable(CyclicBarrier barrier, CountDownLatch latch){
-
this. barrier = barrier;
-
this. latch = latch;
-
}
-
publicvoidrun() {
-
try{
-
barrier.await();
-
Thread. sleep((long)(Math.random()*4000));//每個client隨機睡眠,為了充分測試refresh和load
-
System. out.println(Thread. currentThread().getName() + ",val:"+ cache .get("key"));
-
latch.countDown();
-
}catch(Exception e) {
-
e.printStackTrace();
-
}
-
}
-
}
-
}
執行結果:
驗證結果和預期一致:
1.在快取還沒初始化的時候,client-1最新獲得了load鎖,進行load操作,在進行load的期間,其他client也到達進入load過程,阻塞,等待client-1釋放鎖,再依次獲得鎖。最終只load by client-1。
2.當超過了refreshAfterWrite設定的時間之內沒有訪問,需要進行refresh,client-5進行 refresh,在這個過程中,其他client並沒有獲得鎖,而是直接查詢舊值,直到refresh後才得到新值,過渡平滑。
3.在超過了expireAfterWrite設定的時間內沒有訪問,main執行緒在訪問的時候,值已經過期,需要進行load操作,而不會得到舊值。