大家好,又見面了。
本文是筆者作為掘金技術社群簽約作者的身份輸出的快取專欄系列內容,將會通過系列專題,講清楚快取的方方面面。如果感興趣,歡迎關注以獲取後續更新。
不知不覺,這已經是《深入理解快取原理與實戰設計》系列專欄的第6篇文章了。經過前面5篇文章的鋪墊,我們系統且全面的介紹了快取相關的概念與典型問題,也手動實操瞭如何構建一個本地最簡版本的通用快取框架,還對JAVA主流的本地快取規範進行了解讀。
秉持著不重複造輪子的理念,本篇文章中,我們就來一起深入剖析JAVA本地快取的優秀「輪子」 —— 來自Google家族的Guava Cache
。聊一聊其實現機制、看一看如何使用。
Guava是Google提供的一套JAVA的工具包,而Guava Cache
則是該工具包中提供的一套完善的JVM級別的高並行快取框架。其實現機制類似ConcurrentHashMap,但是進行了眾多的封裝與能力擴充套件。作為JVM級別的本地快取框架,Guava Cache
具備快取框架該有的眾多基礎特性。當然,Guava Cache能從眾多本地快取類產品中脫穎而出,除了具備上述基礎快取特性外,還有眾多貼心的能力增強,絕對算得上是工具包屆的超級暖男!為什麼這麼說呢?我們一起看下Guava Cache的能力介紹,應該可以有所體會。
作為一個合格的快取容器,支援快取記錄過期是一個基礎能力。Guava Cache
不但支援設定過期時間,還支援選擇是根據插入時間
進行過期處理(建立過期)、或者是根據最後存取時間
進行過期處理(存取過期)。
過期策略 | 具體說明 |
---|---|
建立過期 | 基於快取記錄的插入時間判斷。比如設定10分鐘過期,則記錄加入快取之後,不管有沒有存取,10分鐘時間到則 |
存取過期 | 基於最後一次的存取時間來判斷是否過期。比如設定10分鐘過期,如果快取記錄被存取到,則以最後一次存取時間重新計時;只有連續10分鐘沒有被存取的時候才會過期,否則將一直存在快取中不會被過期。 |
實際使用的時候,可以在建立快取容器的時候指定過期策略即可:
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.expireAfterWrite(30L, TimeUnit.MINUTES)
.build();
}
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.expireAfterAccess(30L, TimeUnit.MINUTES)
.build();
}
是不是很方便?
作為記憶體型快取,必須要防止出現記憶體溢位的風險。Guava Cache支援設定快取容器的最大儲存上限,並支援根據快取記錄條數
或者基於每條快取記錄的權重
(後面會具體介紹)進行判斷是否達到容量閾值。
當容量觸達閾值後,支援根據FIFO + LRU
策略實施具體淘汰處理以騰出位置給新的記錄使用。
淘汰策略 | 具體說明 |
---|---|
FIFO | 根據快取記錄寫入的順序,先寫入的先淘汰 |
LRU | 根據存取順序,淘汰最久沒有存取的記錄 |
實際使用的時候,同樣是在建立快取容器的時候指定容量上限與淘汰策略,這樣就可以放心大膽的使用而不用擔心記憶體溢位問題咯。
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.maximumSize(10000L)
.build();
}
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.maximumWeight(10000L)
.weigher((key, value) -> (int) Math.ceil(instrumentation.getObjectSize(value) / 1024L))
.build();
}
這裡需要注意:按照權重進行限制快取容量的時候必須要指定weighter
屬性才可以生效。上面程式碼中我們通過計算value
物件的位元組數(byte)來計算其權重資訊,每1kb的位元組數作為1個權重,整個快取容器的總權重限制為1w,這樣就可以實現將快取記憶體佔用控制在10000*1k≈10M
左右。
有沒有很省心?
在前面文章中,我們有介紹過快取的三種模型,分別是旁路型
、穿透型
、非同步型
。Guava Cache作為一個封裝好的快取框架,是一個典型的穿透型快取。正常業務使用快取時通常會使用旁路型快取,即先去快取中嘗試查詢獲取資料,如果獲取不到則會從資料庫中進行查詢並加入到快取中;而為了簡化業務端使用複雜度,Guava Cache支援整合資料來源,業務層面呼叫介面查詢快取資料的時候,如果快取資料不存在,則會自動去資料來源中進行資料獲取並加入快取中。
public User findUser(Cache<String, User> cache, String userId) {
try {
return cache.get(userId, () -> {
System.out.println(userId + "使用者快取不存在,嘗試回源查詢並回填...");
return userDao.getUser(userId);
});
} catch (ExecutionException e) {
e.printStackTrace();
}
return null;
}
實際使用的時候如果查詢的使用者不存在,則會自動去回源查詢並寫入快取裡,再次獲取的時候便可以從快取直接獲取:
上面的方法裡,是通過在get方法裡傳入Callable
實現的方式指定回源獲取資料的方式,來實現快取不存在情況的自動資料拉取與回填到快取中的。實際使用的時候,除了Callable方式,還有一種CacheLoader
的模式,也可以實現這一效果。
需要我們在建立快取容器的時候宣告容器為LoadingCache型別(下面的章節中有介紹),並且指定CacheLoader
處理邏輯:
public LoadingCache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
System.out.println(key + "使用者快取不存在,嘗試CacheLoader回源查詢並回填...");
return userDao.getUser(key);
}
});
}
這樣,獲取不到資料的時候,也會自動回源查詢並填充。比如我們執行如下呼叫邏輯:
public static void main(String[] args) {
CacheService cacheService = new CacheService();
LoadingCache<String, User> cache = cacheService.createUserCache();
try {
System.out.println(cache.get("123"));
System.out.println(cache.get("124"));
System.out.println(cache.get("123"));
} catch (Exception e) {
e.printStackTrace();
}
}
執行結果如下:
123使用者快取不存在,嘗試CacheLoader回源查詢並回填...
User(userId=123, userName=鐵柱, department=研發部)
124使用者快取不存在,嘗試CacheLoader回源查詢並回填...
User(userId=124, userName=翠花, department=測試部)
User(userId=123, userName=鐵柱, department=研發部)
兩種方式都可以實現這一效果,實際可以根據需要與場景選擇合適的方式。
當然,有些時候,可能也會涉及到CacheLoader
與Callable
兩種方式結合使用的場景,這種情況下優先會執行Callable提供的邏輯,Callable缺失的場景會使用CacheLoader提供的邏輯。
public static void main(String[] args) {
CacheService cacheService = new CacheService();
LoadingCache<String, User> cache = cacheService.createUserCache();
try {
System.out.println(cache.get("123", () -> new User("xxx")));
System.out.println(cache.get("124"));
System.out.println(cache.get("123"));
} catch (Exception e) {
e.printStackTrace();
}
}
執行後,可以看出Callable邏輯被優先執行,而CacheLoader作為兜底策略存在:
User(userId=xxx, userName=null, department=null)
124使用者快取不存在,嘗試CacheLoader回源查詢並回填...
User(userId=124, userName=翠花, department=測試部)
User(userId=xxx, userName=null, department=null)
這個是與上面資料來源整合一起的輔助增強能力。在高並行場景下,如果某個key值沒有命中快取,大量的請求同步打到下游模組處理的時候,很容易造成快取擊穿問題。
為了防止快取擊穿問題,可以通過加鎖的方式來規避。當快取不可用時,僅持鎖的執行緒
負責從資料庫中查詢資料並寫入快取中,其餘請求重試時先嚐試從快取中獲取資料,避免所有的並行請求全部同時打到資料庫上。
作為穿透型快取的保護策略之一,Guava Cache自帶了並行鎖定
機制,同一時刻僅允許一個請求去回源獲取資料並回填到快取中,而其餘請求則阻塞等待,不會造成資料來源的壓力過大。
有沒有被暖心到?
引入快取的一個初衷是希望快取能夠提升系統的處理效能,而有限快取容量中僅儲存部分資料的時候,我們會希望儲存的有限資料可以儘可能的覆蓋並抗住大部分的請求流量,所以對快取的命中率會非常關注。
Guava Cache深知這一點,所以提供了stat
統計紀錄檔,支援檢視快取資料的載入或者命中情況統計。我們可以基於命中情況,不斷的去優化程式碼中快取的資料策略,以發揮出快取的最大價值。
Guava Cache的統計資訊封裝為CacheStats
物件進行承載,主要包含一下幾個關鍵指標項:
指標 | 含義說明 |
---|---|
hitCount | 命中快取次數 |
missCount | 沒有命中快取次數(查詢的時候記憶體中沒有) |
loadSuccessCount | 回源載入的時候載入成功次數 |
loadExceptionCount | 回源載入但是載入失敗的次數 |
totalLoadTime | 回源載入操作總耗時 |
evictionCount | 刪除記錄的次數 |
快取容器建立的時候,可以通過recordStats()
開啟快取行為的統計記錄:
public static void main(String[] args) {
LoadingCache<String, User> cache = CacheBuilder.newBuilder()
.recordStats()
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
System.out.println(key + "使用者快取不存在,嘗試CacheLoader回源查詢並回填...");
User user = userDao.getUser(key);
if (user == null) {
System.out.println(key + "使用者不存在");
}
return user;
}
});
try {
System.out.println(cache.get("123");
System.out.println(cache.get("124"));
System.out.println(cache.get("123"));
System.out.println(cache.get("126"));
} catch (Exception e) {
} finally {
CacheStats stats = cache.stats();
System.out.println(stats);
}
}
上述程式碼執行之後結果輸出如下:
123使用者快取不存在,嘗試CacheLoader回源查詢並回填...
User(userId=123, userName=鐵柱, department=研發部)
124使用者快取不存在,嘗試CacheLoader回源查詢並回填...
User(userId=124, userName=翠花, department=測試部)
User(userId=123, userName=鐵柱, department=研發部)
126使用者快取不存在,嘗試CacheLoader回源查詢並回填...
126使用者不存在
CacheStats{hitCount=1, missCount=3, loadSuccessCount=2, loadExceptionCount=1, totalLoadTime=1972799, evictionCount=0}
可以看出,一共執行了4次請求,其中1次命中,3次回源處理,2次回源載入成功,1次回源沒找到資料,與列印出來的CacheStats
統計結果完全吻合。
有著上述能力的加持,前面將Guava Cache稱作「暖男」不過分吧?
在本系列專欄的第一篇文章《聊一聊作為高並行系統基石之一的快取,會用很簡單,用好才是技術活》中,我們在快取的一步步演進介紹中提過本地快取與集中式快取的區別,也聊了各自的優缺點。
作為一款純粹的本地快取框架,Guava Cache具備本地快取該有的優勢,也無可避免的存在著本地快取的弊端。
維度 | 簡要概述 |
---|---|
優勢 | 基於空間換時間的策略,利用記憶體的高速處理效率,提升機器的處理效能,減少大量對外的IO請求互動,比如讀取DB、請求外部網路、讀取本地磁碟資料等等操作。 |
弊端 | 整體容量受限,可能對本機記憶體造成壓力。此外,對於分散式多節點叢集部署的場景,快取更新場景會出現快取漂移問題,導致各個節點之間的快取資料不一致。 |
鑑於上述優劣綜合判斷,可以大致圈定Guava Cache
的實際適用場合:
這類場景中,會將資料快取到本地記憶體中,採用定時觸發(或者事件推播)的策略重新載入到記憶體中。這樣業務處理邏輯直接從記憶體讀取需要的資料,修改系統設定項之後,需要等待一定的時間後方可生效。
很多的設定中心採用的都是這個快取策略。統一設定中心中管理設定資料,然後各個業務節點會從統一設定中心拉取設定並儲存在自己原生的記憶體中然後使用本地記憶體中的資料。這樣可以有效規避設定中心的單點故障問題,降低了設定中心的請求壓力,也提升了業務節點自身的業務處理效能(減少了與設定中心之間的網路互動請求)。
對於分散式系統而言,集中式快取是一個常規場景中很好的選項。但是對於一些超大並行量且讀效能要求嚴苛的系統而言,一個請求流程中需要頻繁的去與Redis互動,其網路開銷也是不可忍受的。所以可以採用將資料本機記憶體快取的方式,分散redis的壓力,降低對外請求互動的次數,提升介面響應速度。
HashMap/ConcurrentHashMap
的替代品這種場景也很常見,我們在專案中經常會遇到一些資料的需要臨時快取一下,為了方便很多時候直接使用的HashMap
或者ConcurrentHashMap
來實現。而Guava Cache聚焦快取場景做了很多額外的功能增強(比如資料過期能力支援、容量上限約束等),可以完美替換掉HashMap/ConcurrentHashMap,更適合快取場景使用。
使用Guava Cache,首先需要引入對應的依賴包。對於Maven專案,可以在pom.xml
中新增對應的依賴宣告即可:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
這樣,就完成了依賴引入。
具體使用前首先面臨的就是如何建立Guava Cache範例。可以藉助CacheBuilder
以一種優雅的方式來構建出合乎我們訴求的Cache範例。
對CacheBuilder中常見的屬性方法,歸納說明如下:
方法 | 含義說明 |
---|---|
newBuilder | 構造出一個Builder範例類 |
initialCapacity | 待建立的快取容器的初始容量大小(記錄條數) |
maximumSize | 指定此快取容器的最大容量(最大快取記錄條數) |
maximumWeight | 指定此快取容器的最大容量(最大比重值),需結合weighter 方可體現出效果 |
expireAfterWrite | 設定過期策略,按照資料寫入時間進行計算 |
expireAfterAccess | 設定過期策略,按照資料最後存取時間來計算 |
weighter | 入參為一個函數式介面,用於指定每條存入的快取資料的權重佔比情況。這個需要與maximumWeight 結合使用 |
refreshAfterWrite | 快取寫入到快取之後 |
concurrencyLevel | 用於控制快取的並行處理能力,同時支援多少個執行緒並行寫入操作 |
recordStats | 設定開啟此容器的資料載入與快取命中情況統計 |
基於CacheBuilder
及其提供的各種方法,我們可以輕鬆的進行快取容器的構建、並指定容器的各種約束條件。
比如下面這樣:
public LoadingCache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.initialCapacity(1000) // 初始容量
.maximumSize(10000L) // 設定最大容量
.expireAfterWrite(30L, TimeUnit.MINUTES) // 設定寫入過期時間
.concurrencyLevel(8) // 設定最大並行寫操作執行緒數
.refreshAfterWrite(1L, TimeUnit.MINUTES) // 設定自動重新整理資料時間
.recordStats() // 開啟快取執行情況統計
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
return userDao.getUser(key);
}
});
}
Guava Cache容器物件建立完成後,可以基於其提供的對外介面完成相關快取的具體操作。首先可以瞭解下Cache提供的對外操作介面:
對關鍵介面的含義梳理歸納如下:
介面名稱 | 具體說明 |
---|---|
get | 查詢指定key對應的value值,如果快取中沒匹配,則基於給定的Callable 邏輯去獲取資料回填快取中並返回 |
getIfPresent | 如果快取中存在指定的key值,則返回對應的value值,否則返回null(此方法不會觸發自動回源與回填操作) |
getAllPresent | 針對傳入的key列表,返回快取中存在的對應value值列表(不會觸發自動回源與回填操作) |
put | 往快取中新增key-value鍵值對 |
putAll | 批次往快取中新增key-value鍵值對 |
invalidate | 從快取中刪除指定的記錄 |
invalidateAll | 從快取中批次刪除指定記錄,如果無引數,則清空所有快取 |
size | 獲取快取容器中的總記錄數 |
stats | 獲取快取容器當前的統計資料 |
asMap | 將快取中的資料轉換為ConcurrentHashMap 格式返回 |
cleanUp | 清理所有的已過期的資料 |
在專案中,可以基於上述介面,實現各種快取操作功能。
public static void main(String[] args) {
CacheService cacheService = new CacheService();
LoadingCache<String, User> cache = cacheService.createUserCache6();
cache.put("122", new User("122"));
cache.put("122", new User("122"));
System.out.println("put操作後查詢:" + cache.getIfPresent("122"));
cache.invalidate("122");
System.out.println("invalidate操作後查詢:" + cache.getIfPresent("122"));
System.out.println(cache.stats());
}
執行後,結果如下:
put操作後查詢:User(userId=122, userName=null, department=null)
invalidate操作後查詢:null
CacheStats{hitCount=1, missCount=1, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=0}
當然,上述範例程式碼中這種使用方式有個明顯的弊端就是業務層面對Guava Cache的私有API
依賴過深,後續如果需要替換Cache元件的時候會比較痛苦,需要對業務呼叫的地方進行大改。所以真正專案裡面,最好還是對其適當封裝,以實現業務層面的解耦。如果你的專案是使用Spring框架,也可以基於Spring Cache
統一規範來整合並使用Guava Cache,降低對業務邏輯的侵入。
好啦,關於Guava Cache的功能與關鍵特性介紹,以及專案中具體的整合與使用方法,就介紹到這裡了。總結一下,Guava Cache其實就是一個增強版的大號ConcurrentHashMap,在保證執行緒安全的情況下,增加了快取必備的資料過期、容量限制、回源策略等能力,既保證了本身的精簡,又使得整體能力足以滿足大部分本地快取場景的使用訴求。也正是由於這些原因,Guava Cache在JAVA領域廣受好評,使用範圍非常的廣泛。
下一篇文章中,我們將繼續對Guava Cache展開討論,跳出使用層面,剖析其內部核心實現邏輯。如果有興趣,歡迎關注後續文章的更新。
那麼,關於本文中提及的內容,你是否有自己的一些想法與見解呢?歡迎評論區一起交流下,期待和各位小夥伴們一起切磋、共同成長。