天天看點

Guava Cache的緩存管理與使用

作者:閃念基因

前言

Guava工程包含了若幹被Google的Java項目廣泛依賴的核心庫, Guava Cache是Guava的核心庫之一,Guava Cache是一個全記憶體的本地緩存實作,它提供了線程安全的實作機制。整體上來說Guava cache 是本地緩存的不二之選,簡單易用,性能好。今天我們就來聊聊guava cache的那些事兒。

GuavaCache資料結構

Guava Cache繼承了ConcurrentHashMap的設計思路,使用多個segments方式的細粒度鎖,在保證線程安全的同時,支援高并發場景需求。Cache類似于Map,它是存儲鍵值對的集合,不同的是它還需要處理expire、dynamic load等算法邏輯,需要一些額外資訊來實作這些操作。對此,根據面向對象思想,需要做方法與資料的關聯封裝。

Guava Cache的緩存管理與使用

需要說明的是:

  • 每一個Segment中的有效隊列(廢棄隊列不算)的個數最多可能不止一個, 隊列用于實作LRU緩存回收算法,資料超過設定的最大值時,使用LRU算法移除;
  • 其中的ReferenceEntry[i]用于存放key-value,ReferenceEntry是對一個鍵值對節點的抽象,緩存的key被封裝在 WeakReference引用内, value被封裝在 WeakReference或SoftReference引用内
  • 每個ReferenceEntry數組項都是一條ReferenceEntry鍊,自動将entry節點加載進緩存結構中。
  • 多個Segment之間互不打擾,可以并發執行
  • 各個Segment的擴容隻需要擴自己的就好,與其他Segment無關
  • 在保證緩存命中率的前提下,根據需要根據時間、空間兩個次元設定好初始化容量與并發水準參數

收政策

Guava cache 的資料回收政策整體有三種回收方案,分為基于弱引用的回收政策、基于容量的回收政策,以及基于時間的回收政策

1、基于引用的回收

通過使用弱引用的鍵、或弱引用的值、或軟引用的值,Guava Cache可以把緩存設定為允許垃圾回收:

CacheBuilder.weakKeys()使用弱引用存儲鍵。當鍵沒有其它(強或軟)引用時,緩存項可以被垃圾回收。因為垃圾回收僅依賴恒等式(==),使用弱引用鍵的緩存用==而不是equals比較鍵。

CacheBuilder.weakValues()使用弱引用存儲值。當值沒有其它(強或軟)引用時,緩存項可以被垃圾回收。因為垃圾回收僅依賴恒等式(==),使用弱引用值的緩存用==而不是equals比較值。CacheBuilder.softValues()使用軟引用存儲值。軟引用隻有在響應記憶體需要時,才按照全局最近最少使用的順序回收。考慮到使用軟引用的性能影響,我們通常建議使用更有性能預測性的緩存大小限定(見下文,基于容量回收)。使用軟引用值的緩存同樣用==而不是equals比較值。

2、基于容量回收政策

maximumSize(long)可以設定緩存的最大容量。緩存将會嘗試回收最近沒有使用,或者沒有經常使用的緩存項。警告:緩存可能會在容量達到限制之前執行回收,通常是在緩存大小逼近限制大小時。

另外,如果不同的緩存項有不同的“權重”,緩存項有不同的記憶體占用,此時你需要使用CacheBuilder.weigher(Weigher)指定一個權重計算函數,并使用CacheBuilder.maxmumWeight(long)設定總權重。和maximumSize同樣需要注意的是緩存也是在逼近總權重的時候進行回收處理。此外,緩存項的權重是在建立時進行計算,此後不再改變。

3、基于時間回收政策

guava cache三種基于時間的清理或重新整理緩存資料的方式:

expireAfterAccess: 當緩存項在指定的時間段内沒有被讀或寫就會被回收。

這種緩存的回收順序和基于大小回收一樣。當緩存中存儲的資料達到過期時間沒有被讀寫,則資料就會被回收,如果資料一直處于被讀寫狀态,資料一直不會被回收,這種情況就可能導緻資料髒讀的發生。

expireAfterWrite:當緩存項在指定的時間段内沒有更新就會被回收。

使用expireAfterWrite,使每次更新之後的指定時間讓緩存失效,然後重新load緩存。在并發的場景下,guava cache會嚴格限制隻有1個加載操作,這樣會很好地防止緩存失效的瞬間大量請求穿透到後端引起雪崩效應。

然而,通過分析源碼,guava cache在限制隻有1個加載操作時進行加鎖,并發場景下,其他請求必須阻塞等待這個加載操作完成;而且,在加載完成之後,其他請求的線程會逐一獲得鎖,去判斷是否已被加載完成,每個線程必須輪流地走一個“”獲得鎖,獲得值,釋放鎖“”的過程,這樣會導緻系統的請求抛棄量的增加,同時在QPS的表現上會出現波峰波谷走勢(當請求命中緩存時服務QPS瞬間上升,緩存時效時QPS瞬間降低的顯現)。

refreshAfterWrite:當緩存項上一次更新操作之後的多久會被重新整理。

refreshAfterWrite的特點是,在refresh的過程中,嚴格限制隻有1個重新加載操作,而其他查詢先傳回舊值,這樣有效地可以減少等待和鎖競争導緻的阻塞,是以refreshAfterWrite會比expireAfterWrite性能好。但是它也有一個缺點,因為到達指定時間後,它不能嚴格保證所有的查詢都擷取到新值。guava cache并沒使用額外的線程去做定時清理和加載的功能,而是依賴于查詢請求。在查詢的時候去比對上次更新的時間,如超過設定的時間會選取1個線程進行加載或重新整理。是以,如果使用refreshAfterWrite,在吞吐量很低的情況下,如很長一段時間内沒有查詢之後産生瞬時并發的場景下,由于請求不等待緩存的加載完成而是直接傳回緩存中的舊值,這個舊值有可能是很長時間之前的資料,這将會在一些時效性很高的場景下引發問題。

可以看出refreshAfterWrite和expireAfterWrite兩種方式各有優缺點,各有使用場景。在金币商城的春運項目中,我們首次在項目中,針對瞬時流量高峰的一些場景,我們使用guava cache,并在refreshAfterWrite和expireAfterWrite找到了一個折中的解決方案,比如商城的商品資訊,控制緩存每3s進行refresh,如果超過5s沒有通路,那麼則讓緩存失效,下次通路時不會得到舊值,而是必須得待新值加載。

碼分析

通過追蹤LoadingCache的get方法源碼(以下源碼為guava 18.0版本),發現最終會調用以下核心方法,下面貼出源碼:

com.google.common.cache.LocalCache.Segment.get方法:

@Nullable

V get(Object key, int hash) {

try{

if (this.count != 0) {

now = this.map.ticker.read();

LocalCache.ReferenceEntry e =getLiveEntry(key, hash, now);

if (e == null) {

Object localObject1 = null;

return localObject1;

}

Object value =e.getValueReference().get();

if (value != null) {

recordRead(e, now);

Object localObject2 =scheduleRefresh(e, e.getKey(), hash, value, now, this.map.defaultLoader);

return localObject2;

}

tryDrainReferenceQueues();

}

long now = null;

return now; } finally { postReadCleanup();

}

}

這個緩沖的get方法,判斷是否有存活值,先查找LocalCache中是否已存在entry沒有被回收、也沒有expire的entry,如果找到,并在CacheBuilder中配置了refreshAfterWrite,并且目前時間間隔已經操作這個事件,則重新加載值,否則,直接傳回原有的值,即根據expireAfterAccess和expireAfterWrite進行判斷是否過期,如果過期,則value為null,根據refreshAfterWrite判斷隊列中的資料是否需要refresh。

從段代碼來看,在get的時候,是先判斷過期,再判斷refresh,是以我們在實際使用時可以通過設定refreshAfterWrite為3s,将expireAfterWrite 設為5s,當通路頻繁的時候,會在每3秒都進行緩存資料預refresh,而地吞吐量的時候,當超過5s沒有通路,緩存的資料進行過期失效處理,下一次通路會将cache中的資料進行更新。

下面看看com.google.common.cache.LocalCache.Segment.scheduleRefresh方法:

VscheduleRefresh(LocalCache.ReferenceEntry<K, V> entry, K key, int hash, V oldValue, long now, CacheLoader<? super K, V> loader)

{

if((this.map.refreshes())&& (now - entry.getWriteTime() > this.map.refreshNanos) &&(!(entry.getValueReference().isLoading())))

{

Object newValue = refresh(key, hash,loader, true);

if (newValue != null) {

return newValue;

}

}

returnoldValue;

}

判斷是否需要refresh,且緩存時間已失效,且目前非loading狀态,如果是則進行refresh操作,并傳回新值,否則直接傳回緩存中的oldValue;

我們進一步的檢視scheduleRefresh中調用的refresh方法,源碼如下

@Nullable

V refresh(K key, int hash, CacheLoader<? super K, V> loader, boolean checkTime)

{

LocalCache.LoadingValueReference loadingValueReference =insertLoadingValueReference(key, hash, checkTime);

if(loadingValueReference == null) {

returnnull;

}

ListenableFuture result = loadAsync(key, hash, loadingValueReference,loader);

if(result.isDone())

try {

return Uninterruptibles.getUninterruptibly(result);

}

catch (Throwable t)

{

}

returnnull;

}

插入loadingValueReference,表示該值正在loading,其他請求根據此判斷是需要進行refresh還是傳回舊值。insertLoadingValueReference裡有加鎖操作,確定隻有1個refresh穿透到後端。限于篇幅,這裡不再展開。但是,這裡加鎖的範圍比load時候加鎖的範圍要小,在expire->load的過程,所有的get一旦知道expire,則需要獲得鎖,直到得到新值為止,阻塞的影響範圍會是從expire到load到新值為止;而refresh->reload的過程,一旦get發現需要refresh,會先判斷是否有loading,再去獲得鎖,然後釋放鎖之後再去reload,阻塞的範圍隻是insertLoadingValueReference的一個小對象的new和set操作,幾乎可以忽略不計,是以這是之前說refresh比expire高效的原因之一。

到這裡,我們知道了refresh和expire的差別:refresh執行reload,而expire後會重新執行load,和初始化時一樣。

下面看看com.google.common.cache.LocalCache.Segment.lockedGetOrLoad方法:

V lockedGetOrLoad(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException

{

LocalCache.ValueReference valueReference = null;

LocalCache.LoadingValueReference loadingValueReference = null;

booleancreateNewEntry = true;

lock();

LocalCache.ReferenceEntry e;

try

{

long now = this.map.ticker.read();

preWriteCleanup(now);

int newCount = this.count - 1;

AtomicReferenceArray table = this.table;

int index = hash & table.length() - 1;

LocalCache.ReferenceEntry first =(LocalCache.ReferenceEntry)table.get(index);

for (e = first; e != null; e = e.getNext()) {

Object entryKey = e.getKey();

if ((e.getHash() != hash) || (entryKey == null) || (!(this.map.keyEquivalence.equivalent(key,entryKey))))

continue;

valueReference =e.getValueReference();

if (valueReference.isLoading()) {

createNewEntry = false; break;

}

Object value = valueReference.get();

if (value == null) {

enqueueNotification(entryKey, hash,valueReference, RemovalCause.COLLECTED);

} elseif (this.map.isExpired(e,now))

{

enqueueNotification(entryKey, hash,valueReference, RemovalCause.EXPIRED);

} else {

recordLockedRead(e, now);

this.statsCounter.recordHits(1);

Object localObject2 = value;

return localObject2;

}

this.writeQueue.remove(e);

this.accessQueue.remove(e);

this.count = newCount;

break;

}

if (createNewEntry) {

loadingValueReference = new LocalCache.LoadingValueReference();

if (e == null) {

e = newEntry(key, hash, first);

e.setValueReference(loadingValueReference);

table.set(index, e);

} else {

e.setValueReference(loadingValueReference);

}

}

} finally{

unlock();

postWriteCleanup();

}

if(createNewEntry)

{

try

{

synchronized (e) {

Object localObject1 = loadSync(key,hash, loadingValueReference, loader);

this.statsCounter.recordMisses(1); return localObject1; } } finally { this.statsCounter.recordMisses(1);

}

}

returnwaitForLoadingValue(e, key, valueReference);

}

步驟有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會先阻塞住。

議方案

綜上所述,在日常使用guava cache的過程中,建議使用如下的方式進行緩存政策的管理,具體代碼如下:

private LoadingCache<String,Optional< Object >> demoCahce = CacheBuilder.newBuilder()

.expireAfterWrite(5, TimeUnit.SECONDS).maximumSize(500).recordStats()

.refreshAfterWrite(3, TimeUnit.SECONDS)

.build(new CacheLoader<String, Optional<Object>>(){

@Override

public Optional<Object> load(String key) throws Exception {

return Optional.fromNullable(takeAwardService.getHornNotice());

}

});

在緩存的初始化和管理上,建議将時間回收政策與容量回收政策同時設定的方式進行緩存管理政策,使用expireAfterWrite為緩存中的資料進行過期時效管理,使用refreshAfterWrite方法完成緩存資料的預更新操作,這樣既解決了單獨使用expireAfterWrite導緻的緩存穿透問題,又解決了隻使用refreshAfterWrite導緻的資料過舊的問題,同時使用maximumSize對緩沖的容量進行限制,這樣就在時間和空間兩個次元對緩存進行了高效的記憶體管理。

作者:顧鑫

來源-微信公衆号:58無線技術

出處:https://mp.weixin.qq.com/s/vi8Y-u-uZc6V357yqwVH1g

繼續閱讀