「每日分享」性能優化之本地緩存利器-guava cache

點擊上方"java全棧技術"關注,每天學習一個java知識點

在系統中,一些訪問量大但是數據量小、與業務無關的緩存適合採用本地緩存。為什麼不採用分佈式緩存呢?分佈式集群緩存的構建、維護成本較高,不太適合做緊急的項目。而本地緩存訪問速度快,使用方便,劣勢是數據更新的一致性難以保證,使用範圍有所限制。Guava Cache是本地緩存的不二之選,今天我們就來一探究竟。下面是本文的目錄大綱:

  1. guava cache的優點和使用場景,用來判斷業務中是否適合使用此緩存
  2. 介紹常用的方法,並給出示例,作為使用的參考
  3. 深入解讀源碼。

guava簡介

guava cache是一個本地緩存。有以下優點:

  • 很好的封裝了get、put操作,能夠集成數據源。
  • 一般我們在業務中操作緩存,都會操作緩存和數據源兩部分。如:put數據時,先插入DB,再刪除原來的緩存;ge數據時,先查緩存,命中則返回,沒有命中時,需要查詢DB,再把查詢結果放入緩存中。 guava cache封裝了這麼多步驟,只需要調用一次get/put方法即可。
  • 線程安全的緩存,與ConcurrentMap相似,但前者增加了更多的元素失效策略,後者只能顯示的移除元素。
  • Guava Cache提供了三種基本的緩存回收方式:基於容量回收、定時回收和基於引用回收。定時回收有兩種:按照寫入時間,最早寫入的最先回收;按照訪問時間,最早訪問的最早回收。
  • 監控緩存加載/命中情況。

常用方法

  • V getIfPresent(Object key) 獲取緩存中key對應的value,如果緩存沒命中,返回null。return value if cached, otherwise return null.
  • V get(K key) throws ExecutionException 獲取key對應的value,若緩存中沒有,則調用LocalCache的load方法,從數據源中加載,並緩存。 return value if cached, otherwise load, cache and return.
  • void put(K key, V value) if cached, return; otherwise create, cache , and return.
  • void invalidate(Object key); 刪除緩存
  • void invalidateAll(); 清楚所有的緩存,相當遠map的clear操作。
  • long size(); 獲取緩存中元素的大概個數。為什麼是大概呢?元素失效之時,並不會實時的更新size,所以這裡的size可能會包含失效元素。
  • CacheStats stats(); 緩存的狀態數據,包括(未)命中個數,加載成功/失敗個數,總共加載時間,刪除個數等。
  • ConcurrentMap asMap(); 將緩存存儲到一個線程安全的map中。

批量操作就是循環調用上面對應的方法,如:

  • ImmutableMap getAllPresent(Iterable> keys);
  • void putAll(Map extends K,? extends V> m);
  • void invalidateAll(Iterable> keys);

在GuavaCache中緩存的容器被定義為接口Cache的實現類,這些實現類都是線程安全的,因此通常定義為一個單例。下面是官方給的demo:

「每日分享」性能優化之本地緩存利器-guava cache

回收策略

常用定時回收,下面是三種基於時間的清理或刷新緩存數據的方式:

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

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

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

考慮到時效性,我們可以使用expireAfterWrite,使每次更新之後的指定時間讓緩存失效,然後重新加載緩存。guava cache會嚴格限制只有1個加載操作,這樣會很好地防止緩存失效的瞬間大量請求穿透到後端引起雪崩效應。

然而,通過分析源碼,guava cache在限制只有1個加載操作時進行加鎖,其他請求必須阻塞等待這個加載操作完成;而且,在加載完成之後,其他請求的線程會逐一獲得鎖,去判斷是否已被加載完成,每個線程必須輪流地走一個“”獲得鎖,獲得值,釋放鎖“”的過程,這樣性能會有一些損耗。這裡由於我們計劃本地緩存1秒,所以頻繁的過期和加載,鎖等待等過程會讓性能有較大的損耗。

因此我們考慮使用refreshAfterWrite。refreshAfterWrite的特點是,在refresh的過程中,嚴格限制只有1個重新加載操作,而其他查詢先返回舊值,這樣有效地可以減少等待和鎖爭用,所以refreshAfterWrite會比expireAfterWrite性能好。但是它也有一個缺點,因為到達指定時間後,它不能嚴格保證所有的查詢都獲取到新值。瞭解過guava cache的定時失效(或刷新)原來的同學都知道,guava cache並沒使用額外的線程去做定時清理和加載的功能,而是依賴於查詢請求。在查詢的時候去比對上次更新的時間,如超過指定時間則進行加載或刷新。所以,如果使用refreshAfterWrite,在吞吐量很低的情況下,如很長一段時間內沒有查詢之後,發生的查詢有可能會得到一箇舊值(這個舊值可能來自於很長時間之前),這將會引發問題。

可以看出refreshAfterWrite和expireAfterWrite兩種方式各有優缺點。

guava cache源碼解析

示例代碼:

「每日分享」性能優化之本地緩存利器-guava cache

「每日分享」性能優化之本地緩存利器-guava cache

「每日分享」性能優化之本地緩存利器-guava cache

「每日分享」性能優化之本地緩存利器-guava cache

先了解一些主要類和接口:

  • CacheBuilder:類,緩存構建器。構建緩存的入口,指定緩存配置參數並初始化本地緩存。
  • CacheBuilder在build方法中,會把前面設置的參數,全部傳遞給LocalCache,它自己實際不參與任何計算。這種初始化參數的方法值得借鑑,代碼簡潔易讀。
  • CacheLoader:抽象類。用於從數據源加載數據,定義load、reload、loadAll等操作。
  • Cache:接口,定義get、put、invalidate等操作,這裡只有緩存增刪改的操作,沒有數據加載的操作。
  • AbstractCache:抽象類,實現Cache接口。其中批量操作都是循環執行單次行為,而單次行為都沒有具體定義。
  • LoadingCache:接口,繼承自Cache。定義get、getUnchecked、getAll等操作,這些操作都會從數據源load數據。
  • AbstractLoadingCache:抽象類,繼承自AbstractCache,實現LoadingCache接口。
  • LocalCache:類。整個guava cache的核心類,包含了guava cache的數據結構以及基本的緩存的操作方法。
  • LocalManualCache:LocalCache內部靜態類,實現Cache接口。其內部的增刪改緩存操作全部調用成員變量localCache(LocalCache類型)的相應方法。
  • LocalLoadingCache:LocalCache內部靜態類,繼承自LocalManualCache類,實現LoadingCache接口。其所有操作也是調用成員變量localCache(LocalCache類型)的相應方法。

綜上,guava cache的核心操作,都在LocalCache中實現。

其他:

  • CacheStats:緩存加載/命中統計信息。

在看具體的代碼之前,先來簡單瞭解一下LocalCache的數據結構。

LocalCache的數據結構如下所示:

「每日分享」性能優化之本地緩存利器-guava cache

LocalCache的數據結構與ConcurrentHashMap很相似,都由多個segment組成,且各segment相對獨立,互不影響,所以能支持並行操作。每個segment由一個table和若干隊列組成。緩存數據存儲在table中,其類型為AtomicReferenceArray>,即一個數組,數組中每個元素是一個鏈表。兩個隊列分別是writeQueue和accessQueue,用來存儲寫入的數據和最近訪問的數據,當數據過期,需要刷新整體緩存(見上述示例最後一次cache.getIfPresent("key5"))時,遍歷隊列,如果數據過期,則從table中刪除。segment中還有基於引用場景的其他隊列,這裡先不做討論。

CacheBuilder

CacheBuilder是緩存配置和構建入口,先看一些屬性。CacheBuilder的設置操作都是為這些屬性賦值。

「每日分享」性能優化之本地緩存利器-guava cache

CacheBuilder構建緩存有兩個方法:

「每日分享」性能優化之本地緩存利器-guava cache

「每日分享」性能優化之本地緩存利器-guava cache

「每日分享」性能優化之本地緩存利器-guava cache

「每日分享」性能優化之本地緩存利器-guava cache

「每日分享」性能優化之本地緩存利器-guava cache

「每日分享」性能優化之本地緩存利器-guava cache

LocalCache

LocalCache是guava cache的核心類。LocalCache的構造函數在上面已經分析過,接著看下核心方法。

對於get(key, loader)方法流程:

  • 對key做hash,找到存儲的segment及數組table上的位置;
  • 鏈表上查找entry,如果entry不為空,且value沒有過期,則返回value,並刷新entry。
  • 若鏈表上找不到entry,或者value已經過期,則調用lockedGetOrLoad。
  • 鎖住整個segment,遍歷entry可能在的鏈表,查看數據是否存在是否過期,若存在則返回。若過期則刪除(table,各種queue)。若不存在,則新建一個entry插入table。放開整個segment的鎖。
  • 鎖住entry,調用loader的reload方法,從數據源加載數據,然後調用storeLoadedValue更新緩存。
  • storeLoadedValue時,鎖住整個segment,將value設置到entry中,並設置相關數據(入寫入/訪問隊列,加載/命中數據等)。

getAll(keys)方法:

  • 循環調用get方法,從緩存中獲取key對應的value。沒有命中的記錄下來。
  • 如果有沒有命中的key,調用loadAll(keys,loader)方法加載數據。
  • 將加載的數據依次緩存,調用segment的put(K key, int hash, V value, boolean onlyIfAbsent)方法。
  • put時,鎖住整個segment,將數據插入鏈表,更新統計數據。

put(key,value)方法:

  • 對key做hash,找到segment的位置和table上的位置;
  • 鎖住整個segment,將數據插入鏈表,更新統計數據。

putAll(map) 循環調用put方法。

putIfAbsent(key, value) 緩存中,鍵值對不存在的時候才插入。

實踐

guava cache是將數據源中的數據緩存在本地,那如果我們想把遠端數據源中的數據緩存在遠端分佈式緩存(如redis),可以怎麼來使用guava cache的方式進行封裝呢?

可以仿照guava寫一個簡單的緩存,定義如下:

CacheBuilder類 : 配置緩存參數,構建緩存。同上面所講。

Cache接口:定義增刪查接口。

MyCache類:實現Cache接口,put -> 存入DB,更新緩存; get -> 查詢緩存,存在即返回;若不存在,查詢DB,更新緩存,返回。

CacheLoader類:供MyCache調用,get和getAll時提供單次查DB和批量查DB。


分享到:


相關文章: