聊一聊安全且正確使用緩存的那些事 —— 關於緩存可靠性、關乎數據一致性

語言: CN / TW / HK

theme: healer-readable

本文為稀土掘金技術社區首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!


大家好,又見面了。

在上一篇文檔《聊一聊作為高併發系統基石之一的緩存,會用很簡單,用好才是技術活》中,我們對緩存的龐大體系進行了個初步的探討,浮光掠影般的介紹了本地緩存集中緩存多級緩存的不同形式,也走馬觀花似的初識了緩存設計的關鍵原則與需要關注的典型問題

作為《深入理解緩存原理與實戰設計》系列專欄的第二篇內容,從本篇開始,我們將聚焦緩存體系中的具體場景,分別進行深入的闡述與探討。本篇我們就一起具體地聊一聊緩存使用中需要關注的典型問題可靠性防護措施。

在分佈式系統盛行的今天,尤其是在一些用户體量比較大的互聯網業務系統裏面,緩存充當着扛壓屏障的作用。當前各互聯網系統可以抗住動輒數萬甚至數十萬的併發請求量,緩存機制功不可沒。而一旦緩存出現問題,對系統的影響往往也是致命的。所以在緩存的使用時必須要考慮完備的兜底與災難應對策略。

熱點數據與淘汰策略

大部分服務端使用的抗壓型緩存,為了保證緩存執行速度,普遍都是將數據存儲在內存中。而受限於硬件與成本約束,內存的容量不太可能像磁盤一樣近乎無限的去隨意擴容使用。對於實際數據量及其龐大且無法將其全部存儲於緩存中的時候,我們需要保證存儲在緩存中的有限部分數據要儘可能的命中更多的請求,即要求緩存中存儲的都是熱點數據

説到這裏,就會存在一個不得不面對的問題:當數據量超級大而緩存的內存容量有限的情況下,如果容量滿了該怎麼辦

斷舍離!

緩存實現的時候,必須要有一種機制,能夠保證內存中的數據不會無限制增加 —— 也即數據淘汰機制數據淘汰機制,是一個成熟的緩存體系所必備的基礎能力。這裏有個概念需要釐清,即數據淘汰策略與數據過期是兩個不同的概念。

  • 數據過期,是緩存系統的一個正常邏輯,是符合業務預期的一種數據刪除機制。即設定了有效期的緩存數據,過期之後從緩存中移除。

  • 數據淘汰,是緩存系統的一種“有損自保”的降級策略,是業務預期之外的一種數據刪除手段。指的是所存儲的數據沒達到過期時間,但緩存空間滿了,對於新的數據想要加入緩存中時,緩存模塊需要執行的一種應對策略。

我們把緩存當做一個容器,試想一下,一個容器已滿的情況下,繼續往裏面放東西,可以有什麼應對之法?無外乎兩種:

  1. 直接拒絕,因為滿了,放不下了。

  2. 從容器裏面扔掉一些已有內容,然後騰挪出部分空間出來,將新的東西放進來。

進一步地,當決定採用先從容器中扔掉一些已有內容的時候,又會面臨一個新的抉擇,應該扔掉哪些內容?實踐中常用的也有幾種方案:

  1. 一切隨緣,隨機決定。從容器中現有的內容中隨機扔掉剔除一些。

  2. 按需排序,保留常用。即基於LRU策略,將最久沒有被使用過的數據給剔除掉。

  3. 提前過期,淘汰出局。對於一些設置了過期時間的記錄,將其按照過期時間點進行排序,將最近即將過期的數據剔除(類似讓其提前過期)。

  4. 其它策略。自行實現緩存時,除了上述集中常見策略,也可以根據業務的場景構建業務自定義的淘汰策略。比如根據創建日期、根據最後修改日期、根據優先級、根據訪問次數等等。

一些主流的緩存中間件的淘汰機制大都也是遵循上述的方案來實現的。比如Redis提供了高達6種不同的數據淘汰機制,供使用方按需選擇,將有限的空間僅用來存儲熱點數據,實現緩存的價值最大化。如下:

從上圖可以看出,Redis對隨機淘汰和LRU策略進行的更精細化的實現,支持將淘汰目標範圍細分為全部數據和設有過期時間的數據,這種策略相對更為合理一些。因為一般設置了過期時間的數據,本身就具備可刪除性,將其直接淘汰對業務不會有邏輯上的影響;而沒有設置過期時間的數據,通常是要求常駐內存的,往往是一些配置數據或者是一些需要當做白名單含義使用的數據(比如用户信息,如果用户信息不在緩存裏,則説明用户不存在),這種如果強行將其刪除,可能會造成業務層面的一些邏輯異常。

緩存雪崩:避免緩存的集中失效

為了限制緩存的數量,很多的緩存記錄都會設置一定的有效期,到期後自動失效。這種在一些批量緩存構建或者全量緩存重建時,因為設置了相同的失效時間,會導致大量甚至全部的緩存數據在短時間內集體失效,這樣會導致大量的請求無法命中緩存而直接流轉到了下游模塊,導致系統癱瘓,也即緩存雪崩

其實解決的思路也很簡單,避免出現集中失效就好咯。如何避免呢?

一種簡單的策略,就是批量加載的場景,將過期時間在一個固定時間段內以毫秒級別進行隨機打散,比如本來要設置每條記錄過期時間為5分鐘,則批量加載的時候可以設置過期時間為5~10分鐘之間的任意一個毫秒數。這樣就可以有效的避免數據集中失效,避免出現緩存雪崩而影響業務穩定。

此外,在一些大型系統裏面,尤其是一些分佈式微服務化的系統中,很多情況下都會有多個獨立的緩存服務,而最終持久化數據則集中存儲。如果某個獨立緩存真的出現了緩存雪崩,業務層面應該如何將受損範圍控制在僅自身模塊、避免殃及數據庫以及下游公共服務模塊,進而避免業務出現系統性癱瘓呢?這個就需要結合服務治理中的一些手段來綜合防範了,比如服務降級服務熔斷、以及接口限流等策略。

緩存擊穿:有效的冷數據預熱加載機制

正如前面所提到的,基於內存的緩存,受內存容量限制,往往都會加載一些熱點數據。而這些熱點緩存數據,可以命中大部分的業務請求。少部分沒有命中緩存的數據,則直接轉由業務模塊進行處理(比如從MySQL裏面進行查詢)。

先來看一個例子:

互動論壇系統,使用Redis作為緩存,緩存最近1年的帖子信息。如果用户查看的帖子是最近1年的,則直接從Redis中查詢並返回,如果用户查看的帖子是1年前的,則從MySQL中進行撈取並返回。

因為論壇系統中,大部分人會閲讀或者查看的都是最近新發的帖子,只有極少數的人可能會偶爾“挖墳”查看一年前的歷史帖子。系統上線前會根據冷熱請求的比例與總量情況,評估需要部署的硬件規模,以確保可以支撐住線上正常的訪問請求。但為了避免緩存數據被無限撐滿,一般業務緩存數據都會設置一個過期時間,來保證緩存數據的定期清理與更新。

近段時間,娛樂圈的雷聲不斷,各種新鮮的大瓜也讓吃瓜羣眾撐到打嗝。

有一天,娛樂圈當紅流量明星李某某突然被爆料與某網紅存在某些不正當的關係,甚至被爆有多次PC被捕的驚天大瓜,引起粉絲和路人的強烈關注。

吃瓜羣眾們羣情高漲、熱搜一波蓋過一波、帖子的瀏覽量光速攀升,論壇系統在緩存模塊的加持下,雖然整體CPU和內存佔用都飆升上去了,倒也相安無事。

但天有不測風雲,恰好這個時候,這條帖子的記錄在緩存中過期被刪除了。然後狂濤巨浪般的請求湧向了後端的數據庫,讓數據庫原地癱瘓,進而陸陸續續殃及了整個論壇系統。這就是典型的一個緩存擊穿的問題。

緩存擊穿和前面提到的緩存雪崩產生的原因其實很相似。區別點在於:

  • 緩存雪崩是大面積的緩存失效導致大量請求湧入數據庫。

  • 緩存擊穿是少量緩存失效的時候恰好失效的數據遭遇大併發量的請求,導致這些請求全部湧入數據庫中。

針對這種情況,我們可以為熱點數據設置一個過期時間續期的操作,比如每次請求的時候自動將過期時間續期一下。此外,也可以在數據庫記錄訪問的時候藉助分佈式鎖來防止緩存擊穿問題的出現。當緩存不可用時,僅持鎖的線程負責從數據庫中查詢數據並寫入緩存中,其餘請求重試時先嚐試從緩存中獲取數據,避免所有的併發請求全部同時打到數據庫上。如下圖所示:

對上面的出處理過程描述説明如下:

  1. 沒有命中緩存的時候,先請求獲取分佈式鎖,獲取到分佈式鎖的線程,執行DB查詢操作,然後將查詢結果寫入到緩存中;

  2. 沒有搶到分佈式鎖的請求,原地自旋等待一定時間後進行再次重試;

  3. 未搶到鎖的線程,再次重試的時候,先嚐試去緩存中獲取下是否能獲取到數據,如果可以獲取到數據,則直接取緩存已有的數據並返回;否則重複上述123步驟。

按照上面的策略,經過一番通宵緊急上線操作後,系統終於恢復了正常。正當開發人員長舒了口氣準備下班回家睡覺的時候,系統警報再次想起,系統再次宕機了。

有人扒出了一個2年前的帖子,這個帖子在2年前就已經爆料李某某由於PC被警方拘捕,當時大家都不信。於是這個2年前的帖子得到了眾人狂熱的轉發與閲讀查看。

其實宕機的原因很明顯,因為系統只規劃緩存了最近1年的所有帖子信息,而對超過1年的帖子的操作,都會直接請求到數據庫上。這個2年前的帖子突然爆火導致大量的用户來請求直接打到了下游,再次將數據庫壓垮 —— 也就是説又一次出現了緩存擊穿,在同一塊石頭上摔倒了兩次!

對於業務中最常使用的旁路型緩存而言,通常會先讀取緩存,如果不存在則去數據庫查詢,並將查詢到的數據添加到緩存中,這樣就可以使得後面的請求繼續命中緩存。

但是這種常規操作存在個“漏洞”,因為大部分緩存容量有限制,且很多場景會基於LRU策略進行內存中熱點數據的淘汰,假如有個惡意程序(比如爬蟲)一直在刷歷史數據,容易將內存中的熱點數據變為歷史數據,導致真正的用户請求被打到數據庫層。因而又出現了一些業務場景,會使用類似上面所舉的例子的策略,緩存指定時間段內的數據(比如最近1年),且數據不存在時從DB獲取內容之後也不會回寫到緩存中。針對這種場景,在緩存的設計時,需要考慮到對這種冷數據的加熱機制進行一些額外處理,如設定一個門檻,如果指定時間段內對一個冷數據的訪問次數達到閾值,則將冷數據加熱,添加到熱點數據緩存中,並設定一個獨立的過期時間,來解決此類問題。

比如上面的例子中,我們可以約定同一秒內對某條冷數據的請求超過10次,則將此條冷數據加熱作為臨時熱點數據存入緩存,設定緩存過期時間為30天(一般一個陳年八卦一個月足夠消停下去了)。通過這樣的機制,來解決冷數據的突然竄熱對系統帶來的不穩定影響。如下圖所示:

又是一番緊急上線,終於,系統又恢復正常了。

緩存穿透:合理的防身自保手段

我們的系統對外開放並運行的時候,面對的環境險象環生。你不知道請求是來自一個正常用户還是某些別有用心的盜竊者、亦或是個純粹的破壞者。

還是上面的論壇的例子:

用户在互動論壇上點擊帖子並查看內容的時候,界面調用查詢帖子詳情接口時會傳入帖子ID,然後後端基於帖子ID先去緩存中查詢,如果緩存中存在則直接返回數據,否則會嘗試從MySQL中查詢數據並返回。

有些人盯上了論壇的內容,便搞了個爬蟲程序,模擬帖子ID的生成規則,調用查詢詳情接口並傳入自己生成的ID去遍歷挖取系統內的帖子數據,這樣導致很多傳入的ID是無效的、系統內並不存在對應ID的帖子數據。

所以,上面大量無效的ID請求到系統內,因為無法命中緩存而被轉到MySQL中查詢,而MySQL中其實也無法查詢到對應的數據(因為這些ID是惡意生成的、壓根不存在)。大量此類請求頻繁的傳入,就會導致請求一直依賴MySQL進行處理,極易沖垮下游模塊。這個便是經典的緩存穿透問題(緩存穿透緩存擊穿非常相似,區別點在於緩存穿透的實際請求數據在數據庫中也沒有,而緩存擊穿是僅僅在緩存中沒命中,但是在數據庫中其實是存在對應數據的)。

緩存穿透的情況往往出現在一些外部干擾或者攻擊情景中,比如外部爬蟲、比如黑客攻擊等等。為了解決緩存穿透的問題,可以考慮基於一些類似白名單的機制(比如基於布隆過濾器的策略,後面系列文章中會詳細探討),當然,有條件的情況下,也可以構建一些反爬策略,比如添加請求籤名校驗機制、比如添加IP訪問限制策略等等。

緩存的數據一致性

緩存作為持久化存儲(如數據庫)的輔助存在,畢竟屬於兩套系統。理想情況下是緩存數據與數據庫中數據完全一致,但是業務最常使用的旁路緩存架構下,在一些分佈式或者高併發的場景中,可能會出現緩存不一致的情況。

數據庫更新+緩存更新

在數據有變更的時候,需要同時更新緩存和數據庫兩個地方的數據。因為涉及到兩個模塊的數據更新,所以會有2種組合情況:

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

單線程場景下,如果更新緩存和更新數據庫操作都是成功的,則可以保證數據庫與緩存數據是一致的。但是在多線程場景下,由於由於更新緩存和更新數據庫是兩個操作,不具備原子性,就有可能出現多個併發請求交叉的情況,進而導致緩存和數據庫中的記錄不一致的情況。比如下面這個場景:

這種情況下,有很多的人會選擇結合數據庫的事務來一起控制。因為數據庫有事務控制,而Redis等緩存沒有事務性,所以會在一個DB事務中封裝多個操作,比如先執行數據庫操作,執行成功之後再進行緩存更新操作。這樣如果緩存更新失敗,則直接將當前數據庫的事務回滾,企圖用這種方式來保證緩存數據與DB數據的一致。

乍看似乎沒毛病,但是細想一下,其實是有前提條件的。我們知道數據庫事務的隔離級別有幾種不同的類型,需要保證使用的事務隔離級別為Serializable或者Repeatable Read級別,以此來保證併發更新的場景下不會出現數據不一致問題,但這也降低了併發效率,提高數據庫的CPU負載(隔離級別與併發性能存在一定的關聯關係,見下圖所示)。

所以對於一些讀多寫少、寫操作併發競爭不是特別激烈且對一致性要求不是特別高的情況下,可以採用事務(高隔離級別) + 先更新數據庫再更新緩存的方式來達到數據一致的訴求。

數據庫更新+緩存刪除

在旁路型緩存的讀操作分支中,從緩存中沒有讀取到數據而改為從DB中獲取到數據之後,通常都會選擇將記錄寫入到緩存中。所以我們也可以在寫操作的時候選擇將緩存直接刪除,等待後續讀取的時候重新加載到緩存中。

這樣也會有兩種組合情況:

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

這種也會出現前面説的先操作成功,後操作失敗的問題。 我們先看下先刪除緩存再更新數據庫的操作策略。如果先刪除緩存成功,然後更新數據庫失敗,這種情況下,再次讀取的時候,會從DB裏面將舊數據重新加載回緩存中,數據是可以保持一致的。

雖然更新數據庫失敗這種場景下不會出現問題,但是在數據庫更新成功這種正常情況下,卻可能會在併發場景中出現問題。因為常見的緩存(如Redis)是沒有事務的,所以可能會因為併發處理順序的問題導致最終數據不一致。如下圖所示:

上圖中,因為刪除緩存更新DB非原子操作,所以在併發場景下可能的情況:

  1. A請求執行更新數據操作,先刪除了緩存中的數據;

  2. A這個時候還沒來及往DB中更新數據的時候,B查詢請求恰好進入;

  3. B先查詢緩存發現緩存中沒有數據,又從數據庫中查詢記錄並將記錄寫入緩存中(相當於A剛刪了緩存,B又將原樣數據寫回緩存了);

  4. A執行完成更新邏輯,將變更後的數據寫入到DB中。

一番操作完成後,實際上緩存中存儲的是A修改前的內容,而DB中存儲的是A修改後的數據,兩者因此出現了不一致的問題。這樣導致後面的查詢請求依舊是從緩存中獲取到舊數據,而更新後的新數據無法生效。

那麼,如果採用先更新數據庫,再刪除緩存的策略,又會有何種表現呢?假設數據庫更新成功,但是緩存刪除失敗,我們也可以通過數據庫事務回滾的方式將數據庫更新操作回滾掉,這樣在非併發狀態下,可以確保數據庫與緩存中數據是一致的。

當然,因為基於數據庫事務機制來控制,需要注意下事務的粒度不能過大,避免事務成為阻塞系統性能的瓶頸。在對併發性能要求極高的情況下,可以考慮非事物類的其餘方式來實現,如重試機制、或異步補償機制、或多者結合方式等。

比如下圖所示的這種策略:

上圖的數據更新處理策略,可以有效的保證數據的最終一致性,降低極端情況可能出現數據不一致的概率,並兜底增加了數據不一致時的自恢復能力。

具體處理邏輯説明如下:

  • 先執行數據庫的數據更新操作。

  • 更新成功,再去執行緩存記錄刪除操作。

  • 緩存如果刪除失敗,則按照預定的重試策略(比如對於指定錯誤碼進行重試,最多重試3次,每次重試間隔100ms等)進行重試。

  • 如果緩存刪除失敗,且重試依舊失敗,則將此刪除事件放入到MQ中。

  • 獨立的補償邏輯,會去消費MQ中的消息事件請求,然後按照補償策略繼續嘗試刪除。

  • 每個緩存記錄設定過期事件,極端情況下,重試刪除、補償刪除等策略全部失敗時,等到數據記錄過期自動從緩存中淘汰,作為兜底策略

這種處理方式,雖然依舊無法百分百保證數據一致,但是整體出現數據不一致情況的概率與可能性非常的小。

實際使用場景中,對於一致性要求不是特別高、且併發量不是特別大的場景,可以選擇基於數據庫事務保證的先更新數據庫再更新/刪除緩存。而對於併發要求較高、且數據一致性要求較好的時候,推薦選擇先更新數據庫,再刪除緩存,並結合刪除重試 + 補償邏輯 + 緩存過期TTL等綜合手段

小結回顧

本篇內容中,我們主要探討了下緩存的使用過程中的一些典型異常的觸發場景防護策略,並一起聊了下保持緩存與數據庫數據一致性的一些保障手段。

關於這些內容,我們本篇就聊到這裏。

那麼,你是否在使用緩存的時候遇到過類似的問題呢?你是如何解決這些問題的呢?你關於這些問題你是否有更好的理解與應對策略呢?歡迎評論區一起交流下,期待和各位小夥伴們一起切磋、共同成長。

📣 補充説明

本文屬於《深入理解緩存原理與實戰設計》系列專欄的內容之一。該專欄圍繞緩存這個宏大命題進行展開闡述,全方位、系統性地深度剖析各種緩存實現策略與原理、以及緩存的各種用法、各種問題應對策略,並一起探討下緩存設計的哲學。

如果有興趣,也歡迎關注此專欄。

我是悟道,聊技術、又不僅僅聊技術~

如果覺得有用,請點贊 + 關注讓我感受到您的支持。也可以關注下我的公眾號【架構悟道】,獲取更及時的更新。

期待與你一起探討,一起成長為更好的自己。