如何設計一個無懈可擊的緩存系統?

大家好,我是 Tom 哥。

今天我們通過緩存與數據庫之間的一致性這個老生常談的問題來切入,聊聊如何合理的設計一個緩存系統?

如今互聯網應用,無論是 web 還是 app,都基本遵循 "前端 - 後端 - 數據庫" 的架構模型

當業務處於起步階段,流量比較小的時候,上述能夠支撐;但隨着業務的擴張,用戶數和流量越來越大,也就需要整個架構支撐起更大的併發量,但我們服務器上的資源總是有限的,當每天流量達到高峯時,往往這個時候數據庫最先頂不住

當我們分析這些互聯網應用的流量時候,發現大部分的流量實際上都是讀請求,而且大部分數據並沒有頻繁被改變 (即讀多寫少場景,注意本文全文討論的方案都是基於這個前提 )。這個時候引入緩存,是提升性能的一種行之有效的方式,緩存在計算機的世界中處處可見,比如 CPU 緩存,瀏覽器緩存,操作系統緩存,程序代碼中自定義緩存

由於數據庫每秒能接受的請求次數QPS是有限的,當我們在數據庫前面,引入緩存來充當緩衝層;如果命中緩存就直接獲取目標數據並返回,不僅能減少對數據庫的直接訪問帶來的計算壓力,還能提升響應速度,充分壓榨有效的資源,其本質是額外消耗更高速的空間來換時間

凡是有利有弊,引入緩存後,享受緩存帶來的種種好處的優點,但緩存系統其實是非常複雜的,緩存和數據庫的一致性也是個繞不開,讓人腦闊疼的問題;還需要考慮緩存的穩定性、命中率、熱點數據、過期時間等等,我們下文慢慢道來

本地緩存、分佈式緩存

緩存有各種分類,常見的是與應用耦合程度劃分爲:本地緩存local cache分佈式緩存remote cache

本地緩存

本地緩存,由於存在於應用程序的本地內存,應用和緩存在同一個進程內,且沒有網絡延遲,所以速度快

但本地緩存的大小通常受到物理內存的限制,而且還要兼顧應用程序正常運行,容量有限,擴展性差,無法輕鬆擴展到多個節點。還有就是多個應用實例下無法直接的共享緩存,數據的一致性難以保證,複雜度高。數據會隨着應用程序的重啓而丟失

適合讀寫密集、對數據一致性要求較低、網絡環境不穩定的場景

分佈式緩存

主要是指與應用分離的獨立緩存組件,比如 redis,可擴展性強,容量大,可以通過集羣水平擴展;通過通過一致性哈希等技術,保證多節點之間的數據一致性,而且都集成好了,開發者一般直接使用這些特性

當然由於存在網絡延遲,與本地緩存相比,速度較慢;硬件成本也需要較高,來保證其高可用、高可靠性

更適合電商平臺、社交網絡等流量併發大的平臺,或者互聯網這種隨着業務增長,需要彈性擴展以滿足需求的場景

還有綜合二者特點的多級緩存,將本地緩存和分佈式緩存結合起來,本地緩存作爲一級緩存,存儲更新頻率低,訪問頻率高數據;分佈式緩存作爲二級緩存,存儲更新頻率很高的數據

當用戶獲取數據時,先從一級緩存中獲取數據,如果一級緩存有數據則返回數據,否則從二級緩存中獲取數據。如果二級緩存中有數據則更新一級緩存,然後將數據返回客戶端。如果二級緩存沒有數據則去數據庫查詢數據,然後更新二級緩存,接着再更新一級緩存,最後將數據返回給客戶端。這裏邏輯其實和 CPU 內部的緩存很像,大家感興趣地可以自行查閱筆者之前的一篇文章 -CPU 緩存

但緩存相關的問題邏輯挑戰,無論本地緩存還是分佈式緩存都是一樣的,爲方便起見,本文將全文以 redis 爲例,來代稱緩存

緩存穿透、緩存擊穿、緩存雪崩

在將緩存和數據庫的一致性之前,我們需要保證,引入的緩存,即構建的緩存系統是穩定的,這是保證數據一致性的前提

關於緩存的穩定性,有 3 種經典問題:緩存穿透、緩存擊穿、緩存雪崩,聊這 3 個問題前,我們得知曉緩存最常見的應用模式Cache-Aside Pattern旁路緩存的讀模式

旁路緩存模式,是指優先查詢緩存,查詢不到再去查詢數據庫。如果這時候數據庫查到數據了,就將緩存的數據回寫更新,這樣緩存可以爲後續請求服務!

緩存穿透

緩存穿透: 當請求過來,訪問不存在的數據時 (即既不在緩存中,也不在數據庫中),這會導致訪問緩存,未命中,繼續訪問數據庫 db,然後發現在數據庫中還是未查詢到數據,這個時候也就不能回寫緩存,來爲後續的請求服務;也就是說,當這種請求過來,每次都會去查數據庫,緩存形同虛設,一旦流量暴增,容易直接帶崩數據庫

這種不存在的數據可能被管理員誤刪,也有可能被黑客惡意利用 (惡意請求),不斷地去試,一旦發現一個不存在的數據,就拼命發請求訪問這個數據,直到數據庫鎖住

那解決辦法也很簡單,常見的有:

  1. 比如每次訪問數據如果既不在緩存中,也不在數據庫中,那就緩存一個佔位符或者空值過期時間也不要設置過長,比如 1 分鐘就行,這樣的話,在 1 分鐘內,這麼多請求只有一次能直接訪問數據庫,這樣就能顯著降低數據庫的壓力;如果緩存過期時間過長,會出現大量的空緩存,進而導致緩存資源的浪費

  2. 還可以針對請求攜帶的參數,比如是那種特殊字符、非法字符等,我們數據庫肯定不會存這些東西,直接在應用服務層進行限制,不允許訪問

  3. 還可以通過第三方組件來實現,比如布隆過濾器,其主要是其特性: 布隆過濾器判斷一個元素不在集合中,那肯定就不在。如果判斷存在,那有一定可能性它在說謊,具體原理可以參考筆者以前的一篇文章海量數據處理的利器 - 布隆過濾器。在緩存和數據庫之間再加上布隆過濾器,通過布隆過濾器快速判斷數據是否存在,從而避免多次之間請求數據庫

緩存擊穿

在我們正常的業務之中,總有一些數據會被頻繁訪問,這就是熱點數據

所謂的緩存擊穿指的是,緩存中熱點數據的 key 過期失效,由於是熱點數據,在過期的一瞬間會有大量的請求過來 (高併發),這些請求,最終都會直接訪問數據庫,這樣數據庫很容易被打垮,緩存彷彿被 "擊穿" 了

常見的解決方案:

  1. 加鎖,進程鎖 / 分佈式鎖,當請求過來時,緩存未命中時,會通過鎖將這個緩存 key 鎖上,等當這個請求從數據庫獲取數據後再回寫到緩存中後,再釋放鎖;期間其他請求過來,會獲取鎖失敗,等待一段時間重試,就可以直接讀取緩存了。需要注意的是,如果業務量不大,進程鎖就夠了的話,也就沒必要上分佈式鎖,多引入額外組件,就會增加系統的不穩定性

還可以繼續改進,將請求 2 未獲得鎖,直接返回,升級成自旋鎖,它不直接返回,而是等待一會重新嘗試獲取鎖,這種高併發情況下,只有唯一請求是 db 請求,所有請求共享結果

  1. 給緩存的 Key 設置合理的過期時間並加上隨機值,儘量減少緩存短期大量失效,出現大量訪問數據庫的情況,實現 "削峯填谷"

  2. 網上有文章提出,可以讓熱點數據的緩存不設置過期時間,這樣不就可以永不過期嘛,但這其實是個很危險的操作

使用緩存的前提是一定要設置過期時間,因爲由於項目會不斷迭代更新,業務不斷複雜,開發人員更替,緩存會變得越來越難以維護,另外緩存和數據庫無法避免的數據不一致的情況,緩存的過期時間其實就是兜底,防止緩存和數據庫數據長時間不一致

我們還可以通過消息隊列來間接地讓熱點數據的緩存延期,當熱點緩存過期時,後臺服務再檢測更新緩存,防止緩存擊穿;至於是否延期,得做訪問量分析與統計,當然引入新的組件也會帶來額外的穩定性問題,還是得根據業務情況,實事求是

緩存雪崩

緩存雪崩,指定是大量請求未命中緩存,直接訪問數據庫,導致數據庫壓力過大,倘若請求足夠的多,會直接將數據庫壓垮,繼而影響整個系統,如同 "雪崩"

個人感覺緩存擊穿是緩存雪崩的一個子集,緩存雪崩一般有 2 種誘因:緩存服務異常,比如 redis 故障宕機或者緩存服務是正常的,但大量緩存數據在同一時間過期

一般解決 redis 故障宕機,是搭建集羣由單節點到多節點,提升 redis 的容災能力,當主節點宕機後,從節點可以切換成爲主節點,繼續提供緩存服務;若是真的宕機了,那我們應該使用熔斷機制,同時當流量到達一定的閾值,直接禁止請求對數據庫的訪問,返回系統擁擠之類的提示,維持系統穩定,等待緩存恢復再允許對數據庫訪問

防止大量緩存數據在同一時間過期,一般是給緩存的 Key 設置合理的過期時間並加上隨機偏差,儘量讓緩存失效時間均勻分佈,實現 "削峯填谷",簡單而有效

要麼加鎖,唯一 db 請求,所有同類請求共享結果,與緩存擊穿的解決方法一致,我們就不再贅述了

還有一種方式就是,當每天系統訪問的流量高峯來臨之前,先提前將熱點數據入緩存,避免直到用戶請求的時候,再先查詢數據庫,然後將數據緩存的過程,這個也叫緩存預熱

CAP 原則 和 如何保證緩存一致性

由於在數據庫層前,引入緩存,主要是通過空間去換時間,享受緩存帶來的種種好處的優點,但此時一份數據存在不同的副本,且在不同空間中,此時更新緩存、db 就會帶來緩存一致性的挑戰

我們還需要了解一下著名的 CAP 原則,指在一個分佈式系統中,一致性Consistency、可用性Availability、分區容錯性Partition tolerance這 3 者最多同時滿足 2 項,不可能同時滿足 3 項!!!

  1. 一致性 Consistency,即所有節點在同一時間具有相同的數據,強一致性

  2. 可用性 Availability,即服務必須一直處於可用的狀態,每次請求都能獲取到正常的響應,高可用

  3. 分區容錯性 Partition tolerance,即分區故障時,要求在一定時限內,仍然或者恢復到能對外提供滿足一致性和可用性的服務,系統繼續正常運行

還記得本文的一開始嗎?

爲了應對高流量,我們的系統選擇了高性能和高吞吐量,所以只能滿足 AP

而緩存與數據庫的緩存一致性難以避免的具體原因是:由於無法保證同時更新 db 和緩存不在同一個事務中,所以其不是原子操作緩存不一致是無法避免的要保證強一致性,我們可以上分佈式鎖,但會導致整個系統的併發性能下降,還記得我們引入緩存的初衷嗎?是爲了提升系統的整體性能吶!!!所以這種方案我們一般不採用~

但我們可以通過一些方案,來實現緩存的最終一致性,其次儘可能減小緩存不一致的時間窗口,我們下面分別來聊聊常見的幾種方式及其它們的問題:

  1. 先更新數據庫,再更新緩存

  2. 先更新緩存,再更新數據庫

  3. 先刪緩存,再更新數據庫

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

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

先更新數據庫,再更新緩存,可能會遇到下面這種情況:

當請求 (或者可以說線程) 併發的情況,比如 2 個請求 1、2 同時去更新 db 時,請求 1 快一點;但當程序延遲或者其他情況,導致當請求去更新緩存時,請求 2 快一點,這就會導致最終db=20,緩存=10這種數據不一致的情況,不一致的情況將持續到下次緩存失效,或者去更新數據庫緩存的時候,在此期間還不能保證更新緩存一定就可以成功

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

這種和先更新數據庫,再更新緩存是類似的情況:

這種更新緩存的方式,是無法避免併發導致的數據不一致問題,而且出現的頻率也不低,所以我們應該儘量不更新緩存

先刪緩存,再更新數據庫 和 延遲雙刪

前一個更新請求,先刪除緩存,再更新數據庫,當後面讀請求來發現沒有命中緩存,去數據庫讀數據,然後再回寫到緩存中,給後續請求服務,這是個很不錯的設想,但它還是會出現下面這種情況:

當 2 個併發請求過來,請求 1 是更新請求,當請求 1 刪除調緩存後,還沒去 db 更新數據,期間請求 2 來獲取數據,緩存未命中 (剛被請求 1 刪了嘛),去數據庫獲取數據10後,後回寫緩存,把緩存更新爲10;這個時候請求 1 終於去更新 db 了,把db更新爲20,這個時候還是會出現緩存和數據庫不一致的情況

一旦發生數據不一致,髒數據會一直在緩存中,直到下一次更新請求過來

補充:延遲雙刪關注我,我再多講幾句~ 如今在先刪緩存,再更新數據庫的基礎上,還有個優化版叫延遲雙刪

既然請求可能會把髒數據重新寫入緩存中,髒數據會一直在緩存中,直到下一次更新請求過來,這個數據不一致的時間窗口較長,如果這個時候休眠指定時間 N,我們另起線程 (異步化) 去刪除這個髒數據緩存,這個時候不就能縮短極端情況下不一致的時間窗口了嘛,一般 N 設爲5s左右,需要根據項目實際情況而定。

另外也可以通過消息隊列 MQ 來刪除緩存,利用消息隊列的可靠性,來保證刪除緩存的操作能夠成功執行,並異步化進行復雜邏輯的解耦

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

那先更新數據庫,再刪除緩存呢?它也被稱爲 Cache Aside Pattern 旁路緩存的寫模式,我們再來看一種情況:

從上面時序圖,我們可以看出,先更新數據庫,再刪除緩存這種方案是可以保證緩存的最終一致性,但它在某一時間內,還是存在緩存不一致的時間窗口 (上圖請求 2 命中緩存與數據庫不一致)

但這個不一致的時間窗口很短,通常不超過 1ms,在互聯網項目中通常可以忽略這麼短時間的不一致

但你覺得這就是終極方案了?

別急我們再看它有可能發生的一種情況:

當 2 個併發請求過來,請求 1 是讀請求,正好緩存不存在,直接讀取db=20,在回寫緩存期間,請求 2 又過來更新db=10,在刪除緩存 (沒緩存),然後請求 1 再姍姍來遲地更新緩存=20,這就導致了緩存與數據的不一致情況

但實際上這種情況,觸發的概率非常低,因爲緩存的存取速度 (內存),要遠遠快於數據庫 (磁盤)。關於儲存介質的速度差異,感興趣地可以去看看計算機儲存器的讀寫速度差異

所以很難出現請求 1 已經更新了數據庫並且刪除了緩存,請求 2 才更新完緩存的情況;爲防止刪除緩存失敗,給緩存加個過期時間簡單而有效

但這其實也反映了:

  1. 先更新數據庫,再刪除緩存這種模式並不太適合寫請求遠遠多於讀請求的場景下,而且當併發量特別高的情況下,緩存刪除的代價也會較大 (容易緩存擊穿),這個時候更新數據庫後更新緩存可能是更適合的方案,還能進而通過 MQ 異步來優化

  2. 如果讀請求遠遠大於寫請求的場景下,先更新數據庫,再刪除緩存是個較好的方案,背後是 lazy 計算的思想:不要每次都重新做複雜的計算,而是等到它需要用的時候再重新計算

  3. 本文提到的這 4 種方案,無論是哪種方案都是無法絕對保證緩存的一致性,只能保證最終一致性,縮短不一致的時間窗口。所以緩存必須要設置過期時間,這就是對緩存不一致的兜底措施

  4. 最後如果對數據一致性要求極高的話,就不要再額外引入緩存,不引入緩存就沒有這麼多煩惱!

如何保證刪除緩存能執行成功

另外在實際環境中,執行刪除緩存,也會有問題,因爲無法保證系統會一定去刪除緩存,如果刪除緩存失敗,也會造成緩存與數據庫的不一致,下面介紹幾種常見的方案:

基於消息隊列刪除緩存

由於刪除緩存不一定能成功,一般會採用多次重試刪除的方案,需要一個隊列來記錄,是否刪除成功,如果沒有成功就繼續回隊列中,一般會引入中間件消息隊列 MQ 來,利用其高可靠性來保證刪除操作的執行,同時還能異步化,實現複雜業務邏輯的解耦

我們來看下其主要流程:

更新數據庫的同時,發送刪除緩存的消息到消息隊列中,首次消費消息去執行刪除緩存的操作,如果成功就直接返回業務,並把這個消息消費掉;如果由於各種原因導致緩存刪除失敗,那就重新將這個消息放進消息隊列中,等待下一次的消費

當第二次消費刪除該緩存的消息時,如果刪除成功就把該消息消費掉,並返回;如果沒有刪除成功就繼續放回消息隊列中,每個消息都有消費次數的上限,超出就報錯告警

另外一般將更新數據庫的模塊和同時發生刪除緩存消息的模塊放在同一個服務裏,因爲這樣後期維護起來,纔不會發現莫名奇妙,不然就是給排查和維護上強度~~

當然再引入 mq,也要額外考慮 mq 的高可用性,所以需要根據實際情況,考慮是否有必要引入 mq,如果不引入怎麼辦?最簡單的我們可以通過內存隊列、線程池等方式實現,性能更高,畢竟在本地沒有網絡延遲,代價就是更考驗程序員的心智,啥都要操心~

基於 binlog 來刪除緩存

還有一種比較有意思的方式,我們上面需要在程序中顯式去發送消息,講人話就是程序需要額外承擔發送消息的壓力, 而通過訂閱數據庫比如 Mysql 的 binlog,來監聽數據的真實變化直接去處理有關的緩存,讓程序專心地去操作數據庫

binlog 用於記錄數據庫執行的寫入性操作 (不包括查詢) 信息,以二進制的形式保存在磁盤中。binlog 是 mysql 的邏輯日誌,並且由 Server 層進行記錄,使用任何存儲引擎的 mysql 數據庫都會記錄 binlog 日誌。可通過解析 binlog 文件來查看數據庫的操作歷史記錄

業內比較成熟的有中間件 Canal,我司也用的這個,Canal 會模擬 MySQL 主從複製的交互協議,把自己僞裝成一個 MySQL 的從節點,向 MySQL 主節點發送dump請求,MySQL 收到請求後,就會開始推送 Binlog 給 Canal,Canal 解析 Binlog 字節流,解析出其中有關數據庫中數據更新的日誌,解析日誌並執行對應數據的刪除緩存操作,然後再引入 MQ,通過消息隊列的ACK機制,來確保這條消息的執行成功

希望大家通過這些方案的學習,能夠領悟爲什麼只能滿足 AP**?**

爲什麼緩存的數據一致性問題是無法避免的挑戰?

引入緩存後,我們該如何監控起來呢?進一步分析過期時間是否合適,緩存的命中率

或者是否必需引入緩存?不引入緩存可就沒有緩存的數據一致性,這些都需要數據分析作爲支撐

或者引入緩存如何進一步優化,緩存的 key 如何花式設置,緩存預熱有講究,還有團隊如何規範使用緩存等等,有太多可以深究

關於我:Tom 哥,前阿里 P7 技術專家,offer 收割機,參加多次淘寶雙 11 大促活動。歡迎關注,我會持續輸出更多經典原創文章,爲你晉級大廠助力

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Ub2reFwxyM8lkfwOwz-VgA