要及時「緩存」你們的珍貴時光
1.緩存概述
在很久很久以前人類和洪水作斗爭的過程中,水庫發(fā)揮了至關(guān)重要的作用 : 在發(fā)洪水時可以蓄水,緩解洪水對下游的沖擊;在干旱時可以把庫存的水釋放出來以供人們使用。這里的水庫就起著緩存的作用。在如今互聯(lián)網(wǎng)的世界里隨著互聯(lián)網(wǎng)的普及,內(nèi)容信息越來越復(fù)雜,用戶數(shù)和訪問量越來越大,我們的應(yīng)用需要支撐更多的并發(fā)量,同時我們的應(yīng)用服務(wù)器和數(shù)據(jù)庫服務(wù)器所做的計(jì)算也越來越多。
但是往往我們的應(yīng)用服務(wù)器資源是有限的,且服務(wù)器技術(shù)變革是緩慢的,數(shù)據(jù)庫每秒能接受的請求次數(shù)也是有限的,那么如何能夠有效利用有限的資源來提供盡可能大的吞吐量呢?一個有效的辦法就是引入緩存,打破標(biāo)準(zhǔn)流程,每個環(huán)節(jié)中請求可以從緩存中直接獲取目標(biāo)數(shù)據(jù)并返回,從而減少計(jì)算量,有效提升響應(yīng)速度,讓有限的資源服務(wù)更多的用戶。
2.緩存的定義
緩存就是數(shù)據(jù)交換的緩沖區(qū)(稱作Cache),這個概念最初是來自于內(nèi)存和 CPU。當(dāng)某一硬件要讀取數(shù)據(jù)時,會首先從緩存中查找需要的數(shù)據(jù),如果找到了則直接使用執(zhí)行,緩存找不到的話則從內(nèi)存中找。由于緩存的運(yùn)行速度比內(nèi)存快得多,故緩存的作用就是幫助硬件更快地運(yùn)行。
3.緩存的分類
當(dāng)用戶從鍵入一個地址到頁面的展示過程中通常包含了很多種緩存。有前端緩存、本地緩存(協(xié)商緩存,強(qiáng)緩存等)到我們的網(wǎng)關(guān)緩存(CDN 緩存)、最后到我們服務(wù)端緩存。服務(wù)端緩存又區(qū)分為進(jìn)程緩存(本地緩存),還有比較火的分布式緩存,最后到了數(shù)據(jù)庫層面的緩存。如下圖所示:
4.緩存是一把雙刃劍
在我們通常的軟件設(shè)計(jì)中,有一些熱點(diǎn)數(shù)據(jù)需要展示到頁面,我們通常當(dāng)這些數(shù)據(jù)緩存到內(nèi)存或者其他讀寫速度優(yōu)異的框架中。減少與數(shù)據(jù)庫進(jìn)行 I/O 操作。提升數(shù)據(jù)的響應(yīng)速度。這一切看起來就是這么完美。
實(shí)際上,在緩存系統(tǒng)的設(shè)計(jì)架構(gòu)中,還有很多坑。如果設(shè)計(jì)不當(dāng)會導(dǎo)致很多嚴(yán)重的后果。設(shè)計(jì)不當(dāng),輕則請求變慢、性能降低,重則會數(shù)據(jù)不一致、系統(tǒng)可用性降低,甚至?xí)?dǎo)致緩存雪崩,整個系統(tǒng)無法對外提供服務(wù)。
接下來我們著重講述一下在緩存設(shè)計(jì)過程中幾大經(jīng)典的問題。
緩存失效
先解釋一下什么叫做緩存失效。
我們在存放緩存的時候,可以指定緩存 Key 的失效時間,當(dāng)失效時間到了,此緩存就會失效,由于在緩存中找不到該數(shù)據(jù),所以這個時候如果用戶有請求該數(shù)據(jù)就繞過緩存直接到數(shù)據(jù)庫中請求數(shù)據(jù)。
看到這里小伙伴們肯定有很多問號?
這不是很正常的現(xiàn)象嘛?為什么要把這個問題拿出來說呢?莫急看下圖圖示
這里我們通過兩個場景來說明一下
- 場景一:這種情況下一般不會對數(shù)據(jù)庫造成比較嚴(yán)重的影響,因?yàn)槭У?key 的數(shù)量比較少,即使同時請求到數(shù)據(jù)庫層面也是可以接受的。
- 場景二:在這種場景中,當(dāng)緩存里面的大量 Key 同時失效,這個時候如果有請求過來,會穿過失效的 Key全部落到數(shù)據(jù)庫層面。導(dǎo)致數(shù)據(jù)庫的負(fù)荷瞬間添加??赡軙霈F(xiàn)數(shù)據(jù)庫宕機(jī)等特大事故。
解決方案
看到這里很多聰明的小伙伴其實(shí)已經(jīng)想到了。場景 2 的事故主要因?yàn)楹芏?key 一起失效的原因,跟我們?nèi)粘懢彺娴倪^期時間息息相關(guān)。如果我們在日常的開發(fā)過程中需要將一批 Key 設(shè)置到緩存中并制定失效時間。這個時候就要注意場景 2 發(fā)生的情況。我們可以在失效時間 + 隨機(jī)時間。避免大量 Key 失效沖擊我們的數(shù)據(jù)庫。
緩存擊穿
通常情況下,我們?nèi)ゲ樵償?shù)據(jù)都是存在的。那么如果請求去查詢一條壓根兒數(shù)據(jù)庫中根本就不存在的數(shù)據(jù),也就是緩存和數(shù)據(jù)庫都查詢不到的這條數(shù)據(jù)會怎么樣呢?這樣會導(dǎo)致每次訪問都會直接打到數(shù)據(jù)庫上面去。這種查詢不存在數(shù)據(jù)的現(xiàn)象我們稱為緩存穿透。
下面是緩存失效的場景
很多伙伴看到這里肯定又會覺得這是一件很正常的事情。試想一下,如果有黑客會對你的系統(tǒng)進(jìn)行攻擊,拿一個不存在的 key 不停的去查詢數(shù)據(jù),會產(chǎn)生大量的請求到數(shù)據(jù)庫去查詢??赡軙?dǎo)致你的數(shù)據(jù)庫由于壓力過大而宕掉。
解決方案一
- 首先我們能想到的就是在網(wǎng)關(guān)參數(shù)進(jìn)行過濾。校驗(yàn)請求的 key 是否是我們系統(tǒng) key 的格式等
當(dāng)然這網(wǎng)關(guān)層所能做到的只是一些簡單過濾。每個后端的設(shè)計(jì)人員應(yīng)該對服務(wù)的可用性和健壯性負(fù)責(zé)。接下來我們看看服務(wù)端應(yīng)該如何處理
- 服務(wù)端可以將不存在的 key 暫時保存到我們的緩存中,再次接收到同樣的請求后如果直接命中緩存并且值為空那么就會直接返回,不會穿透到數(shù)據(jù)庫層面,這樣就避免了緩存擊穿。
但是黑客/惡意攻擊者是不會這么輕易被打發(fā)的。每次請求都會傳不同的 key 來攻擊我們的服務(wù)。這個時候這個方案起不到作用了。
解決方案二
構(gòu)建一個 BloomFilter(布隆過濾器) 緩存過濾器,記錄全量數(shù)據(jù)。這樣訪問數(shù)據(jù)時,可以直接通過 BloomFilter 判斷這個 key 是否存在,如果不存在直接返回即可,根本無需查緩存和 DB。這樣在緩存之前加了一層校驗(yàn)。如果key 值不存在,就不會請求到我們的緩存更加不會到我們的數(shù)據(jù)庫中。
布隆過濾器可以理解為一個不怎么精確的 set結(jié)構(gòu),當(dāng)你使用它的 contains 方法判斷某個對象是否存在時,它可能會誤判。但是布隆過濾器也不是特別不精確,只要參數(shù)設(shè)置的合理,它的精確度可以控制的相對足夠精確,只會有小小的誤判概率。當(dāng)布隆過濾器說某個值存在時,這個值可能不存在;當(dāng)它說不存在時,那就肯定不存在。即使誤判不存在走到緩存和后端服務(wù)也是可以接受的。
緩存雪崩
緩存雪崩是指緩存的部分節(jié)點(diǎn)不可用導(dǎo)致整個緩存體系甚至整個服務(wù)系統(tǒng)不可用。
那么你可能會有疑問,緩存雪崩和緩存擊穿有什么關(guān)系呢?
從概念上來看,緩存擊穿是因?yàn)椴樵儾淮嬖诘?key 穿透緩存直接訪問我們的數(shù)據(jù)庫。而緩存雪崩是因?yàn)槲覀兊木彺婀?jié)點(diǎn)不可用,請求未經(jīng)過緩存就直到了我們的數(shù)據(jù)庫層面。然而兩者都會影響我們的服務(wù)穩(wěn)定性。
緩存節(jié)點(diǎn)的不可用會導(dǎo)致緩存雪崩,那么我們緩存組件集群部署是不是就解決了這個問題呢?
集群部署有兩種情況:
- 一種就是簡單的主從例如 redis 的哨兵之殤
- 采取一致性 hash 算法集群部署例如 redis 的分片集群
第一種情況:發(fā)送雪崩的時候一般是多個節(jié)點(diǎn)同時不可用,例如我們的節(jié)點(diǎn)服務(wù)器內(nèi)容不足,雖然分主從節(jié)點(diǎn)都是存儲的數(shù)據(jù)都是一樣的。如果緩存中的數(shù)據(jù)過大導(dǎo)致節(jié)點(diǎn)不可用。那大部分節(jié)點(diǎn)也會存在這個問題。請求會大面積的落到數(shù)據(jù)庫層面導(dǎo)致后端系統(tǒng)崩潰。
第二種情況: 首先看一下下圖雖然數(shù)據(jù)根據(jù)會根據(jù)取模算法分配到不同的節(jié)點(diǎn)中,假設(shè)節(jié)點(diǎn) A 不可用,數(shù)據(jù) A 會按照逆時針找到節(jié)點(diǎn) B,會因?yàn)楸緛響?yīng)該存放到節(jié)點(diǎn) A 的數(shù)據(jù)存放到節(jié)點(diǎn) B,以此類推會導(dǎo)致整個緩存節(jié)點(diǎn)不可用。請求也會大面積落到我們后端的數(shù)據(jù)庫層面導(dǎo)致系統(tǒng)崩潰。
解決方案
- 對緩存體系進(jìn)行實(shí)時監(jiān)控,當(dāng)請求訪問的慢速比超過閥值時,及時報(bào)警,通過機(jī)器替換、服務(wù)替換進(jìn)行及時恢復(fù)。
- 對緩存增加多個副本,緩存異?;蛘埱?miss 后,再讀取其他緩存副本。
- ehcache 本地緩存 + Hystrix 限流&降級,避免 MySQL被打死
- 業(yè)務(wù) DB 的訪問增加讀寫開關(guān),當(dāng)發(fā)現(xiàn) DB 請求變慢、阻塞,慢請求超過閥值時,就會關(guān)閉讀開關(guān),部分或所有讀 DB 的請求進(jìn)行 failfast 立即返回,待 DB 恢復(fù)后再打開讀開關(guān)。
數(shù)據(jù)不一致
數(shù)據(jù)不一致的概念很簡單:就是緩存中的數(shù)據(jù)和數(shù)據(jù)庫中的數(shù)據(jù)不一致。
那為什么會不一致呢?我們的數(shù)據(jù)被緩存之后,一旦數(shù)據(jù)被修改(修改時也是刪除緩存中的數(shù)據(jù))或刪除,我們就需要同時操作緩存和數(shù)據(jù)庫。這時就會存在一個數(shù)據(jù)不一致的問題。
如上圖所示當(dāng)我們先刪除數(shù)據(jù)庫再去操作緩存,緩存中未刪除數(shù)據(jù)庫其實(shí)已經(jīng)不存在該數(shù)據(jù)了。這個時候就會出現(xiàn)緩存不一致的情況。
聰明的小伙伴肯定想到了我們還是需要先做緩存刪除操作,再去完成數(shù)據(jù)庫操作。則會去數(shù)據(jù)庫中查詢,如果緩存中沒有該數(shù)據(jù),則會去數(shù)據(jù)庫中查詢,之后再放入到緩存中。這樣就完美了嘛?答案肯定不會這么簡單。請看下圖:
解決方案
這里其實(shí)沒有什么很完美的解決方法??梢詫⒆兏?key 添加到安全隊(duì)列中。當(dāng)另一個查詢請求 B 進(jìn)來時,如果發(fā)現(xiàn)緩存中沒有該值,則會先去隊(duì)列中查看該數(shù)據(jù)是否正在被更新或刪除,如果隊(duì)列中有該數(shù)據(jù),則阻塞等待,直到 A 操作數(shù)據(jù)庫成功之后,喚醒該阻塞線程,再去數(shù)據(jù)庫中查詢該數(shù)據(jù)。這里其實(shí)也是有很多缺陷的。線程需要阻塞等待。
最好的解決方案就是如果數(shù)據(jù)更新比較頻繁且對數(shù)據(jù)有一定的一致性要求,我通常不建議使用緩存??吹竭@里是不是發(fā)出了一句切!!!!
5.總結(jié)
緩存雖然能大幅度的提高服務(wù)器的性能以及用戶的體驗(yàn)感。但是隨著而來的就是各種由于緩存導(dǎo)致的一系列問題。所以當(dāng)我們使用緩存的過程中需要注意以上的經(jīng)典問題。