萬字圖文講透數據庫緩存一致性問題

 緩存合理使用確提升了系統的吞吐量和穩定性,然而這是有代價的。這個代價便是緩存和數據庫的一致性帶來了挑戰,本文將針對最常見的 cache-aside 策略下如何維護緩存一致性徹底講透。

但是客觀上,我們的業務規模很可能要求着更高的 QPS,有些業務的規模本身就非常大,也有些業務會遇到一些流量高峯,比如電商會遇到大促的情況。

而這時候大部分的流量實際上都是讀請求,而且大部分數據也是沒有那麼多變化的,如熱門商品信息、微博的內容等常見數據就是如此。此時,緩存就是我們應對此類場景的利器。

緩存的意義

所謂緩存,實際上就是用空間換時間,準確地說是用更高速的空間來換時間,從而整體上提升讀的性能。

何爲更高速的空間呢?

更快的存儲介質。通常情況下,如果說數據庫的速度慢,就得用更快的存儲組件去替代它,目前最常見的就是 Redis(內存存儲)。Redis 單實例的讀 QPS 可以高達 10w/s,90% 的場景下只需要正確使用 Redis 就能應對。

就近使用本地內存。就像 CPU 也有高速緩存一樣,緩存也可以分爲一級緩存、二級緩存。即便 Redis 本身性能已經足夠高了,但訪問一次 Redis 畢竟也需要一次網絡 IO,而使用本地內存無疑有更快的速度。不過單機的內存是十分有限的,所以這種一級緩存只能存儲非常少量的數據,通常是最熱點的那些 key 對應的數據。這就相當於額外消耗寶貴的服務內存去換取高速的讀取性能。

引入緩存後的一致性挑戰

用空間換時間,意味着數據同時存在於多個空間。最常見的場景就是數據同時存在於 Redis 與 MySQL 上(爲了問題的普適性,後面舉例中若沒有特別說明,緩存均指 Redis 緩存)。

實際上,最權威最全的數據還是在 MySQL 裏的。而萬一 Redis 數據沒有得到及時的更新(例如數據庫更新了沒更新到 Redis),就出現了數據不一致。

大部分情況下,只要使用了緩存,就必然會有不一致的情況出現,只是說這個不一致的時間窗口是否能做到足夠的小。有些不合理的設計可能會導致數據持續不一致,這是我們需要改善設計去避免的。

這裏的一致性實際上對於本地緩存也是同理的,例如數據庫更新後沒有及時更新本地緩存,也是有一致性問題的,下文統一以 Redis 緩存作爲引子講述,實際上處理本地緩存原理基本一致。

(一)緩存不一致性無法客觀地完全消滅

爲什麼我們幾乎沒辦法做到緩存和數據庫之間的強一致呢?

理想情況下,我們需要在數據庫更新完後把對應的最新數據同步到緩存中,以便在讀請求的時候能讀到新的數據而不是舊的數據(髒數據)。但是很可惜,由於數據庫和 Redis 之間是沒有事務保證的,所以我們無法確保寫入數據庫成功後,寫入 Redis 也是一定成功的;即便 Redis 寫入能成功,在數據庫寫入成功後到 Redis 寫入成功前的這段時間裏,Redis 數據也肯定是和 MySQL 不一致的。如下兩圖所示:

所以說這個時間窗口是沒辦法完全消滅的,除非我們付出極大的代價,使用分佈式事務等各種手段去維持強一致,但是這樣會使得系統的整體性能大幅度下降,甚至比不用緩存還慢,這樣不就與我們使用緩存的目標背道而馳了嗎?

不過雖然無法做到強一致,但是我們能做到的是緩存與數據庫達到最終一致,而且不一致的時間窗口我們能做到儘可能短,按照經驗來說,如果能將時間優化到 1ms 之內,這個一致性問題帶來的影響我們就可以忽略不計。

更新緩存的手段

通常情況下,我們在處理查詢請求的時候,使用緩存的邏輯如下:

data = queryDataRedis(key);
if (data ==null) {
     data = queryDataMySQL(key); //緩存查詢不到,從MySQL做查詢
     if (data!=null) {
         updateRedis(key, data);//查詢完數據後更新MySQL最新數據到Redis
     }
}

也就是說優先查詢緩存,查詢不到才查詢數據庫。如果這時候數據庫查到數據了,就將緩存的數據進行更新。這是我們常說的 cache aside 的策略,也是最常用的策略。

這樣的邏輯是正確的,而一致性的問題一般不來源於此,而是出現在處理寫請求的時候。所以我們簡化成最簡單的寫請求的邏輯,此時你可能會面臨多個選擇,究竟是直接更新緩存,還是失效緩存?而無論是更新緩存還是失效緩存,都可以選擇在更新數據庫之前,還是之後操作。

這樣就演變出 4 個策略:更新數據庫後更新緩存、更新數據庫前更新緩存、更新數據庫後刪除緩存、更新數據庫前刪除緩存。下面我們來分別講述。

(一)更新數據庫後更新緩存的不一致問題

一種常見的操作是,設置一個過期時間,讓寫請求以數據庫爲準,過期後,讀請求同步數據庫中的最新數據給緩存。那麼在加入了過期時間後,是否就不會有問題了呢?並不是這樣。

大家設想一下這樣的場景。

假如這裏有一個計數器,把數據庫自減 1,原始數據庫數據是 100,同時有兩個寫請求申請計數減一,假設線程 A 先減數據庫成功,線程 B 後減數據庫成功。那麼這時候數據庫的值是 98,緩存里正確的值應該也要是 98。

但是特殊場景下,你可能會遇到這樣的情況:

線程 A 和線程 B 同時更新這個數據

更新數據庫的順序是先 A 後 B

更新緩存時順序是先 B 後 A

如果我們的代碼邏輯還是更新數據庫後立刻更新緩存的數據,那麼——

updateMySQL();
updateRedis(key, data);

就可能出現:數據庫的值是 100->99->98,但是緩存的數據卻是 100->98->99,也就是數據庫與緩存的不一致。而且這個不一致只能等到下一次數據庫更新或者緩存失效纔可能修復。

jeYmQV

當然,如果更新 Redis 本身是失敗的話,兩邊的值固然也是不一致的,這個前文也闡述過,幾乎無法根除。

(二)更新數據庫前更新緩存的不一致問題

那你可能會想,這是否表示,我應該先讓緩存更新,之後再去更新數據庫呢?類似這樣:

updateRedis(key, data);//先更新緩存
updateMySQL();//再更新數據庫

這樣操作產生的問題更是顯而易見的,因爲我們無法保證數據庫的更新成功,萬一數據庫更新失敗了,你緩存的數據就不只是髒數據,而是錯誤數據了。

你可能會想,是否我在更新數據庫失敗的時候做 Redis 回滾的操作能夠解決呢?這其實也是不靠譜的,因爲我們也不能保證這個回滾的操作 100% 被成功執行。

同時,在寫寫併發的場景下,同樣有類似的一致性問題,請看以下情況:

舉個例子。線程 A 希望把計數器置爲 0,線程 B 希望置爲 1。而按照以上場景,緩存確實被設置爲 1,但數據庫卻被設置爲 0。

gAZAan

所以通常情況下,更新緩存再更新數據庫是我們應該避免使用的一種手段。

(三)更新數據庫前刪除緩存的問題

那如果採取刪除緩存的策略呢?也就是說我們在更新數據庫的時候失效對應的緩存,讓緩存在下次觸發讀請求時進行更新,是否會更好呢?同樣地,針對在更新數據庫前和數據庫後這兩個刪除時機,我們來比較下其差異。

最直觀的做法,我們可能會先讓緩存失效,然後去更新數據庫,代碼邏輯如下:

deleteRedis(key);//先刪除緩存讓緩存失效
updateMySQL();//再更新數據庫

這樣的邏輯看似沒有問題,畢竟刪除緩存後即便數據庫更新失敗了,也只是緩存上沒有數據而已。然後併發兩個寫請求過來,無論怎麼樣的執行順序,緩存最後的值也都是會被刪除的,也就是說在併發寫寫的請求下這樣的處理是沒問題的。

然而,這種處理在讀寫併發的場景下卻存在着隱患。

還是剛剛更新計數的例子。例如現在緩存的數據是 100,數據庫也是 100,這時候需要對此計數減 1,減成功後,數據庫應該是 99。如果這之後觸發讀請求,緩存如果有效的話,裏面應該也要被更新爲 99 纔是正確的。

那麼思考下這樣的請求情況:

線程 A 更新這個數據的同時,線程 B 讀取這個數據

線程 A 成功刪除了緩存裏的老數據,這時候線程 B 查詢數據發現緩存失效

線程 A 更新數據庫成功

nxXh07

可以看到,在讀寫併發的場景下,一樣會有不一致的問題。

針對這種場景,有個做法是所謂的 “延遲雙刪策略”,就是說,既然可能因爲讀請求把一箇舊的值又寫回去,那麼我在寫請求處理完之後,等到差不多的時間延遲再重新刪除這個緩存值。

rRtq7b

這種解決思路的關鍵在於對 N 的時間的判斷,如果 N 時間太短,線程 A 第二次刪除緩存的時間依舊早於線程 B 把髒數據寫回緩存的時間,那麼相當於做了無用功。而 N 如果設置得太長,那麼在觸發雙刪之前,新請求看到的都是髒數據。

(四)更新數據庫後刪除緩存

那如果我們把更新數據庫放在刪除緩存之前呢,問題是否解決?我們繼續從讀寫併發的場景看下去,有沒有類似的問題。

rqx0DK

可以看到,大體上,採取先更新數據庫再刪除緩存的策略是沒有問題的,僅在更新數據庫成功到緩存刪除之間的時間差內——[T2,T3) 的窗口 ,可能會被別的線程讀取到老值。

而在開篇的時候我們說過,緩存不一致性的問題無法在客觀上完全消滅,因爲我們無法保證數據庫和緩存的操作是一個事務裏的,而我們能做到的只是儘量縮短不一致的時間窗口。

在更新數據庫後刪除緩存這個場景下,不一致窗口僅僅是 T2 到 T3 的時間,內網狀態下通常不過 1ms,在大部分業務場景下我們都可以忽略不計。因爲大部分情況下一個用戶的請求很難能再 1ms 內快速發起第二次。

但是真實場景下,還是會有一個情況存在不一致的可能性,這個場景是讀線程發現緩存不存在,於是讀寫併發時,讀線程回寫進去老值。併發情況如下:

fHgUHv

總的來說,這個不一致場景出現條件非常嚴格,因爲併發量很大時,緩存不太可能不存在;如果併發很大,而緩存真的不存在,那麼很可能是這時的寫場景很多,因爲寫場景會刪除緩存。

所以待會我們會提到,寫場景很多時候實際上並不適合採取刪除策略。

(五)總結四種更新策略

終上所述,我們對比了四個更新緩存的手段,做一個總結對比,其中應對方案也提供參考,具體不做展開,如下表:

GWVfHw

從一致性的角度來看,採取更新數據庫後刪除緩存值,是更爲適合的策略。因爲出現不一致的場景的條件更爲苛刻,概率相比其他方案更低。

那麼是否更新緩存這個策略就一無是處呢?不是的!

刪除緩存值意味着對應的 key 會失效,那麼這時候讀請求都會打到數據庫。如果這個數據的寫操作非常頻繁,就會導致緩存的作用變得非常小。而如果這時候某些 Key 還是非常大的熱 key,就可能因爲扛不住數據量而導致系統不可用。

如下圖所示:

刪除策略頻繁的緩存失效導致讀請求無法利用緩存

所以做個簡單總結,足以適應絕大部分的互聯網開發場景的決策:

針對大部分讀多寫少場景,建議選擇更新數據庫後刪除緩存的策略。

針對讀寫相當或者寫多讀少的場景,建議選擇更新數據庫後更新緩存的策略。

最終一致性如何保證?

緩存設置過期時間

第一個方法便是我們上面提到的,當我們無法確定 MySQL 更新完成後,緩存的更新 / 刪除一定能成功,例如 Redis 掛了導致寫入失敗了,或者當時網絡出現故障,更常見的是服務當時剛好發生重啓了,沒有執行這一步的代碼。

這些時候 MySQL 的數據就無法刷到 Redis 了。爲了避免這種不一致性永久存在,使用緩存的時候,我們必須要給緩存設置一個過期時間,例如 1 分鐘,這樣即使出現了更新 Redis 失敗的極端場景,不一致的時間窗口最多也只是 1 分鐘。

這是我們最終一致性的兜底方案,萬一出現任何情況的不一致問題,最後都能通過緩存失效後重新查詢數據庫,然後回寫到緩存,來做到緩存與數據庫的最終一致。

如何減少緩存刪除 / 更新的失敗?

萬一刪除緩存這一步因爲服務重啓沒有執行,或者 Redis 臨時不可用導致刪除緩存失敗了,就會有一個較長的時間(緩存的剩餘過期時間)是數據不一致的。

那我們有沒有什麼手段來減少這種不一致的情況出現呢?這時候藉助一個可靠的消息中間件就是一個不錯的選擇。

因爲消息中間件有 ATLEAST-ONCE 的機制,如下圖所示。

我們把刪除 Redis 的請求以消費 MQ 消息的手段去失效對應的 Key 值,如果 Redis 真的存在異常導致無法刪除成功,我們依舊可以依靠 MQ 的重試機制來讓最終 Redis 對應的 Key 失效。

而你們或許會問,極端場景下,是否存在更新數據庫後 MQ 消息沒發送成功,或者沒機會發送出去機器就重啓的情況?

這個場景的確比較麻煩,如果 MQ 使用的是 RocketMQ,我們可以藉助 RocketMQ 的事務消息,來讓刪除緩存的消息最終一定發送出去。而如果你沒有使用 RocketMQ,或者你使用的消息中間件並沒有事務消息的特性,則可以採取消息表的方式讓更新數據庫和發送消息一起成功。事實上這個話題比較大了,我們不在這裏展開。

如何處理複雜的多緩存場景?

有些時候,真實的緩存場景並不是數據庫中的一個記錄對應一個 Key 這麼簡單,有可能一個數據庫記錄的更新會牽扯到多個 Key 的更新。還有另外一個場景是,更新不同的數據庫的記錄時可能需要更新同一個 Key 值,這常見於一些 App 首頁數據的緩存。

我們以一個數據庫記錄對應多個 Key 的場景來舉例。

假如系統設計上我們緩存了一個粉絲的主頁信息、主播打賞榜 TOP10 的粉絲、單日 TOP 100 的粉絲等多個信息。如果這個粉絲註銷了,或者這個粉絲觸發了打賞的行爲,上面多個 Key 可能都需要更新。只是一個打賞的記錄,你可能就要做:

updateMySQL();//更新數據庫一條記錄
deleteRedisKey1();//失效主頁信息的緩存
updateRedisKey2();//更新打賞榜TOP10
deleteRedisKey3();//更新單日打賞榜TOP100

這就涉及多個 Redis 的操作,每一步都可能失敗,影響到後面的更新。甚至從系統設計上,更新數據庫可能是單獨的一個服務,而這幾個不同的 Key 的緩存維護卻在不同的 3 個微服務中,這就大大增加了系統的複雜度和提高了緩存操作失敗的可能性。最可怕的是,操作更新記錄的地方很大概率不只在一個業務邏輯中,而是散發在系統各個零散的位置。

針對這個場景,解決方案和上文提到的保證最終一致性的操作一樣,就是把更新緩存的操作以 MQ 消息的方式發送出去,由不同的系統或者專門的一個系統進行訂閱,而做聚合的操作。如下圖:

不同業務系統訂閱 MQ 消息單獨維護各自的緩存 Key

專門更新緩存的服務訂閱 MQ 消息維護所有相關 Key 的緩存操作

通過訂閱 MySQL binlog 的方式處理緩存

上面講到的 MQ 處理方式需要業務代碼裏面顯式地發送 MQ 消息。還有一種優雅的方式便是訂閱 MySQL 的 binlog,監聽數據的真實變化情況以處理相關的緩存。

例如剛剛提到的例子中,如果粉絲又觸發打賞了,這時候我們利用 binlog 表監聽是能及時發現的,發現後就能集中處理了,而且無論是在什麼系統什麼位置去更新數據,都能做到集中處理。

目前業界類似的產品有 Canal,具體的操作圖如下:

利用 Canel 訂閱數據庫 binlog 變更從而發出 MQ 消息,讓一個專門消費者服務維護所有相關 Key 的緩存操作

到這裏,針對大型系統緩存設計如何保證最終一致性,我們已經從策略、場景、操作方案等角度進行了細緻的講述,希望能對你起到幫助。

作者個人郵箱 jaskeylin@apache.org,微信:JaskeyLam

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