緩存這匹“野馬”,你駕馭得了嗎?
原創(chuàng)在之前的文章《你應(yīng)該知道的緩存進化史》中介紹了愛奇藝的緩存架構(gòu)和緩存的進化歷史。
【51CTO.com原創(chuàng)稿件】俗話說得好,工欲善其事,必先利其器,有了好的工具肯定得知道如何用好這些工具,本篇將分為如下幾個方面介紹如何利用好緩存:
- 你真的需要緩存嗎
- 如何選擇合適的緩存
- 多級緩存
- 緩存更新
- 緩存挖坑三劍客
- 緩存污染
- 序列化
- GC調(diào)優(yōu)
- 緩存的監(jiān)控
- 一款好的框架
- 總結(jié)
你真的需要緩存嗎
在使用緩存之前,需要確認(rèn)你的項目是否真的需要緩存。使用緩存會引入一定的技術(shù)復(fù)雜度,一般來說從兩個方面來判斷是否需要使用緩存:
CPU 占用
如果你有某些應(yīng)用需要消耗大量的 CPU 去計算,比如正則表達式;如果你使用正則表達式比較頻繁,而它又占用了很多 CPU 的話,那你就應(yīng)該使用緩存將正則表達式的結(jié)果給緩存下來。
數(shù)據(jù)庫 IO 占用
如果你發(fā)現(xiàn)你的數(shù)據(jù)庫連接池比較空閑,可以不用緩存。但是如果數(shù)據(jù)庫連接池比較繁忙,甚至經(jīng)常報出連接不夠的報警,那么是時候應(yīng)該考慮緩存了。
筆者曾經(jīng)有個服務(wù)被很多其他服務(wù)調(diào)用,其他時間都還好,但是在每天早上 10 點的時候總是會報出數(shù)據(jù)庫連接池連接不夠的報警。
經(jīng)過排查,我發(fā)現(xiàn)有幾個服務(wù)選擇了在 10 點做定時任務(wù),大量的請求打過來,DB 連接池不夠,從而產(chǎn)生連接池不夠的報警。
這個時候有幾個選擇,我們可以通過擴容機器來解決,也可以通過增加數(shù)據(jù)庫連接池來解決。
但是沒有必要增加這些成本,因為只有在 10 點的時候才會出現(xiàn)這個問題。后來引入了緩存,不僅解決了這個問題,而且還增加了讀的性能。
如果并沒有上述兩個問題,那么你不必為了增加緩存而緩存。
如何選擇合適的緩存
緩存分為進程內(nèi)緩存和分布式緩存。包括筆者在內(nèi)的很多人在開始選緩存框架的時候都會感到困惑:網(wǎng)上的緩存太多了,大家都吹噓自己很牛逼,我該怎么選擇呢?
選擇合適的進程緩存
首先看幾個比較常用緩存的比較,具體原理可以參考《你應(yīng)該知道的緩存進化史》:
對于 ConcurrentHashMap 來說,比較適合緩存比較固定不變的元素,且緩存的數(shù)量較小的。
雖然從上面表格中比起來有點遜色,但是由于它是 JDK 自帶的類,在各種框架中依然有大量的使用。
比如我們可以用來緩存反射的 Method,F(xiàn)ield 等等;也可以緩存一些鏈接,防止重復(fù)建立。在 Caffeine 中也是使用的 ConcurrentHashMap 來存儲元素。
對于 LRUMap 來說,如果不想引入第三方包,又想使用淘汰算法淘汰數(shù)據(jù),可以使用這個。
對于 Ehcache 來說,由于其 jar 包很大,較重量級。對于需要持久化和集群的一些功能的,可以選擇 Ehcache。
筆者沒怎么使用過這個緩存,如果要選擇的話,可以選擇分布式緩存來替代 Ehcache。
對于 Guava Cache 來說,Guava 這個 jar 包在很多 Java 應(yīng)用程序中都有大量的引入。
所以很多時候直接用就好了,并且它本身是輕量級的而且功能較為豐富,在不了解 Caffeine 的情況下可以選擇 Guava Cache。
對于 Caffeine 來說,筆者是非常推薦的,它在***率,讀寫性能上都比 Guava Cache 好很多。
并且它的 API 和 Guava Cache 基本一致,甚至?xí)嘁稽c。在真實環(huán)境中使用 Caffeine,取得過不錯的效果。
總結(jié)一下:如果不需要淘汰算法則選擇 ConcurrentHashMap;如果需要淘汰算法和一些豐富的 API,這里推薦選擇 Caffeine。
選擇合適的分布式緩存
這里我選取三個比較出名的分布式緩存來作為比較,MemCache(沒有實戰(zhàn)使用過),Redis(在美團又叫 Squirrel),Tair(在美團又叫 Cellar)。
不同的分布式緩存功能特性和實現(xiàn)原理方面有很大的差異,因此它們所適應(yīng)的場景也有所不同:
- MemCache:這一塊接觸得比較少,不做過多的推薦。其吞吐量較大,但是支持的數(shù)據(jù)結(jié)構(gòu)較少,并且不支持持久化。
- Redis:支持豐富的數(shù)據(jù)結(jié)構(gòu),讀寫性能很高,但是數(shù)據(jù)全內(nèi)存,必須要考慮資源成本,支持持久化。
- Tair:支持豐富的數(shù)據(jù)結(jié)構(gòu),讀寫性能較高,部分類型比較慢,理論上容量可以***擴充。
總結(jié):如果服務(wù)對延遲比較敏感,Map/Set 數(shù)據(jù)也比較多的話,比較適合 Redis。
如果服務(wù)需要放入緩存量的數(shù)據(jù)很大,對延遲又不是特別敏感的話,那就可以選擇 Tair。
在美團的很多應(yīng)用中對 Tair 都有應(yīng)用,在筆者的項目中使用其存放我們生成的支付 Token,支付碼,用來替代數(shù)據(jù)庫存儲。大部分的情況下兩者都可以選擇,互為替代。
多級緩存
一說到緩存,很多人腦子里面馬上就會出現(xiàn)下面的圖:
Redis 用來存儲熱點數(shù)據(jù),Redis 中沒有的數(shù)據(jù)則直接去數(shù)據(jù)庫訪問。
在之前介紹本地緩存的時候,很多人都問我,我已經(jīng)有 Redis 了,我為什么還需要了解 Guava,Caffeine 這些進程緩存呢?
我統(tǒng)一回復(fù)下,有如下兩個原因:
- Redis 如果掛了或者使用老版本的 Redis,會進行全量同步,此時 Redis 是不可用的,這個時候我們只能訪問數(shù)據(jù)庫,很容易造成雪崩。
- 訪問 Redis 會有一定的網(wǎng)絡(luò) I/O 以及序列化反序列化,雖然性能很高但是終究沒有本地方法快,可以將最熱的數(shù)據(jù)存放在本地,以便進一步加快訪問速度。
這個思路并不是我們做互聯(lián)網(wǎng)架構(gòu)獨有的,在計算機系統(tǒng)中使用 L1,L2,L3 多級緩存,用來減少對內(nèi)存的直接訪問,從而加快訪問速度。
所以如果僅僅是使用 Redis,能滿足我們大部分需求,但是當(dāng)需要追求更高性能以及更高可用性的時候,那就不得不了解多級緩存。
使用進程緩存
對于進程內(nèi)緩存,它本來受限于內(nèi)存大小的限制,以及進程緩存更新后其他緩存無法得知,所以一般來說進程緩存適用于:
數(shù)據(jù)量不是很大,數(shù)據(jù)更新頻率較低,之前我們有個查詢商家名字的服務(wù),在發(fā)送短信的時候需要調(diào)用,由于商家名字變更頻率較低,并且就算是變更了沒有及時變更緩存,短信里面帶有老的商家名字客戶也能接受。
利用 Caffeine 作為本地緩存,Size 設(shè)置為 1 萬,過期時間設(shè)置為 1 個小時,基本能在高峰期解決問題。
如果數(shù)據(jù)量更新頻繁,也想使用進程緩存的話,那么可以將其過期時間設(shè)置為較短,或者設(shè)置其較短的自動刷新的時間。這些對于 Caffeine 或者 Guava Cache 來說都是現(xiàn)成的 API。
使用多級緩存
俗話說得好,世界上沒有什么是一個緩存解決不了的事,如果有,那就兩個。
一般來說我們選擇一個進程緩存和一個分布式緩存來搭配做多級緩存,一般來說引入兩個也足夠了。
如果使用三個,四個的話,技術(shù)維護成本會很高,反而有可能會得不償失,如下圖所示:
利用 Caffeine 做一級緩存,Redis 作為二級緩存,步驟如下:
- 首先去 Caffeine 中查詢數(shù)據(jù),如果有直接返回。如果沒有則進行第 2 步。
- 再去 Redis 中查詢,如果查詢到了返回數(shù)據(jù)并在 Caffeine 中填充此數(shù)據(jù)。如果沒有查到則進行第 3 步。
- ***去 MySQL 中查詢,如果查詢到了返回數(shù)據(jù)并在 Redis,Caffeine 中依次填充此數(shù)據(jù)。
對于 Caffeine 的緩存,如果有數(shù)據(jù)更新,只能刪除更新數(shù)據(jù)的那臺機器上的緩存,其他機器只能通過超時來過期緩存,超時設(shè)定可以有兩種策略:
- 設(shè)置成寫入后多少時間后過期。
- 設(shè)置成寫入后多少時間刷新。
對于 Redis 的緩存更新,其他機器立刻可見,但是也必須要設(shè)置超時時間,其時間比 Caffeine 的過期長。
為了解決進程內(nèi)緩存的問題,設(shè)計進一步優(yōu)化:
通過 Redis 的 Pub/Sub,可以通知其他進程緩存對此緩存進行刪除。如果 Redis 掛了或者訂閱機制不靠譜,依靠超時設(shè)定,依然可以做兜底處理。
緩存更新
一般來說緩存的更新有兩種情況:
- 先刪除緩存,再更新數(shù)據(jù)庫。
- 先更新數(shù)據(jù)庫,再刪除緩存。
這兩種情況在業(yè)界,大家都有自己的看法。具體怎么使用還得看各自的取舍。當(dāng)然肯定有人會問為什么要刪除緩存呢?而不是更新緩存呢?
當(dāng)有多個并發(fā)的請求更新數(shù)據(jù),你并不能保證更新數(shù)據(jù)庫的順序和更新緩存的順序一致,那么就會出現(xiàn)數(shù)據(jù)庫中和緩存中數(shù)據(jù)不一致的情況。所以一般來說考慮刪除緩存。
先刪除緩存,再更新數(shù)據(jù)庫
對于一個更新操作簡單來說,就是先對各級緩存進行刪除,然后更新數(shù)據(jù)庫。
這個操作有一個比較大的問題,在對緩存刪除完之后,有一個讀請求,這個時候由于緩存被刪除所以直接會讀庫,讀操作的數(shù)據(jù)是老的并且會被加載進入緩存當(dāng)中,后續(xù)讀請求全部訪問的老數(shù)據(jù)。
對緩存的操作不論成功失敗都不能阻塞我們對數(shù)據(jù)庫的操作,那么很多時候刪除緩存可以用異步的操作,但是先刪除緩存不能很好的適用于這個場景。
先刪除緩存也有一個好處是,如果對數(shù)據(jù)庫操作失敗了,那么由于先刪除的緩存,最多只是造成 Cache Miss。
先更新數(shù)據(jù)庫,再刪除緩存(推薦)
如果我們使用更新數(shù)據(jù)庫,再刪除緩存就能避免上面的問題。但是同樣引入了新的問題。
試想一下有一個數(shù)據(jù)此時是沒有緩存的,所以查詢請求會直接落庫,更新操作在查詢請求之后,但是更新操作刪除數(shù)據(jù)庫操作在查詢完之后回填緩存之前,就會導(dǎo)致我們緩存中和數(shù)據(jù)庫出現(xiàn)緩存不一致。
為什么我們這種情況有問題,很多公司包括 Facebook 還會選擇呢?因為要觸發(fā)這個條件比較苛刻:
- 首先需要數(shù)據(jù)不在緩存中。
- 其次查詢操作需要在更新操作先到達數(shù)據(jù)庫。
- ***查詢操作的回填比更新操作的刪除后觸發(fā),這個條件基本很難出現(xiàn),因為更新操作的本來在查詢操作之后,一般來說更新操作比查詢操作稍慢。但是更新操作的刪除卻在查詢操作之后,所以這個情況比較少出現(xiàn)。
對比上面先刪除緩存,再更新數(shù)據(jù)庫的問題來說這種問題出現(xiàn)的概率很低,況且我們有超時機制保底所以基本能滿足我們的需求。
如果真的需要追求***,可以使用二階段提交,但是成本和收益一般來說不成正比。
當(dāng)然還有個問題是如果我們刪除失敗了,緩存的數(shù)據(jù)就會和數(shù)據(jù)庫的數(shù)據(jù)不一致,那么我們就只能靠過期超時來進行兜底。
對此我們可以進行優(yōu)化,如果刪除失敗的話 我們不能影響主流程那么我們可以將其放入隊列后續(xù)進行異步刪除。
緩存挖坑三劍客
大家一聽到緩存有哪些注意事項,首先想到的肯定是緩存穿透,緩存擊穿,緩存雪崩這三個挖坑的小能手,這里簡單介紹一下他們具體是什么以及應(yīng)對的方法。
緩存穿透
緩存穿透是指查詢的數(shù)據(jù)在數(shù)據(jù)庫是沒有的,那么在緩存中自然也沒有,所以在緩存中查不到就會去數(shù)據(jù)庫查詢,這樣的請求一多,我們數(shù)據(jù)庫的壓力自然會增大。
為了避免這個問題,可以采取下面兩個手段:
約定:對于返回為 NULL 的依然緩存,對于拋出異常的返回不進行緩存,注意不要把拋異常的也給緩存了。
采用這種手段會增加我們緩存的維護成本,需要在插入緩存的時候刪除這個空緩存,當(dāng)然我們可以通過設(shè)置較短的超時時間來解決這個問題。
制定一些規(guī)則過濾一些不可能存在的數(shù)據(jù),小數(shù)據(jù)用 BitMap,大數(shù)據(jù)可以用布隆過濾器。
比如你的訂單 ID 明顯是在一個范圍 1-1000,如果不是 1-1000 之內(nèi)的數(shù)據(jù)那其實可以直接給過濾掉。
緩存擊穿
對于某些 Key 設(shè)置了過期時間,但是它是熱點數(shù)據(jù),如果某個 Key 失效,可能大量的請求打過來,緩存未***,然后去數(shù)據(jù)庫訪問,此時數(shù)據(jù)庫訪問量會急劇增加。
為了避免這個問題,我們可以采取下面的兩個手段:
- 加分布式鎖:加載數(shù)據(jù)的時候可以利用分布式鎖鎖住這個數(shù)據(jù)的 Key,在 Redis 中直接使用 SetNX 操作即可。
對于獲取到這個鎖的線程,查詢數(shù)據(jù)庫更新緩存,其他線程采取重試策略,這樣數(shù)據(jù)庫不會同時受到很多線程訪問同一條數(shù)據(jù)。
- 異步加載:由于緩存擊穿是熱點數(shù)據(jù)才會出現(xiàn)的問題,可以對這部分熱點數(shù)據(jù)采取到期自動刷新的策略,而不是到期自動淘汰。淘汰也是為了數(shù)據(jù)的時效性,所以采用自動刷新也可以。
緩存雪崩
緩存雪崩是指緩存不可用或者大量緩存由于超時時間相同在同一時間段失效,大量請求直接訪問數(shù)據(jù)庫,數(shù)據(jù)庫壓力過大導(dǎo)致系統(tǒng)雪崩。
為了避免這個問題,我們采取下面的手段:
- 增加緩存系統(tǒng)可用性,通過監(jiān)控關(guān)注緩存的健康程度,根據(jù)業(yè)務(wù)量適當(dāng)?shù)臄U容緩存。
- 采用多級緩存,不同級別緩存設(shè)置的超時時間不同,即使某個級別緩存都過期,也有其他級別緩存兜底。
- 緩存的 Key 值可以取個隨機值,比如以前是設(shè)置 10 分鐘的超時時間,那每個 Key 都可以隨機 8-13 分鐘過期,盡量讓不同 Key 的過期時間不同。
緩存污染
緩存污染一般出現(xiàn)在我們使用本地緩存中。可以想象,在本地緩存中如果你獲得了緩存,但是你接下來修改了這個數(shù)據(jù),這個數(shù)據(jù)卻并沒有更新在數(shù)據(jù)庫,這樣就造成了緩存污染:
上面的代碼就造成了緩存污染,通過 ID 獲取 Customer,但是需求需要修改 Customer 的名字。
所以開發(fā)人員直接在取出來的對象中直接修改,這個 Customer 對象就會被污染,其他線程取出這個數(shù)據(jù)就是錯誤的數(shù)據(jù)。
要想避免這個問題需要開發(fā)人員從編碼上注意,并且代碼必須經(jīng)過嚴(yán)格的 Review,以及全方位的回歸測試,才能從一定程度上解決這個問題。
序列化
序列化是很多人都不注意的一個問題,很多人忽略了序列化的問題,上線之后馬上報出一下奇怪的錯誤異常,造成了不必要的損失,***一排查都是序列化的問題。
列舉幾個序列化常見的問題:
Key-Value 對象過于復(fù)雜導(dǎo)致序列化不支持:筆者之前出過一個問題,在美團的 Tair 內(nèi)部默認(rèn)是使用 protostuff 進行序列化。
而美團使用的通訊框架是 thfift,thrift 的 TO 是自動生成的,這個 TO 里面有很多復(fù)雜的數(shù)據(jù)結(jié)構(gòu),但是將它存放到了 Tair 中。
查詢的時候反序列化也沒有報錯,單測也通過,但是到 QA 測試的時候發(fā)現(xiàn)這一塊功能有問題,有個字段是 boolean 類型默認(rèn)是 False,把它改成 true 之后,序列化到 Tair 中再反序列化還是 False。
定位到是 protostuff 對于復(fù)雜結(jié)構(gòu)的對象(比如數(shù)組,List 等等)支持不是很好,會造成一定的問題。
后來對這個 TO 進行了轉(zhuǎn)換,用普通的 Java 對象就能進行正確的序列化反序列化。
添加了字段或者刪除了字段,導(dǎo)致上線之后老的緩存獲取的時候反序列化報錯,或者出現(xiàn)一些數(shù)據(jù)移位。
不同的 JVM 的序列化不同,如果你的緩存有不同的服務(wù)都在共同使用(不提倡),那么需要注意不同 JVM 可能會對 Class 內(nèi)部的 Field 排序不同,而影響序列化。
比如(舉例,實際情況不一定如此)下面的代碼,在 JDK7 和 JDK8 中對象 A 的排列順序不同,最終會導(dǎo)致反序列化結(jié)果出現(xiàn)問題:
- //jdk 7
- class A{
- int a;
- int b;
- }
- //jdk 8
- class A{
- int b;
- int a;
- }
序列化的問題必須得到重視,解決的辦法有如下幾點:
測試:對于序列化需要進行全面的測試,如果有不同的服務(wù)并且他們的 JVM 不同,那么你也需要做這一塊的測試。
在上面的問題中筆者的單測通過的原因是用的默認(rèn)數(shù)據(jù) False,所以根本沒有測試 true 的情況,還好 QA 給力,將它給測試出來了。
對于不同的序列化框架都有自己不同的原理,對于添加字段之后如果當(dāng)前序列化框架不能兼容老的,那么可以換個序列化框架。
對于 protostuff 來說它是按照 Field 的順序來進行反序列化的,對于添加字段我們需要放到末尾,也就是不能插在中間,否則會出現(xiàn)錯誤。
對于刪除字段來說,用 @Deprecated 注解進行標(biāo)注棄用,如果貿(mào)然刪除,除非是***一個字段,否則肯定會出現(xiàn)序列化異常。
可以使用雙寫來避免,對于每個緩存的 Key 值可以加上版本號,每次上線版本號都加 1。
比如現(xiàn)在線上的緩存用的是 Key_1,即將要上線的是 Key_2,上線之后對緩存的添加是會寫新老兩個不同的版本(Key_1,Key_2)的 Key-Value,讀取數(shù)據(jù)還是讀取老版本 Key_1 的數(shù)據(jù)。
假設(shè)之前的緩存的過期時間是半個小時,那么上線半個小時之后,之前的老緩存存量的數(shù)據(jù)都會被淘汰,此時線上老緩存和新緩存的數(shù)據(jù)基本是一樣的,切換讀操作到新緩存,然后停止雙寫。
采用這種方法基本能平滑過渡新老 Model 交替,但是不好的就是需要短暫的維護兩套新老 Model,下次上線的時候需要刪除掉老 Model,這樣增加了維護成本。
GC 調(diào)優(yōu)
對于大量使用本地緩存的應(yīng)用,由于涉及到緩存淘汰,那么 GC 問題必定是常事。如果出現(xiàn) GC 較多,STW 時間較長,那么必定會影響服務(wù)可用性。
這一塊給出下面幾點建議:
- 經(jīng)常查看 GC 監(jiān)控,如何發(fā)現(xiàn)不正常,需要想辦法對其進行優(yōu)化。
- 對于 CMS 垃圾收集算法,如果發(fā)現(xiàn) Remark 過長,如果是大量本地緩存應(yīng)用的話這個過長應(yīng)該很正常,因為在并發(fā)階段很容易有很多新對象進入緩存,從而 Remark 階段掃描很耗時,Remark 又會暫停。
可以開啟 XX:CMSScavengeBeforeRemark,在 Remark 階段前進行一次 YGC,從而減少 Remark 階段掃描 GC Root 的開銷。
- 可以使用 G1 垃圾收集算法,通過 XX:MaxGCPauseMillis 設(shè)置***停頓時間,提高服務(wù)可用性。
緩存的監(jiān)控
很多人對于緩存的監(jiān)控也比較忽略,基本上線之后如果不報錯,然后就默認(rèn)它就生效了。
但是存在這個問題,很多人由于經(jīng)驗不足,有可能設(shè)置了不恰當(dāng)?shù)倪^期時間,或者不恰當(dāng)?shù)木彺娲笮?dǎo)致緩存***率不高,讓緩存成為了代碼中的一個裝飾品。
所以對于緩存各種指標(biāo)的監(jiān)控,也比較重要,通過不同的指標(biāo)數(shù)據(jù),我們可以對緩存的參數(shù)進行優(yōu)化,從而讓緩存達到***化:
上面的代碼中用來記錄 Get 操作的,通過 Cat 記錄了獲取緩存成功,緩存不存在,緩存過期,緩存失敗(獲取緩存時如果拋出異常,則叫失敗)。
通過這些指標(biāo),我們就能統(tǒng)計出***率,我們調(diào)整過期時間和大小的時候就可以參考這些指標(biāo)進行優(yōu)化。
一款好的框架
一個好的劍客沒有一把好劍怎么行呢?如果要使用好緩存,一個好的框架也必不可少。
在最開始使用的時候,大家使用緩存都用一些 util,把緩存的邏輯寫在業(yè)務(wù)邏輯中:
上面的代碼把緩存的邏輯耦合在業(yè)務(wù)邏輯當(dāng)中,如果我們要增加成多級緩存那就需要修改我們的業(yè)務(wù)邏輯,不符合開閉原則,所以引入一個好的框架是不錯的選擇。
推薦大家使用 JetCache 這款開源框架,它實現(xiàn)了 Java 緩存規(guī)范 JSR107 并且支持自動刷新等高級功能。
筆者參考 JetCache 結(jié)合 Spring Cache,監(jiān)控框架 Cat 以及美團的熔斷限流框架 Rhino 實現(xiàn)了一套自有的緩存框架,讓操作緩存,打點監(jiān)控,熔斷降級,業(yè)務(wù)人員無需關(guān)心。
上面的代碼可以優(yōu)化成:
對于一些監(jiān)控數(shù)據(jù)也能輕松從大盤上看到:
總結(jié)
想要真正的使用好一個緩存,必須要掌握很多的知識,并不是看幾個 Redis 原理分析,就能把 Redis 緩存用得爐火純青。
對于不同場景,緩存有各自不同的用法,同樣的不同的緩存也有自己的調(diào)優(yōu)策略,進程內(nèi)緩存你需要關(guān)注的是它的淘汰算法和 GC 調(diào)優(yōu),以及要避免緩存污染等。
分布式緩存你需要關(guān)注的是它的高可用,如果它不可用了,如何進行降級,以及一些序列化的問題。
一個好的框架也是必不可少的,對它如果使用得當(dāng)再加上上面介紹的經(jīng)驗,相信能讓你很好的駕馭住這頭野馬——緩存。
【51CTO原創(chuàng)稿件,合作站點轉(zhuǎn)載請注明原文作者和出處為51CTO.com】