在推薦服務中,雖然容許少許請求因計算超時等緣由返回默認列表。但從運營指標來講,越高的「完算率」意味着越完整的算法效果呈現,也意味着越高的商業收益。(完算率類比視頻的完播率,成功完成整個推薦線上流程計算的請求次數/總請求次數)html
爲了可以儘量快地完成計算,多級緩存方案已經成爲推薦線上服務的標配。其中本地緩存顯得尤其重要,而 Caffeine Cache 就是近幾年脫穎而出的高性能本地緩存庫。Caffeine Cache 已經在 Spring Boot 2.0 中取代了 Google Guava 成爲默認緩存框架,足見其成熟和可靠。java
關於 Caffeine 的介紹文章有不少,再也不累述,可閱讀文末的參考資料瞭解 Caffeine 的簡述、性能基準測試結果、基本 API 用法和 Window-TinyLFU 緩存算法原理等。雖然接觸 Caffeine 的時間不長,但其簡潔的 API 和如絲般順滑的異步加載能力簡直不要太好用。而本菜鳥在使用的過程當中也踩了一些坑,使用不當甚至緩存也能卡得和磁盤 IO 同樣慢。node
通過一番學習嘗試,總算了解到 Caffeine Cache 如絲般順滑的奧祕,總結下來分享一下。git
使用 Caffeine Cache,除了 Spring 中常見的 @EnableCache、@Cacheable 等註解外,直接使用 Caffeine.newBuilder().build() 方法建立 LoadingCache 也是推薦服務經常使用的方式。github
咱們先來看看 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 還使用了其餘編碼小技巧,我們下次有空接着聊。
2.Caffeine Cache-高性能Java本地緩存組件
6.System.identityHashCode(obj)與obj.hashcode
做者:vivo 互聯網服務器團隊-Li Haoxuan