您好,登錄后才能下訂單哦!
這篇文章主要介紹“如何正確使用Caffeine Cache”,在日常操作中,相信很多人在如何正確使用Caffeine Cache問題上存在疑惑,小編查閱了各式資料,整理出簡單好用的操作方法,希望對大家解答”如何正確使用Caffeine Cache”的疑惑有所幫助!接下來,請跟著小編一起來學習吧!
在推薦服務中,雖然允許少量請求因計算超時等原因返回默認列表。但從運營指標來說,越高的“完算率”意味著越完整的算法效果呈現,也意味著越高的商業收益。(完算率類比視頻的完播率,成功完成整個推薦線上流程計算的請求次數/總請求次數)
為了能夠盡可能快地完成計算,多級緩存方案已經成為推薦線上服務的標配。其中本地緩存顯得尤為重要,而 Caffeine Cache 就是近幾年脫穎而出的高性能本地緩存庫。Caffeine Cache 已經在 Spring Boot 2.0 中取代了 Google Guava 成為默認緩存框架,足見其成熟和可靠。
關于 Caffeine 的介紹文章有很多,不再累述,可閱讀文末的參考資料了解 Caffeine 的簡述、性能基準測試結果、基本 API 用法和 Window-TinyLFU 緩存算法原理等。雖然接觸 Caffeine 的時間不長,但其簡潔的 API 和如絲般順滑的異步加載能力簡直不要太好用。而本菜鳥在使用的過程中也踩了一些坑,使用不當甚至緩存也能卡得和磁盤 IO 一樣慢。
經過一番學習嘗試,總算了解到 Caffeine Cache 如絲般順滑的奧秘,總結下來分享一下。
使用 Caffeine Cache,除了 Spring 中常見的 @EnableCache、@Cacheable 等注解外,直接使用 Caffeine.newBuilder().build() 方法創建 LoadingCache 也是推薦服務常用的方式。
我們先來看看 Caffeine#builder 都有哪些配置套路:
當然可以,光腳的不怕穿鞋的,上線后別走……
雖然 expireAfterWrite 和 expireAfterAccess 同時配置不報錯,但 access 包含了 write,所以選一個就好了親。
只要配置上都會使用 == 來比較對象相等,而不是 equals;還有一個非常重要的配置,也是決定緩存如絲般順滑的秘訣:刷新策略 refreshAfterWrite。該配置使得 Caffeine 可以在數據加載后超過給定時間時刷新數據。下文詳解。
機智如我在 Builder 上也能踩坑
和 lombok 的 builder 不同,Caffeine#builder 的策略調用兩次將會導致運行時異常!這是因為 Caffeine 構建時每個策略都保存了已設置的標記位,所以重復設置并不是覆蓋而是直接拋異常:
public Caffeine<K, V> maximumWeight(@NonNegative long maximumWeight) { requireState(this.maximumWeight == UNSET_INT, "maximum weight was already set to %s", this.maximumWeight); requireState(this.maximumSize == UNSET_INT, "maximum size was already set to %s", this.maximumSize); this.maximumWeight = maximumWeight; requireArgument(maximumWeight >= 0, "maximum weight must not be negative"); return this; }
比如上述代碼,maximumWeight() 調用兩次的話就會拋出異常并提示 maximum weight was already set to xxx。
首先在實現類 LocalLoadingCache<K, V> 中可以看到;
default @Nullable V get(K key) { return cache().computeIfAbsent(key, mappingFunction()); }
但突然發現這個 get 方法沒有實現類!Why?我們跟蹤 cache() 方法就可以發現端倪:
public BoundedLocalCache<K, V> cache() { return cache; } public UnboundedLocalCache<K, V> cache() { return cache; }
根據調用 Caffeine.newBuilder().build() 的過程,決定了具體生成的是 BoundedLocalCache 還是 UnboundedLocalCache;
判定 BoundedLocalCache 的條件如下:
public <K1 extends K, V1 extends V> LoadingCache<K1, V1> build( @NonNull CacheLoader<? super K1, V1> loader) { requireWeightWithWeigher(); @SuppressWarnings("unchecked") Caffeine<K1, V1> self = (Caffeine<K1, V1>) this; return isBounded() || refreshes() ? new BoundedLocalCache.BoundedLocalLoadingCache<>(self, loader) : new UnboundedLocalCache.UnboundedLocalLoadingCache<>(self, loader); }
其中的 isBounded()、refreshes() 方法分別如下:
boolean isBounded() { return (maximumSize != UNSET_INT) || (maximumWeight != UNSET_INT) || (expireAfterAccessNanos != UNSET_INT) || (expireAfterWriteNanos != UNSET_INT) || (expiry != null) || (keyStrength != null) || (valueStrength != null); } boolean refreshes() { // 調用了 refreshAfter 就會返回 false return refreshNanos != UNSET_INT; }
可以看到一般情況下常規的配置都是 BoundedLocalCache。所以我們以它為例繼續看 BoundedLocalCache#computeIfAbsent 方法吧:
public @Nullable V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction, boolean recordStats, boolean recordLoad) { // 常用的 LoadingCache#get 方法 recordStats、recordLoad 都為 true // mappingFunction 即 builder 中傳入的 CacheLoader 實例包裝 requireNonNull(key); requireNonNull(mappingFunction); // 默認的 ticker read 返回的是 System.nanoTime(); // 關于其他的 ticker 見文末參考文獻,可以讓使用者自定義超時的計時方式 long now = expirationTicker().read(); // data 是 ConcurrentHashMap<Object, Node<K, V>> // key 根據代碼目前都是 LookupKeyReference 對象 // 可以發現 LookupKeyReference 保存的是 System.identityHashCode(key) 結果 // 關于 identityHashCode 和 hashCode 的區別可閱讀文末參考資料 Node<K, V> node = data.get(nodeFactory.newLookupKey(key)); if (node != null) { V value = node.getValue(); if ((value != null) && !hasExpired(node, now)) { // isComputingAsync 中將會判斷當前是否為異步類的緩存實例 // 是的話再判斷 node.getValue 是否完成。BoundedLocaCache 總是返回 false if (!isComputingAsync(node)) { // 此處在 BoundedLocaCache 中也是直接 return 不會執行 tryExpireAfterRead(node, key, value, expiry(), now); setAccessTime(node, now); } // 異步驅逐任務提交、異步刷新操作 // CacheLoader#asyncReload 就在其中的 refreshIfNeeded 方法被調用 afterRead(node, now, recordStats); return value; } } if (recordStats) { // 記錄緩存的加載成功、失敗等統計信息 mappingFunction = statsAware(mappingFunction, recordLoad); } // 這里2.8.0版本不同實現類生成的都是 WeakKeyReference Object keyRef = nodeFactory.newReferenceKey(key, keyReferenceQueue()); // 本地緩存沒有,使用加載函數讀取到緩存 return doComputeIfAbsent(key, keyRef, mappingFunction, new long[] { now }, recordStats); }
上文中 hasExpired 判斷數據是否過期,看代碼就很明白了:是通過 builder 的配置 + 時間計算來判斷的。
boolean hasExpired(Node<K, V> node, long now) { return (expiresAfterAccess() && (now - node.getAccessTime() >= expiresAfterAccessNanos())) | (expiresAfterWrite() && (now - node.getWriteTime() >= expiresAfterWriteNanos())) | (expiresVariable() && (now - node.getVariableTime() >= 0)); }
繼續看代碼,doComputeIfAbsent 方法主要內容如下:
@Nullable V doComputeIfAbsent(K key, Object keyRef, Function<? super K, ? extends V> mappingFunction, long[] now, boolean recordStats) { @SuppressWarnings("unchecked") V[] oldValue = (V[]) new Object[1]; @SuppressWarnings("unchecked") V[] newValue = (V[]) new Object[1]; @SuppressWarnings("unchecked") K[] nodeKey = (K[]) new Object[1]; @SuppressWarnings({"unchecked", "rawtypes"}) Node<K, V>[] removed = new Node[1]; int[] weight = new int[2]; // old, new RemovalCause[] cause = new RemovalCause[1]; // 對 data 這個 ConcurrentHashMap 調用 compute 方法,計算 key 對應的值 // compute 方法的執行是原子的,并且會對 key 加鎖 // JDK 注釋說明 compute 應該短而快并且不要在其中更新其他的 key-value Node<K, V> node = data.compute(keyRef, (k, n) -> { if (n == null) { // 沒有值的時候調用 builder 傳入的 CacheLoader#load 方法 // mappingFunction 是在 LocalLoadingCache#newMappingFunction 中創建的 newValue[0] = mappingFunction.apply(key); if (newValue[0] == null) { return null; } now[0] = expirationTicker().read(); // builder 沒有指定 weigher 時,這里默認為 SingletonWeigher,總是返回 1 weight[1] = weigher.weigh(key, newValue[0]); n = nodeFactory.newNode(key, keyReferenceQueue(), newValue[0], valueReferenceQueue(), weight[1], now[0]); setVariableTime(n, expireAfterCreate(key, newValue[0], expiry(), now[0])); return n; } // 有值的時候對 node 實例加同步塊 synchronized (n) { nodeKey[0] = n.getKey(); weight[0] = n.getWeight(); oldValue[0] = n.getValue(); // 設置驅逐原因,如果數據有效直接返回 if ((nodeKey[0] == null) || (oldValue[0] == null)) { cause[0] = RemovalCause.COLLECTED; } else if (hasExpired(n, now[0])) { cause[0] = RemovalCause.EXPIRED; } else { return n; } // 默認的配置 writer 是 CacheWriter.disabledWriter(),無操作; // 自己定義的 CacheWriter 一般用于驅逐數據時得到回調進行外部數據源操作 // 詳情可以參考文末的資料 writer.delete(nodeKey[0], oldValue[0], cause[0]); newValue[0] = mappingFunction.apply(key); if (newValue[0] == null) { removed[0] = n; n.retire(); return null; } weight[1] = weigher.weigh(key, newValue[0]); n.setValue(newValue[0], valueReferenceQueue()); n.setWeight(weight[1]); now[0] = expirationTicker().read(); setVariableTime(n, expireAfterCreate(key, newValue[0], expiry(), now[0])); setAccessTime(n, now[0]); setWriteTime(n, now[0]); return n; } }); // 剩下的代碼主要是調用 afterWrite、notifyRemoval 等方法 // 進行后置操作,后置操作中將會再次嘗試緩存驅逐 // ... return newValue[0]; }
看完上面的代碼,遇到這些問題也就心里有數了。
顯式調用 invalid 方法時;弱引用、軟引用可回收時;get 方法老值存在且已完成異步加載后調用 afterRead。
get 方法老值不存在,調用 doComputeIfAbsent 加載完數據后調用 afterWrite。
首先 CacheLoader#load 方法是必須提供的,緩存調用時將是同步操作(回顧上文 data.compute 方法),會阻塞當前線程。
而 CacheLoader#asyncReload 需要配合builder#refreshAfterWrite 使用這樣將在computeIfAbsent->afterRead->refreshIfNeeded 中調用,并異步更新到 data 對象上;并且,load 方法沒有傳入oldValue,而 asyncReload 方法提供了oldValue,這意味著如果觸發 load 操作時,緩存是不能保證 oldValue 是否存在的(可能是首次,也可能是已失效)。
CacheLoader#load 耗時長,將會導致緩存運行過程中查詢數據時阻塞等待加載,當多個線程同時查詢同一個 key 時,業務請求可能阻塞,甚至超時失敗;
CacheLoader#asyncReload 耗時長,在時間周期滿足的情況下,即使耗時長,對業務的影響也較小
首要前提是外部數據查詢能保證單次查詢的性能(一次查詢天長地久那加本地緩存也于事無補);然后,我們在構建 LoadingCache 時,配置 refreshAfterWrite 并在 CacheLoader 實例上定義 asyncReload 方法;
靈魂追問:只有以上兩步就夠了嗎?
機智的我突然覺得事情并不簡單。還有一個時間設置的問題,我們來看看:
如果 expireAfterWrite 周期 < refreshAfterWrite 周期會如何?此時查詢失效數據時總是會調用 load 方法,refreshAfterWrite 根本沒用!
如果 CacheLoader#asyncReload 有額外操作,導致它自身實際執行查詢耗時超過 expireAfterWrite 又會如何?還是 CacheLoader#load 生效,refreshAfterWrite 還是沒用!
所以絲滑的正確打開方式,是 refreshAfterWrite 周期明顯小于 expireAfterWrite 周期,并且 CacheLoader#asyncReload 本身也有較好的性能,才能如絲般順滑地加載數據。此時就會發現業務不斷進行 get 操作,根本感知不到數據加載時的卡頓!
computeIfAbsent 和 doComputeIfAbsent 方法可以看出如果加載結果是 null,那么每次從緩存查詢,都會觸發 mappingFunction.apply,進一步調用 CacheLoader#load。從而流量會直接打到后端數據庫,造成緩存穿透。
防止的方法也比較簡單,在業務可接受的情況下,如果未能查詢到結果,則返回一個非 null 的“假對象”到本地緩存中。
靈魂追問:如果查不到,new 一個對象返回行不行?
key 范圍不大時可以,builder 設置了 size-based 驅逐策略時可以,但都存在消耗較多內存的風險,可以定義一個默認的 PLACE_HOLDER 靜態對象作為引用。
靈魂追問:都用同一個假對象引用真的大丈夫(沒問題)?
這么大的坑本菜鳥怎么能錯過!緩存中存的是對象引用,如果業務 get 后修改了對象的內容,那么其他線程再次獲取到這個對象時,將會得到修改后的值!鬼知道那個深夜定位出這個問題的我有多興奮(蒼蠅搓手)。
當時緩存中保存的是 List<Item>,而不同線程中對這些 item 的 score 進行了不同的 set 操作,導致同一個 item 排序后的分數和順序變幻莫測。本菜鳥一度以為是推薦之神降臨,冥冥中加持 CTR 所以把 score 變來變去。
靈魂追問:那怎么解決緩存被意外修改的問題呢?怎么 copy 一個對象呢?
So easy,就在 get 的時候 copy 一下對象就好了。
靈魂追問4:怎么 copy 一個對象?……停!咱們以后有機會再來說說這個淺拷貝和深拷貝,以及常見的拷貝工具吧,聚焦聚焦……
根據 CacheLoader#load和 CacheLoader#asyncReload 的參數區別,我們可以發現:
應該在 asyncReload 中來處理,如果查詢數據庫異常,則可以返回 oldValue 來繼續使用之前的緩存;否則只能通過 load 方法中返回預留空對象來解決。使用哪一種方法需要根據具體的業務場景來決定。
【踩坑】返回 null 將導致 Caffeine 認為該值不需要緩存,下次查詢還會繼續調用 load 方法,緩存并沒生效。
根據代碼可以知道,已經進入 doComputeIfAbsent 的線程將阻塞在 data.compute 方法上;
比如短時間內有 N 個線程同時 get 相同的 key 并且 key 不存在,則這 N 個線程最終都會反復執行 compute 方法。但只要 data 中該 key 的值更新成功,其他進入 computeIfAbsent 的線程都可直接獲得結果返回,不會出現阻塞等待加載;
所以,如果一開始就有大量請求進入 doComputeIfAbsent 阻塞等待數據,就會造成短時間請求掛起、超時的問題。由此在大流量場景下升級服務時,需要考慮在接入流量前對緩存進行預熱(我查我自己,嗯),防止瞬時請求太多導致大量請求掛起或超時。
靈魂追問:如果一次 load 耗時 100ms,一開始有 10 個線程冷啟動,最終等待時間會是 1s 左右嗎?
其實……要看情況,回顧一下 data.compute 里面的代碼:
if (n == null) { // 這部分代碼其他后續線程進入后已經有值,不再執行 } synchronized (n) { // ... if ((nodeKey[0] == null) || (oldValue[0] == null)) { cause[0] = RemovalCause.COLLECTED; } else if (hasExpired(n, now[0])) { cause[0] = RemovalCause.EXPIRED; } else { // 未失效時在這里返回,不會觸發 load 函數 return n; } // ... }
所以,如果 load 結果不是 null,那么只第一個線程花了 100ms,后續線程會盡快返回,最終時長應該只比 100ms 多一點。但如果 load 結果返回 null(緩存穿透),相當于沒有查到數據,于是后續線程還會再次執行 load,最終時間就是 1s 左右。
到此,關于“如何正確使用Caffeine Cache”的學習就結束了,希望能夠解決大家的疑惑。理論與實踐的搭配能更好的幫助大家學習,快去試試吧!若想繼續學習更多相關知識,請繼續關注億速云網站,小編會繼續努力為大家帶來更多實用的文章!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。