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

俗話說得好,工欲善其事,必先利其器,有了好的工具確定得知道如何用好這些工具,本篇將分爲以下幾個方面介紹如何利用好緩存:正則表達式

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

你真的須要緩存嗎算法

在使用緩存以前,須要確認你的項目是否真的須要緩存。使用緩存會引入必定的技術複雜度,通常來講從兩個方面來判斷是否須要使用緩存:數據庫

CPU 佔用數組

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

數據庫 IO 佔用bash

若是你發現你的數據庫鏈接池比較空閒,能夠不用緩存。可是若是數據庫鏈接池比較繁忙,甚至常常報出鏈接不夠的報警,那麼是時候應該考慮緩存了。網絡

筆者曾經有個服務被不少其餘服務調用,其餘時間都還好,可是在天天早上 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 的排列順序不一樣,最終會致使反序列化結果出現問題:

//jdk 7 class A{     int a;     int b; } //jdk 8 class A{     int b;     int a; } 複製代碼

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

測試:對於序列化須要進行全面的測試,若是有不一樣的服務而且他們的 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 調優,以及要避免緩存污染等。

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

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

做者:李釗來源:51CTO技術棧
原文地址:http://stor.51cto.com/art/201808/582218.htm

相關文章
相關標籤/搜索