如何優雅的設計和使用緩存?

背景

在以前的文章中你應該知道的緩存進化史介紹了愛奇藝的緩存架構和緩存的進化歷史。俗話說得好,工欲善其事,必先利其器,有了好的工具確定得知道如何用好這些工具,本篇將介紹如何利用好緩存。java

1.確認是否須要緩存

在使用緩存以前,須要確認你的項目是否真的須要緩存。使用緩存會引入的必定的技術複雜度,後文也將會一一介紹這些複雜度。通常來講從兩個方面來個是否須要使用緩存:git

  1. CPU佔用:若是你有某些應用須要消耗大量的cpu去計算,好比正則表達式,若是你使用正則表達式比較頻繁,而其又佔用了不少CPU的話,那你就應該使用緩存將正則表達式的結果給緩存下來。
  2. 數據庫IO佔用:若是你發現你的數據庫鏈接池比較空閒,那麼不該該用緩存。可是若是數據庫鏈接池比較繁忙,甚至常常報出鏈接不夠的報警,那麼是時候應該考慮緩存了。筆者曾經有個服務,被不少其餘服務調用,其餘時間都還好,可是在天天早上10點的時候老是會報出數據庫鏈接池鏈接不夠的報警,通過排查,發現有幾個服務選擇了在10點作定時任務,大量的請求打過來,DB鏈接池不夠,從而報出鏈接池不夠的報警。這個時候有幾個選擇,咱們能夠經過擴容機器來解決,也能夠經過增長數據庫鏈接池來解決,可是沒有必要增長這些成本,由於只有在10點的時候纔會出現這個問題。後來引入了緩存,不只解決了這個問題,並且還增長了讀的性能。

若是並無上述兩個問題,那麼你沒必要爲了增長緩存而緩存。github

2.選擇合適的緩存

緩存又分進程內緩存和分佈式緩存兩種。不少人包括筆者在開始選緩存框架的時候都感到了困惑:網上的緩存太多了,你們都吹噓本身很牛逼,我該怎麼選擇呢?正則表達式

2.1 選擇合適的進程緩存

首先看看幾個比較經常使用的緩存的比較,具體原理能夠參考你應該知道的緩存進化史: 比較項 | ConcurrentHashMap | LRUMap | Ehcache | Guava Cache | Caffeine ---|--- |---|--- |--- |--- 讀寫性能 | 很好,分段鎖|通常,全局加鎖|好|好,須要作淘汰操做|很好 淘汰算法 | 無|LRU,通常|支持多種淘汰算法,LRU,LFU,FIFO|LRU,通常|W-TinyLFU, 很好 功能豐富程度 |功能比較簡單 | 功能比較單一| 功能很豐富| 功能很豐富,支持刷新和虛引用等|功能和Guava Cache相似 工具大小 | jdk自帶類,很小|基於LinkedHashMap,較小|很大,最新版本1.4MB|是Guava工具類中的一個小部分,較小|通常,最新版本644KB 是否持久化 |否 |否|是|否|否 是否支持集羣 |否 |否|是|否|否算法

  • 對於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。sql

2.2 選擇合適的分佈式緩存

這裏選取三個比較出名的分佈式緩存來做爲比較,MemCache(沒有實戰使用過),Redis(在美團又叫Squirrel),Tair(在美團又叫Cellar)。不一樣的分佈式緩存功能特性和實現原理方面有很大的差別,所以他們所適應的場景也有所不一樣。數據庫

比較項 MemCache Squirrel/Redis Cellar/Tair
數據結構 只支持簡單的Key-Value結構 String,Hash, List, Set, Sorted Set String,HashMap, List,Set
持久化 不支持 支持 支持
容量大小 數據純內存,數據存儲不宜過多 數據全內存,資源成本考量不宜超過100GB 能夠配置全內存或內存+磁盤引擎,數據容量可無限擴充
讀寫性能 很高 很高(RT0.5ms左右) String類型比較高(RT1ms左右),複雜類型比較慢(RT5ms左右)
  • MemCache:這一塊接觸得比較少,不作過多的推薦。其吞吐量較大,可是支持的數據結構較少,而且不支持持久化。
  • Redis:支持豐富的數據結構,讀寫性能很高,可是數據全內存,必需要考慮資源成本,支持持久化。
  • Tair: 支持豐富的數據結構,讀寫性能較高,部分類型比較慢,理論上容量能夠無限擴充。

總結:若是服務對延遲比較敏感,Map/Set數據也比較多的話,比較適合Redis。若是服務須要放入緩存量的數據很大,對延遲又不是特別敏感的話,那就能夠選擇Tair。在美團的不少應用中對Tair都有應用,在筆者的項目中使用其存放咱們生成的支付token,支付碼,用來替代數據庫存儲。大部分的狀況下二者均可以選擇,互爲替代。數組

3.多級緩存

不少人一想到緩存立刻腦子裏面就會出現下面的圖:緩存

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

在以前介紹本地緩存的時候,不少人都問我,我已經有Redis了,我幹嗎還須要瞭解Guava,Caffeine這些進程緩存呢。我基本統一回復下面兩個答案:

  1. Redis若是掛了或者使用老版本的Redis,其會進行全量同步,此時Redis是不可用的,這個時候咱們只能訪問數據庫,很容易形成雪崩。
  2. 訪問Redis會有必定的網絡I/O以及序列化反序列化,雖然性能很高可是其終究沒有本地方法快,能夠將最熱的數據存放在本地,以便進一步加快訪問速度。這個思路並非咱們作互聯網架構獨有的,在計算機系統中使用L1,L2,L3多級緩存,用來減小對內存的直接訪問,從而加快訪問速度。

因此若是僅僅是使用Redis,能知足咱們大部分需求,可是當須要追求更高的性能以及更高的可用性的時候,那就不得不瞭解多級緩存。

3.1使用進程緩存

對於進程內緩存,其原本受限於內存的大小的限制,以及進程緩存更新後其餘緩存沒法得知,因此通常來講進程緩存適用於:

  1. 數據量不是很大,數據更新頻率較低,以前咱們有個查詢商家名字的服務,在發送短信的時候須要調用,因爲商家名字變動頻率較低,而且就算是變動了沒有及時變動緩存,短信裏面帶有老的商家名字客戶也能接受。利用Caffeine做爲本地緩存,size設置爲1萬,過時時間設置爲1個小時,基本能在高峯期解決問題。
  2. 若是數據量更新頻繁,也想使用進程緩存的話,那麼能夠將其過時時間設置爲較短,或者設置其較短的自動刷新的時間。這些對於Caffeine或者Guava Cache來講都是現成的API。

3.2使用多級緩存

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

通常來講咱們選擇一個進程緩存和一個分佈式緩存來搭配作多級緩存,通常來講引入兩個也足夠了,若是使用三個,四個的話,技術維護成本會很高,反而有可能會得不償失,以下圖所示:

利用Caffeine作一級緩存,Redis做爲二級緩存。

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

對於Caffeine的緩存,若是有數據更新,只能刪除更新數據的那臺機器上的緩存,其餘機器只能經過超時來過時緩存,超時設定能夠有兩種策略:

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

對於Redis的緩存更新,其餘機器立馬可見,可是也必需要設置超時時間,其時間比Caffeine的過時長。

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

經過Redis的pub/sub,能夠通知其餘進程緩存對此緩存進行刪除。若是Redis掛了或者訂閱機制不靠譜,依靠超時設定,依然能夠作兜底處理。

4.緩存更新

通常來講緩存的更新有兩種狀況:

  • 先刪除緩存,再更新數據庫。
  • 先更新數據庫,再刪除緩存。 這兩種狀況在業界,你們對其都有本身的見解。具體怎麼使用還得看各自的取捨。固然確定會有人問爲何要刪除緩存呢?而不是更新緩存呢?你能夠想一想當有多個併發的請求更新數據,你並不能保證更新數據庫的順序和更新緩存的順序一致,那就會出現數據庫中和緩存中數據不一致的狀況。因此通常來講考慮刪除緩存。

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

對於一個更新操做簡單來講,就是先去各級緩存進行刪除,而後更新數據庫。這個操做有一個比較大的問題,在對緩存刪除完以後,有一個讀請求,這個時候因爲緩存被刪除因此直接會讀庫,讀操做的數據是老的而且會被加載進入緩存當中,後續讀請求所有訪問的老數據。

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

先刪除緩存也有一個好處是,若是對數據庫操做失敗了,那麼因爲先刪除的緩存,最多隻是形成Cache Miss。

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

若是咱們使用更新數據庫,再刪除緩存就能避免上面的問題。可是一樣的引入了新的問題,試想一下有一個數據此時是沒有緩存的,因此查詢請求會直接落庫,更新操做在查詢請求以後,可是更新操做刪除數據庫操做在查詢完以後回填緩存以前,就會致使咱們緩存中和數據庫出現緩存不一致。

爲何咱們這種狀況有問題,不少公司包括Facebook還會選擇呢?由於要觸發這個條件比較苛刻。

  1. 首先須要數據不在緩存中。
  2. 其次查詢操做須要在更新操做先到達數據庫。
  3. 最後查詢操做的回填比更新操做的刪除後觸發,這個條件基本很難出現,由於更新操做的原本在查詢操做以後,通常來講更新操做比查詢操做稍慢。可是更新操做的刪除卻在查詢操做以後,因此這個狀況比較少出現。

對比上面4.1的問題來講這種問題的機率很低,何況咱們有超時機制保底因此基本能知足咱們的需求。若是真的須要追求完美,可使用二階段提交,可是其成本和收益通常來講不成正比。

固然還有個問題是若是咱們刪除失敗了,緩存的數據就會和數據庫的數據不一致,那麼咱們就只能靠過時超時來進行兜底。對此咱們能夠進行優化,若是刪除失敗的話 咱們不能影響主流程那麼咱們能夠將其放入隊列後續進行異步刪除。

5.緩存挖坑三劍客

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

5.1緩存穿透

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

爲了不這個問題,能夠採起下面兩個手段:

  1. 約定:對於返回爲NULL的依然緩存,對於拋出異常的返回不進行緩存,注意不要把拋異常的也給緩存了。採用這種手段的會增長咱們緩存的維護成本,須要在插入緩存的時候刪除這個空緩存,固然咱們能夠經過設置較短的超時時間來解決這個問題。

2. 制定一些規則過濾一些不可能存在的數據,小數據用BitMap,大數據能夠用布隆過濾器,好比你的訂單ID 明顯是在一個範圍1-1000,若是不是1-1000以內的數據那其實能夠直接給過濾掉。

5.2緩存擊穿

對於某些key設置了過時時間,可是其是熱點數據,若是某個key失效,可能大量的請求打過來,緩存未命中,而後去數據庫訪問,此時數據庫訪問量會急劇增長。

爲了不這個問題,咱們能夠採起下面的兩個手段:

  1. 加分佈式鎖:加載數據的時候能夠利用分佈式鎖鎖住這個數據的Key,在Redis中直接使用setNX操做便可,對於獲取到這個鎖的線程,查詢數據庫更新緩存,其餘線程採起重試策略,這樣數據庫不會同時受到不少線程訪問同一條數據。
  2. 異步加載:因爲緩存擊穿是熱點數據纔會出現的問題,能夠對這部分熱點數據採起到期自動刷新的策略,而不是到期自動淘汰。淘汰其實也是爲了數據的時效性,因此採用自動刷新也能夠。

5.3緩存雪崩

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

爲了不這個問題,咱們採起下面的手段:

  1. 增長緩存系統可用性,經過監控關注緩存的健康程度,根據業務量適當的擴容緩存。
  2. 採用多級緩存,不一樣級別緩存設置的超時時間不一樣,及時某個級別緩存都過時,也有其餘級別緩存兜底。
  3. 緩存的過時時間能夠取個隨機值,好比之前是設置10分鐘的超時時間,那每一個Key均可以隨機8-13分鐘過時,儘可能讓不一樣Key的過時時間不一樣。

6.緩存污染

緩存污染通常出如今咱們使用本地緩存中,能夠想象,在本地緩存中若是你得到了緩存,可是你接下來修改了這個數據,可是這個數據並無更新在數據庫,這樣就形成了緩存污染:

上面的代碼就形成了緩存污染,經過id獲取Customer,可是需求須要修改Customer的名字,因此開發人員直接在取出來的對象中直接修改,這個Customer對象就會被污染,其餘線程取出這個數據就是錯誤的數據。

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

7.序列化

序列化是不少人都不注意的一個問題,不少人忽略了序列化的問題,上線以後立刻報出一下奇怪的錯誤異常,形成了沒必要要的損失,最後一排查都是序列化的問題。列舉幾個序列化常見的問題:

  1. key-value對象過於複雜致使序列化不支持:筆者以前出過一個問題,在美團的Tair內部默認是使用protostuff進行序列化,而美團使用的通信框架是thfift,thrift的TO是自動生成的,這個TO裏面不少複雜的數據結構,可是將其存放到了Tair中。查詢的時候反序列化也沒有報錯,單測也經過,可是到qa測試的時候發現這一塊功能有問題,發現有個字段是boolean類型默認是false,把它改爲true以後,序列化到tair中再反序列化仍是false。定位到是protostuff對於複雜結構的對象(好比數組,List<Map>等等)支持不是很好,會形成必定的問題。後來對這個TO進行了轉換,用普通的Java對象就能進行正確的序列化反序列化。
  2. 添加了字段或者刪除了字段,致使上線以後老的緩存獲取的時候反序列化報錯,或者出現一些數據移位。
  3. 不一樣的JVM的序列化不一樣,若是你的緩存有不一樣的服務都在共同使用(不提倡),那麼須要注意不一樣JVM可能會對Class內部的Field排序不一樣,而影響序列化。好比下面的代碼,在Jdk7和Jdk8中對象A的排列順序不一樣,最終會致使反序列化結果出現問題:
//jdk 7
class A{
    int a;
    int b;
}
//jdk 8
class A{
    int b;
    int a;
}

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

  1. 測試:對於序列化須要進行全面的測試,若是有不一樣的服務而且他們的JVM不一樣那麼你也須要作這一塊的測試,在上面的問題中筆者的單測經過的緣由是用的默認數據false,因此根本沒有測試true的狀況,還好QA給力,將其給測試出來了。
  2. 對於不一樣的序列化框架都有本身不一樣的原理,對於添加字段以後若是當前序列化框架不能兼容老的,那麼能夠換個序列化框架。 對於protostuff來講他是按照Field的順序來進行反序列化的,對於添加字段咱們須要放到末尾,也就是不能插在中間,不然會出現錯誤。對於刪除字段來講,用@Deprecated註解進行標註棄用,若是貿然刪除,除非是最後一個字段,不然確定會出現序列化異常。
  3. 可使用雙寫來避免,對於每一個緩存的key值能夠加上版本號,每次上線版本號都加1,好比如今線上的緩存用的是Key_1,即將要上線的是Key_2,上線以後對緩存的添加是會寫新老兩個不一樣的版本(Key_1,Key_2)的Key-Value,讀取數據仍是讀取老版本Key_1的數據,假設以前的緩存的過時時間是半個小時,那麼上線半個小時以後,以前的老緩存存量的數據都會被淘汰,此時線上老緩存和新緩存他們的數據基本是同樣的,切換讀操做到新緩存,而後中止雙寫。採用這種方法基本能平滑過渡新老Model交替,可是很差的點就是須要短暫的維護兩套新老Model,下次上線的時候須要刪除掉老Model,增長了維護成本。

8. GC調優

對於大量使用本地緩存的應用,因爲涉及到緩存淘汰,那麼GC問題一定是常事。若是出現GC較多,STW時間較長,那麼一定會影響服務可用性。這一塊給出下面幾點建議:

  1. 常常查看GC監控,如何發現不正常,須要想辦法對其進行優化。
  2. 對於CMS垃圾收集器,若是發現remark過長,若是是大量本地緩存應用的話這個過長應該很正常,由於在併發階段很容易有不少新對象進入緩存,從而remark階段掃描很耗時,remark又會暫停。能夠開啓-XX:CMSScavengeBeforeRemark,在remark階段前進行一次YGC,從而減小remark階段掃描gc root的開銷。
  3. 可使用G1垃圾收集器,經過-XX:MaxGCPauseMillis設置最大停頓時間,提升服務可用性。

9. 緩存的監控

不少人對於緩存的監控也比較忽略,基本上線以後若是不報錯而後就默認他就生效了。可是存在這個問題,不少人因爲經驗不足,有可能設置了不恰當的過時時間,或者不恰當的緩存大小致使緩存命中率不高,讓緩存就成爲了代碼中的一個裝飾品。因此對於緩存各類指標的監控,也比較重要,經過其不一樣的指標數據,咱們能夠對緩存的參數進行優化,從而讓緩存達到最優化:

上面的代碼中用來記錄get操做的,經過Cat記錄了獲取緩存成功,緩存不存在,緩存過時,緩存失敗(獲取緩存時若是拋出異常,則叫失敗),經過這些指標,咱們就能統計出命中率,咱們調整過時時間和大小的時候就能夠參考這些指標進行優化。

10. 一款好的框架

一個好的劍客沒有一把好劍怎麼行呢?若是要使用好緩存,一個好的框架也必不可少。在最開始使用的時候你們使用緩存都用一些util,把緩存的邏輯寫在業務邏輯中:

上面的代碼把緩存的邏輯耦合在業務邏輯當中,若是咱們要增長成多級緩存那就須要修改咱們的業務邏輯,不符合開閉原則,因此引入一個好的框架是不錯的選擇。

推薦你們使用JetCache這款開源框架,其實現了Java緩存規範JSR107而且支持自動刷新等高級功能。筆者參考JetCache結合Spring Cache, 監控框架Cat以及美團的熔斷限流框架Rhino實現了一套自有的緩存框架,讓操做緩存,打點監控,熔斷降級,業務人員無需關心。上面的代碼能夠優化成:

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

最後

想要真正的使用好一個緩存,必需要掌握不少的知識,並非看幾個Redis原理分析,就能把Redis緩存用得爐火純青。對於不一樣場景,緩存有各自不一樣的用法,一樣的不一樣的緩存也有本身的調優策略,進程內緩存你須要關注的是他的淘汰算法和GC調優,以及要避免緩存污染等。分佈式緩存你須要關注的是他的高可用,若是其不可用瞭如何進行降級,以及一些序列化的問題。一個好的框架也是必不可少的,對其若是使用得當再加上上面介紹的經驗,相信能讓你很好的駕馭住這頭野馬——緩存。

最後這篇文章被我收錄於JGrowing,一個全面,優秀,由社區一塊兒共建的Java學習路線,若是您想參與開源項目的維護,能夠一塊兒共建,github地址爲:https://github.com/javagrowing/JGrowing 麻煩給個小星星喲。

若是你以爲這篇文章對你有文章,能夠關注個人技術公衆號,關注以後便可領取,你的關注和轉發是對我最大的支持,O(∩_∩)O

相關文章
相關標籤/搜索