緩存這匹「野馬」,你駕馭得了嗎?

在之前的文章《你應該知道的緩存進化史》中介紹了愛奇藝的緩存架構和緩存的進化歷史。

緩存這匹“野馬”,你駕馭得了嗎?

【51CTO.com原創稿件】俗話說得好,工欲善其事,必先利其器,有了好的工具肯定得知道如何用好這些工具,本篇將分為如下幾個方面介紹如何利用好緩存:

  • 你真的需要緩存嗎
  • 如何選擇合適的緩存
  • 多級緩存
  • 緩存更新
  • 緩存挖坑三劍客
  • 緩存汙染
  • 序列化
  • GC調優
  • 緩存的監控
  • 一款好的框架
  • 總結

你真的需要緩存嗎

在使用緩存之前,需要確認你的項目是否真的需要緩存。使用緩存會引入一定的技術複雜度,一般來說從兩個方面來判斷是否需要使用緩存:

CPU 佔用

如果你有某些應用需要消耗大量的 CPU 去計算,比如正則表達式;如果你使用正則表達式比較頻繁,而它又佔用了很多 CPU 的話,那你就應該使用緩存將正則表達式的結果給緩存下來。

數據庫 IO 佔用

如果你發現你的數據庫連接池比較空閒,可以不用緩存。但是如果數據庫連接池比較繁忙,甚至經常報出連接不夠的報警,那麼是時候應該考慮緩存了。

筆者曾經有個服務被很多其他服務調用,其他時間都還好,但是在每天早上 10 點的時候總是會報出數據庫連接池連接不夠的報警。

經過排查,我發現有幾個服務選擇了在 10 點做定時任務,大量的請求打過來,DB 連接池不夠,從而產生連接池不夠的報警。

這個時候有幾個選擇,我們可以通過擴容機器來解決,也可以通過增加數據庫連接池來解決。

但是沒有必要增加這些成本,因為只有在 10 點的時候才會出現這個問題。後來引入了緩存,不僅解決了這個問題,而且還增加了讀的性能。

如果並沒有上述兩個問題,那麼你不必為了增加緩存而緩存。

如何選擇合適的緩存

緩存分為進程內緩存和分佈式緩存。包括筆者在內的很多人在開始選緩存框架的時候都會感到困惑:網上的緩存太多了,大家都吹噓自己很牛逼,我該怎麼選擇呢?

選擇合適的進程緩存

首先看幾個比較常用緩存的比較,具體原理可以參考《你應該知道的緩存進化史》:

緩存這匹“野馬”,你駕馭得了嗎?

對於 ConcurrentHashMap 來說,比較適合緩存比較固定不變的元素,且緩存的數量較小的。

雖然從上面表格中比起來有點遜色,但是由於它是 JDK 自帶的類,在各種框架中依然有大量的使用。

比如我們可以用來緩存反射的 Method,Field 等等;也可以緩存一些鏈接,防止重複建立。在 Caffeine 中也是使用的 ConcurrentHashMap 來存儲元素。

對於 LRUMap 來說,如果不想引入第三方包,又想使用淘汰算法淘汰數據,可以使用這個。

對於 Ehcache 來說,由於其 jar 包很大,較重量級。對於需要持久化和集群的一些功能的,可以選擇 Ehcache。

筆者沒怎麼使用過這個緩存,如果要選擇的話,可以選擇分佈式緩存來替代 Ehcache。

對於 Guava Cache 來說,Guava 這個 jar 包在很多 Java 應用程序中都有大量的引入。

所以很多時候直接用就好了,並且它本身是輕量級的而且功能較為豐富,在不瞭解 Caffeine 的情況下可以選擇 Guava Cache。

對於 Caffeine 來說,筆者是非常推薦的,它在命中率,讀寫性能上都比 Guava Cache 好很多。

並且它的 API 和 Guava Cache 基本一致,甚至會多一點。在真實環境中使用 Caffeine,取得過不錯的效果。

總結一下:如果不需要淘汰算法則選擇 ConcurrentHashMap;如果需要淘汰算法和一些豐富的 API,這裡推薦選擇 Caffeine。

選擇合適的分佈式緩存

這裡我選取三個比較出名的分佈式緩存來作為比較,MemCache(沒有實戰使用過),Redis(在美團又叫 Squirrel),Tair(在美團又叫 Cellar)。

緩存這匹“野馬”,你駕馭得了嗎?

不同的分佈式緩存功能特性和實現原理方面有很大的差異,因此它們所適應的場景也有所不同:

  • MemCache:這一塊接觸得比較少,不做過多的推薦。其吞吐量較大,但是支持的數據結構較少,並且不支持持久化。
  • Redis:支持豐富的數據結構,讀寫性能很高,但是數據全內存,必須要考慮資源成本,支持持久化。
  • Tair:支持豐富的數據結構,讀寫性能較高,部分類型比較慢,理論上容量可以無限擴充。

總結:如果服務對延遲比較敏感,Map/Set 數據也比較多的話,比較適合 Redis。

如果服務需要放入緩存量的數據很大,對延遲又不是特別敏感的話,那就可以選擇 Tair。

在美團的很多應用中對 Tair 都有應用,在筆者的項目中使用其存放我們生成的支付 Token,支付碼,用來替代數據庫存儲。大部分的情況下兩者都可以選擇,互為替代。

多級緩存

一說到緩存,很多人腦子裡面馬上就會出現下面的圖:

緩存這匹“野馬”,你駕馭得了嗎?

Redis 用來存儲熱點數據,Redis 中沒有的數據則直接去數據庫訪問。

在之前介紹本地緩存的時候,很多人都問我,我已經有 Redis 了,我為什麼還需要了解 Guava,Caffeine 這些進程緩存呢?

我統一回復下,有如下兩個原因:

  • Redis 如果掛了或者使用老版本的 Redis,會進行全量同步,此時 Redis 是不可用的,這個時候我們只能訪問數據庫,很容易造成雪崩。
  • 訪問 Redis 會有一定的網絡 I/O 以及序列化反序列化,雖然性能很高但是終究沒有本地方法快,可以將最熱的數據存放在本地,以便進一步加快訪問速度。

這個思路並不是我們做互聯網架構獨有的,在計算機系統中使用 L1,L2,L3 多級緩存,用來減少對內存的直接訪問,從而加快訪問速度。

緩存這匹“野馬”,你駕馭得了嗎?

所以如果僅僅是使用 Redis,能滿足我們大部分需求,但是當需要追求更高性能以及更高可用性的時候,那就不得不瞭解多級緩存。

使用進程緩存

對於進程內緩存,它本來受限於內存大小的限制,以及進程緩存更新後其他緩存無法得知,所以一般來說進程緩存適用於:

數據量不是很大,數據更新頻率較低,之前我們有個查詢商家名字的服務,在發送短信的時候需要調用,由於商家名字變更頻率較低,並且就算是變更了沒有及時變更緩存,短信裡面帶有老的商家名字客戶也能接受。

利用 Caffeine 作為本地緩存,Size 設置為 1 萬,過期時間設置為 1 個小時,基本能在高峰期解決問題。

如果數據量更新頻繁,也想使用進程緩存的話,那麼可以將其過期時間設置為較短,或者設置其較短的自動刷新的時間。這些對於 Caffeine 或者 Guava Cache 來說都是現成的 API。

使用多級緩存

俗話說得好,世界上沒有什麼是一個緩存解決不了的事,如果有,那就兩個。

一般來說我們選擇一個進程緩存和一個分佈式緩存來搭配做多級緩存,一般來說引入兩個也足夠了。

如果使用三個,四個的話,技術維護成本會很高,反而有可能會得不償失,如下圖所示:

緩存這匹“野馬”,你駕馭得了嗎?

利用 Caffeine 做一級緩存,Redis 作為二級緩存,步驟如下:

  • 首先去 Caffeine 中查詢數據,如果有直接返回。如果沒有則進行第 2 步。
  • 再去 Redis 中查詢,如果查詢到了返回數據並在 Caffeine 中填充此數據。如果沒有查到則進行第 3 步。
  • 最後去 MySQL 中查詢,如果查詢到了返回數據並在 Redis,Caffeine 中依次填充此數據。

對於 Caffeine 的緩存,如果有數據更新,只能刪除更新數據的那臺機器上的緩存,其他機器只能通過超時來過期緩存,超時設定可以有兩種策略:

  • 設置成寫入後多少時間後過期。
  • 設置成寫入後多少時間刷新。

對於 Redis 的緩存更新,其他機器立刻可見,但是也必須要設置超時時間,其時間比 Caffeine 的過期長。

為了解決進程內緩存的問題,設計進一步優化:

緩存這匹“野馬”,你駕馭得了嗎?

通過 Redis 的 Pub/Sub,可以通知其他進程緩存對此緩存進行刪除。如果 Redis 掛了或者訂閱機制不靠譜,依靠超時設定,依然可以做兜底處理。

緩存更新

一般來說緩存的更新有兩種情況:

  • 先刪除緩存,再更新數據庫。
  • 先更新數據庫,再刪除緩存。

這兩種情況在業界,大家都有自己的看法。具體怎麼使用還得看各自的取捨。當然肯定有人會問為什麼要刪除緩存呢?而不是更新緩存呢?

當有多個併發的請求更新數據,你並不能保證更新數據庫的順序和更新緩存的順序一致,那麼就會出現數據庫中和緩存中數據不一致的情況。所以一般來說考慮刪除緩存。

先刪除緩存,再更新數據庫

對於一個更新操作簡單來說,就是先對各級緩存進行刪除,然後更新數據庫。

這個操作有一個比較大的問題,在對緩存刪除完之後,有一個讀請求,這個時候由於緩存被刪除所以直接會讀庫,讀操作的數據是老的並且會被加載進入緩存當中,後續讀請求全部訪問的老數據。

緩存這匹“野馬”,你駕馭得了嗎?

對緩存的操作不論成功失敗都不能阻塞我們對數據庫的操作,那麼很多時候刪除緩存可以用異步的操作,但是先刪除緩存不能很好的適用於這個場景。

先刪除緩存也有一個好處是,如果對數據庫操作失敗了,那麼由於先刪除的緩存,最多隻是造成 Cache Miss。

先更新數據庫,再刪除緩存(推薦)

如果我們使用更新數據庫,再刪除緩存就能避免上面的問題。但是同樣引入了新的問題。

試想一下有一個數據此時是沒有緩存的,所以查詢請求會直接落庫,更新操作在查詢請求之後,但是更新操作刪除數據庫操作在查詢完之後回填緩存之前,就會導致我們緩存中和數據庫出現緩存不一致。

為什麼我們這種情況有問題,很多公司包括 Facebook 還會選擇呢?因為要觸發這個條件比較苛刻:

  • 首先需要數據不在緩存中。
  • 其次查詢操作需要在更新操作先到達數據庫。
  • 最後查詢操作的回填比更新操作的刪除後觸發,這個條件基本很難出現,因為更新操作的本來在查詢操作之後,一般來說更新操作比查詢操作稍慢。但是更新操作的刪除卻在查詢操作之後,所以這個情況比較少出現。

對比上面先刪除緩存,再更新數據庫的問題來說這種問題出現的概率很低,況且我們有超時機制保底所以基本能滿足我們的需求。

如果真的需要追求完美,可以使用二階段提交,但是成本和收益一般來說不成正比。

當然還有個問題是如果我們刪除失敗了,緩存的數據就會和數據庫的數據不一致,那麼我們就只能靠過期超時來進行兜底。

對此我們可以進行優化,如果刪除失敗的話 我們不能影響主流程那麼我們可以將其放入隊列後續進行異步刪除。

緩存挖坑三劍客

大家一聽到緩存有哪些注意事項,首先想到的肯定是緩存穿透,緩存擊穿,緩存雪崩這三個挖坑的小能手,這裡簡單介紹一下他們具體是什麼以及應對的方法。

緩存穿透

緩存穿透是指查詢的數據在數據庫是沒有的,那麼在緩存中自然也沒有,所以在緩存中查不到就會去數據庫查詢,這樣的請求一多,我們數據庫的壓力自然會增大。

為了避免這個問題,可以採取下面兩個手段:

約定:對於返回為 NULL 的依然緩存,對於拋出異常的返回不進行緩存,注意不要把拋異常的也給緩存了。

採用這種手段會增加我們緩存的維護成本,需要在插入緩存的時候刪除這個空緩存,當然我們可以通過設置較短的超時時間來解決這個問題。

緩存這匹“野馬”,你駕馭得了嗎?

制定一些規則過濾一些不可能存在的數據,小數據用 BitMap,大數據可以用布隆過濾器。

比如你的訂單 ID 明顯是在一個範圍 1-1000,如果不是 1-1000 之內的數據那其實可以直接給過濾掉。

緩存這匹“野馬”,你駕馭得了嗎?

緩存擊穿

對於某些 Key 設置了過期時間,但是它是熱點數據,如果某個 Key 失效,可能大量的請求打過來,緩存未命中,然後去數據庫訪問,此時數據庫訪問量會急劇增加。

為了避免這個問題,我們可以採取下面的兩個手段:

  • 加分佈式鎖:加載數據的時候可以利用分佈式鎖鎖住這個數據的 Key,在 Redis 中直接使用 SetNX 操作即可。

對於獲取到這個鎖的線程,查詢數據庫更新緩存,其他線程採取重試策略,這樣數據庫不會同時受到很多線程訪問同一條數據。

  • 異步加載:由於緩存擊穿是熱點數據才會出現的問題,可以對這部分熱點數據採取到期自動刷新的策略,而不是到期自動淘汰。淘汰也是為了數據的時效性,所以採用自動刷新也可以。

緩存雪崩

緩存雪崩是指緩存不可用或者大量緩存由於超時時間相同在同一時間段失效,大量請求直接訪問數據庫,數據庫壓力過大導致系統雪崩。

為了避免這個問題,我們採取下面的手段:

  • 增加緩存系統可用性,通過監控關注緩存的健康程度,根據業務量適當的擴容緩存。
  • 採用多級緩存,不同級別緩存設置的超時時間不同,即使某個級別緩存都過期,也有其他級別緩存兜底。
  • 緩存的 Key 值可以取個隨機值,比如以前是設置 10 分鐘的超時時間,那每個 Key 都可以隨機 8-13 分鐘過期,儘量讓不同 Key 的過期時間不同。

緩存汙染

緩存汙染一般出現在我們使用本地緩存中。可以想象,在本地緩存中如果你獲得了緩存,但是你接下來修改了這個數據,這個數據卻並沒有更新在數據庫,這樣就造成了緩存汙染:

緩存這匹“野馬”,你駕馭得了嗎?

上面的代碼就造成了緩存汙染,通過 ID 獲取 Customer,但是需求需要修改 Customer 的名字。

所以開發人員直接在取出來的對象中直接修改,這個 Customer 對象就會被汙染,其他線程取出這個數據就是錯誤的數據。

要想避免這個問題需要開發人員從編碼上注意,並且代碼必須經過嚴格的 Review,以及全方位的迴歸測試,才能從一定程度上解決這個問題。

序列化

序列化是很多人都不注意的一個問題,很多人忽略了序列化的問題,上線之後馬上報出一下奇怪的錯誤異常,造成了不必要的損失,最後一排查都是序列化的問題。

列舉幾個序列化常見的問題:

Key-Value 對象過於複雜導致序列化不支持:筆者之前出過一個問題,在美團的 Tair 內部默認是使用 protostuff 進行序列化。

而美團使用的通訊框架是 thfift,thrift 的 TO 是自動生成的,這個 TO 裡面有很多複雜的數據結構,但是將它存放到了 Tair 中。

查詢的時候反序列化也沒有報錯,單測也通過,但是到 QA 測試的時候發現這一塊功能有問題,有個字段是 boolean 類型默認是 False,把它改成 true 之後,序列化到 Tair 中再反序列化還是 False。

定位到是 protostuff 對於複雜結構的對象(比如數組,List 等等)支持不是很好,會造成一定的問題。

後來對這個 TO 進行了轉換,用普通的 Java 對象就能進行正確的序列化反序列化。

添加了字段或者刪除了字段,導致上線之後老的緩存獲取的時候反序列化報錯,或者出現一些數據移位。

不同的 JVM 的序列化不同,如果你的緩存有不同的服務都在共同使用(不提倡),那麼需要注意不同 JVM 可能會對 Class 內部的 Field 排序不同,而影響序列化。

比如(舉例,實際情況不一定如此)下面的代碼,在 JDK7 和 JDK8 中對象 A 的排列順序不同,最終會導致反序列化結果出現問題:

  1. //jdk 7
  2. class A{
  3. int a;
  4. int b;
  5. }
  6. //jdk 8
  7. class A{
  8. int b;
  9. int a;
  10. }

序列化的問題必須得到重視,解決的辦法有如下幾點:

測試:對於序列化需要進行全面的測試,如果有不同的服務並且他們的 JVM 不同,那麼你也需要做這一塊的測試。

在上面的問題中筆者的單測通過的原因是用的默認數據 False,所以根本沒有測試 true 的情況,還好 QA 給力,將它給測試出來了。

對於不同的序列化框架都有自己不同的原理,對於添加字段之後如果當前序列化框架不能兼容老的,那麼可以換個序列化框架。

對於 protostuff 來說它是按照 Field 的順序來進行反序列化的,對於添加字段我們需要放到末尾,也就是不能插在中間,否則會出現錯誤。

對於刪除字段來說,用 @Deprecated 註解進行標註棄用,如果貿然刪除,除非是最後一個字段,否則肯定會出現序列化異常。

可以使用雙寫來避免,對於每個緩存的 Key 值可以加上版本號,每次上線版本號都加 1。

比如現在線上的緩存用的是 Key_1,即將要上線的是 Key_2,上線之後對緩存的添加是會寫新老兩個不同的版本(Key_1,Key_2)的 Key-Value,讀取數據還是讀取老版本 Key_1 的數據。

假設之前的緩存的過期時間是半個小時,那麼上線半個小時之後,之前的老緩存存量的數據都會被淘汰,此時線上老緩存和新緩存的數據基本是一樣的,切換讀操作到新緩存,然後停止雙寫。

採用這種方法基本能平滑過渡新老 Model 交替,但是不好的就是需要短暫的維護兩套新老 Model,下次上線的時候需要刪除掉老 Model,這樣增加了維護成本。

GC 調優

對於大量使用本地緩存的應用,由於涉及到緩存淘汰,那麼 GC 問題必定是常事。如果出現 GC 較多,STW 時間較長,那麼必定會影響服務可用性。

這一塊給出下面幾點建議:

  • 經常查看 GC 監控,如何發現不正常,需要想辦法對其進行優化。
  • 對於 CMS 垃圾收集算法,如果發現 Remark 過長,如果是大量本地緩存應用的話這個過長應該很正常,因為在併發階段很容易有很多新對象進入緩存,從而 Remark 階段掃描很耗時,Remark 又會暫停。

可以開啟 XX:CMSScavengeBeforeRemark,在 Remark 階段前進行一次 YGC,從而減少 Remark 階段掃描 GC Root 的開銷。

  • 可以使用 G1 垃圾收集算法,通過 XX:MaxGCPauseMillis 設置最大停頓時間,提高服務可用性。

緩存的監控

很多人對於緩存的監控也比較忽略,基本上線之後如果不報錯,然後就默認它就生效了。

但是存在這個問題,很多人由於經驗不足,有可能設置了不恰當的過期時間,或者不恰當的緩存大小導致緩存命中率不高,讓緩存成為了代碼中的一個裝飾品。

所以對於緩存各種指標的監控,也比較重要,通過不同的指標數據,我們可以對緩存的參數進行優化,從而讓緩存達到最優化:

上面的代碼中用來記錄 Get 操作的,通過 Cat 記錄了獲取緩存成功,緩存不存在,緩存過期,緩存失敗(獲取緩存時如果拋出異常,則叫失敗)。

通過這些指標,我們就能統計出命中率,我們調整過期時間和大小的時候就可以參考這些指標進行優化。

緩存這匹“野馬”,你駕馭得了嗎?

一款好的框架

一個好的劍客沒有一把好劍怎麼行呢?如果要使用好緩存,一個好的框架也必不可少。

在最開始使用的時候,大家使用緩存都用一些 util,把緩存的邏輯寫在業務邏輯中:

緩存這匹“野馬”,你駕馭得了嗎?

上面的代碼把緩存的邏輯耦合在業務邏輯當中,如果我們要增加成多級緩存那就需要修改我們的業務邏輯,不符合開閉原則,所以引入一個好的框架是不錯的選擇。

推薦大家使用 JetCache 這款開源框架,它實現了 Java 緩存規範 JSR107 並且支持自動刷新等高級功能。

筆者參考 JetCache 結合 Spring Cache,監控框架 Cat 以及美團的熔斷限流框架 Rhino 實現了一套自有的緩存框架,讓操作緩存,打點監控,熔斷降級,業務人員無需關心。

上面的代碼可以優化成:

緩存這匹“野馬”,你駕馭得了嗎?

對於一些監控數據也能輕鬆從大盤上看到:

緩存這匹“野馬”,你駕馭得了嗎?

總結

想要真正的使用好一個緩存,必須要掌握很多的知識,並不是看幾個 Redis 原理分析,就能把 Redis 緩存用得爐火純青。

對於不同場景,緩存有各自不同的用法,同樣的不同的緩存也有自己的調優策略,進程內緩存你需要關注的是它的淘汰算法和 GC 調優,以及要避免緩存汙染等。

分佈式緩存你需要關注的是它的高可用,如果它不可用了,如何進行降級,以及一些序列化的問題。

一個好的框架也是必不可少的,對它如果使用得當再加上上面介紹的經驗,相信能讓你很好的駕馭住這頭野馬——緩存。


分享到:


相關文章: