天天看點

緩存資料一緻性探究

作者:碼客生活

緩存是一種較低成本提升系統性能的方式,自它面世第一天起就備受廣大開發者的喜愛。然而正如《人月神話》中的那句經典的“沒有銀彈”中所說,軟體工程的設計沒有銀彈。

就像每一次釋出上線修複問題的同時,也極易引入新的問題,自緩存誕生的第一天起,緩存與資料庫的資料一緻性問題就深深困擾着開發者們。

關鍵詞:原子性、事務性、資料一緻性、雙寫一緻性

緩存的查詢

先查詢緩存,如果查詢失敗,那麼去查詢DB,之後重建緩存,基本上不存在異議。

緩存的更新

先更新DB還是先更新緩存?是更新緩存還是删除緩存?在正常情況下,怎麼操作都可以,但一旦面對高并發場景,就值得細細思量了。

1、先更新資料庫再更新緩存

線程A:更新資料庫(第1s)——> 更新緩存(第10s)

線程B:更新資料庫 (第3s)——> 更新緩存(第5s)

并發場景下,這樣的情況是很容易出現的,每個線程的操作先後順序不同,這樣就導緻請求B的緩存值被請求A給覆寫了,資料庫中是線程B的新值,緩存中是線程A的舊值,并且會一直這麼髒下去直到緩存失效(如果你設定了過期時間的話)。

緩存資料一緻性探究

2、先更新緩存再更新資料庫

線程A:更新緩存(第1s)——> 更新資料庫(第10s)

線程B: 更新緩存(第3s)——> 更新資料庫(第5s)

和前面一種情況相反,緩存中是線程B的新值,而資料庫中是線程A的舊值。

緩存資料一緻性探究

前兩種方式之是以會在并發場景下出現異常,本質上是因為更新緩存和更新資料庫是兩個操作,我們沒有辦法控制并發場景下兩個操作之間先後順序,也就是先開始操作的線程先完成自己的工作。

如果把它化簡,更新時隻更新資料庫,同時删除緩存。等待下一次查詢時命中不到緩存,再去重建緩存,是不是就解決了這個問題?

基于此,後面的兩種方案應運而生。

3、先删除緩存再更新資料庫

通過這種方式,我們很驚喜地發現,前面困擾我們的并發場景的問題确實被解決了!兩個線程都隻修改資料庫,不管誰先,資料庫以之後修改的線程為準。

但這個時候,我們來思考另一個場景:兩個并發操作,一個是更新操作,另一個是查詢操作,更新操作删除緩存後,查詢操作沒有命中緩存,先把老資料讀出來後放到緩存中,然後更新操作更新了資料庫。于是,在緩存中的資料還是老的資料,導緻緩存中的資料是髒的。很顯然,這種狀況也不是我們想要的。

緩存資料一緻性探究

延時雙删

在這種方案下,拓展出了延時雙删的解決手段。

1.删除緩存

2.更新資料庫

3.睡眠一段時間

4.再次删除緩存

加了個睡眠時間,主要是為了確定請求 A 在睡眠的時候,請求 B 能夠在這這一段時間完成「從資料庫讀取資料,再把缺失的緩存寫入緩存」的操作,然後請求 A 睡眠完,再删除緩存。

是以,請求 A 的睡眠時間就需要大于請求 B 「從資料庫讀取資料 + 寫入緩存」的時間。

但是具體睡眠多久其實是個玄學,很難評估出來,是以這個方案也隻是盡可能保證一緻性而已,極端情況下,依然也會出現緩存不一緻的現象。

是以,還是不太建議這種方案。

4、先更新資料庫再删除緩存(cache aside)

這種方式,在方案3的基礎上,又将二者的順序進行了調換。我們再把前面的場景在這種方案下進行驗證:一個是查詢操作,一個是更新操作的并發,我們先更新了資料庫中的資料,此時,緩存依然有效,是以,并發的查詢操作拿的是沒有更新的資料,但是,更新操作馬上讓緩存的失效了,後續的查詢操作再把資料從資料庫中拉出來。而不會方案3一樣,後續的查詢操作一直在取老的資料。

而這,也正是緩存使用的标準的design pattern,也就是cache aside。包括Facebook的論文《Scaling Memcache at Facebook》也使用了這個政策。

那麼,是否這種方案就是萬無一失的完美政策呢?其實也并不然,再來看看這種場景:一個是讀操作,但是沒有命中緩存,然後就到資料庫中取資料,此時來了一個寫操作,寫完資料庫後,讓緩存失效,然後,之前的那個讀操作再把老的資料放進去,是以,會造成髒資料。

但是這個case理論上會出現,不過,實際上出現的機率可能非常低,因為這個條件需要發生在讀緩存時緩存失效,而且并發着有一個寫操作。而實際上資料庫的寫操作會比讀操作慢得多,而且還要鎖表,而讀操作必需在寫操作前進入資料庫操作,而又要晚于寫操作更新緩存,所有的這些條件都具備的機率基本并不大。

是以,要麼通過2PC或是Paxos協定保證一緻性,要麼就是拼命的降低并發時髒資料的機率,而Facebook使用了這個降低機率的玩法,因為2PC太慢,而Paxos太複雜。當然,最好還是為緩存設定上過期時間,這樣,即使資料出現了不一緻,也能在一段時間之後失效,更新上一緻的資料。

操作失敗

上面雖然列舉了不少較為複雜的并發場景,但實際上還是理想情況:即,對資料庫和緩存的操作都是成功的。然而在實際生産中,由于網絡抖動、服務下線等等原因,操作是有可能失敗的。

舉例說明:應用要把資料 X 的值從 1 更新為 2,先成功更新了資料庫,然後在 Redis 緩存中删除 X 的緩存,但是這個操作卻失敗了,這個時候資料庫中 X 的新值為 2,Redis 中的 X 的緩存值為 1,出現了資料庫和緩存資料不一緻的問題。

緩存資料一緻性探究

那麼,後續有通路資料 X 的請求,會先在 Redis 中查詢,因為緩存并沒有 诶删除,是以會緩存命中,但是讀到的卻是舊值 1。

其實不管是先操作資料庫,還是先操作緩存,隻要第二個操作失敗都會出現資料一緻的問題。

問題原因知道了,該怎麼解決呢?有兩種方法:

  • 重試機制。
  • 訂閱 MySQL binlog,再操作緩存。

重試機制

我們可以引入消息隊列,将第二個操作(删除緩存)要操作的資料加入到消息隊列,由消費者來操作資料。

  • 如果應用删除緩存失敗,可以從消息隊列中重新讀取資料,然後再次删除緩存,這個就是重試機制。當然,如果重試超過一定次數,還是沒有成功,我們就需要向業務層發送報錯資訊了。
  • 如果删除緩存成功,就要把資料從消息隊列中移除,避免重複操作,否則就繼續重試。

舉個例子,來說明重試機制的過程。

緩存資料一緻性探究

訂閱 MySQL binlog,再操作緩存

「先更新資料庫,再删緩存」的政策的第一步是更新資料庫,那麼更新資料庫成功,就會産生一條變更日志,記錄在 binlog 裡。

于是我們就可以通過訂閱 binlog 日志,拿到具體要操作的資料,然後再執行緩存删除,阿裡巴巴開源的 Canal 中間件就是基于這個實作的。

Canal 模拟 MySQL 主從複制的互動協定,把自己僞裝成一個 MySQL 的從節點,向 MySQL 主節點發送 dump 請求,MySQL 收到請求後,就會開始推送 Binlog 給 Canal,Canal 解析 Binlog 位元組流之後,轉換為便于讀取的結構化資料,供下遊程式訂閱使用。

下圖是 Canal 的工作原理:

緩存資料一緻性探究

是以,如果要想保證「先更新資料庫,再删緩存」政策第二個操作能執行成功,我們可以使用「消息隊列來重試緩存的删除」,或者「訂閱 MySQL binlog 再操作緩存」,這兩種方法有一個共同的特點,都是采用異步操作緩存。

總結

1、cache aside并非萬能

雖然說catch aside可以被稱之為緩存使用的最佳實踐,但與此同時,它引入了緩存的命中率降低的問題,(每次都删除緩存自然導緻更不容易命中了),是以它更适用于對緩存命中率要求并不是特别高的場景。如果要求較高的緩存命中率,依然需要采用更新資料庫後同時更新緩存的方案。

2、緩存資料不一緻的解決方案

前面已經說了,在更新資料庫後同時更新緩存,會在并發的場景下出現資料不一緻,那我們該怎麼規避呢?方案也有兩種。

引入分布式鎖

在更新緩存之前嘗試擷取鎖,如果已經被占用就先阻塞住線程,等待其他線程釋放鎖後再嘗試更新。但這會影響并發操作的性能。

設定較短緩存時間

設定較短的緩存過期時間能夠使得資料不一緻問題存在的時間也比較長,對業務的影響相對較小。但是與此同時,其實這也使得緩存命中率降低,又回到了前面的問題裡...

是以,綜上所述,沒有永恒的最佳方案,隻有不同業務場景下的方案取舍。

行文至此,不由得默念一聲:“There is no silver bullet!”,并再次為《人月神話》作者的精準洞見而感歎。

緩存資料一緻性探究

繼續閱讀