快取與資料庫的雙寫一致性

detectiveHLH發表於2021-06-11

這幾天瞎逛,不知道在哪裡瞟到了快取的雙寫,就突然想起來這塊雖然簡單,但是細節上還是有足夠多我們可以去關注的點。這篇文章就來詳細聊聊雙寫一致性

首先我們知道,現在將快取記憶體應用於業務當中已經十分常見了,甚至可能跟資料庫的頻率不相上下。你的使用者量如果上去了,直接將一個裸的 MySQL 去扛住所有壓力明顯是不合理的。

這裡的快取記憶體,目前業界主流的就是 Redis 了,關於 Redis 相關的文章,之前也有聊過,在此就不贅述,感興趣的可以看看:

額,不列出來我都沒感覺關於 Redis 我居然寫了這麼多...言歸正傳。

在我們的業務中,普遍都會需要將一部分常用的熱點資料(或者說不經常變但是又比較多的資料)放入 Redis 中快取起來。下次業務來請求查詢時,就可以直接將 Redis 中的資料返回,以此來減少業務系統和資料庫的互動。

這樣有兩個好處,一個是能夠降低資料庫的壓力,另一個自不必說,對相同資料來說能夠有效的降低 API 的 RT(Response Time)。

後者其實還好,降低資料庫的壓力顯得尤為重要,因為我們的業務服務雖然能夠以較低的成本做到橫向擴充套件,但資料庫不能。

這裡的不能,其實不是指資料庫不能擴充套件。MySQL 在主從架構下,通過擴充套件 Slave 節點的數量可以有效的橫向擴充套件讀請求。而 Master 節點由於不是無狀態的,所以擴充套件起來很麻煩。

對,是很麻煩,也不是不能橫向擴充套件。但是在那種架構下,我舉個例子,主-主架構下,會帶來很多意向不到的資料同步問題,並且對整個的架構引入了新的複雜性。

就像我在之前寫的MySQL 主從原理中提到過的一樣,雙主架構更多的意義在於 HA,而不是做負載均衡。

所以,相同的資料會同時存在 Redis 和 MySQL 中,如果該資料並不會改變,那就完美的一匹。可現實很骨感,這個資料99.9999%的概率是一定會變的。

為了維護 Redis 和 MySQL 中資料的一致性,雙寫的問題的就誕生了。

Cache Aside Pattern

其中最經典的方案就是 Cache Aside Pattern ,這套定義了一套快取和資料庫的讀寫方案,以此來保證快取和資料庫中的資料一致性。

具體方案

Cache Aside Pattern 具體又分為兩種 Case,分別是讀和寫。

對於讀請求,會先去 Redis 中查詢資料,如果命中了就會直接返回資料。而如果沒有從快取中獲取到,就會去 DB 中查詢,將查詢到的資料寫回 Redis,然後返回響應。

而更新則相對簡單,但是也是最具有爭議。當收到寫請求時,會先更新 DB 中的資料,成功之後再將快取中的資料刪除

注意這裡是刪除,而不是更新。因為實際生產中,快取中存放的可能不僅僅是單一的像 truefalse或者119這種值。

為什麼是刪除

還有可能在快取中存放一整個結構體,其中包含了非常多的欄位。那麼是不是每次有一個欄位更新就都需要去把資料從快取中讀取出來,解析成對應的結構體,然後更新對應欄位的值,再寫回快取呢?又或者你是直接將原快取刪除,然後又將最新的資料寫入快取?

其實乍一看,好像沒有毛病。我更新難道不應該這麼更新嗎?在這裡,我們的關注點更多的放在了更新的方式上,而把更多的必要性給忽略到了。我們更新了這個值之後,在接下來的一段時間內,它會被頻繁訪問到嗎?可能會,但也可能根本不會被訪問到了。

那既然有可能不會被訪問到, 那我們為啥還要去更新它?而且,更新快取所帶來的開銷有時侯會非常大。

然而這還只是快取資料來源單一的情況,如果快取中快取的是某個讀模型,其資料是通過多張表的資料計算得出的,其開銷會更大。

讀模型,簡單理解就是用現有資料,計算、統計出來的一些資料。

這個思路就類似於懶載入的方式,只在需要的時候去計算它。

爭議在哪兒?

前面提到過,更新時順序為先更新 DB 中的資料,成功之後再刪除快取。但是也有人認為應該先刪除快取,再去更新 DB 中的資料

乍一看,可能並不能發現問題。甚至覺得還有那麼一絲絲合理。因為如果先刪除快取,如果刪除操作失敗,DB 中的資料也不會更新,這樣快取和 DB 中資料也能保證一致性。而且,如果刪除快取成功,但更新 DB 失敗了,大不了下次獲取時,再將資料寫回快取即可,可以說十分的合理。

但,這只是單執行緒的情況下,如果在多執行緒下,會直接造成致命的資料不一致。

上面的流程圖詳細的描述了情況,更新請求1剛剛把快取中的資料刪除,查詢請求2就過來了,查詢請求2會發現快取中是空的,所以按照 Cache Aside Pattern 的讀請求標準,會從 DB 中載入最新的資料並將其寫入快取。而此時更新請求1還沒有對 DB 進行更新操作,所以查詢請求2寫入到快取中的資料仍然是舊資料。

這樣一來,查詢請求3在下一次更新之前,讀取到的就都會是老資料。然後,更新請求1將最新的資料更新至 DB,快取和 DB 的資料就不一致了。

其實 Cache Aside Pattern 中的模式,仍然會在某些 case 下造成資料不一致。但是這個概率非常的低,因為觸發這個不一致的情況的條件太苛刻了。

首先是快取要失效,然後讀請求、寫請求併發的執行,並且讀請求要比寫請求後執行完。為啥說概率不大呢,首先在實際生產中,讀請求一般都要比寫請求快得多。除此之外,讀請求去 DB 請求資料的時間一定要早於寫請求,並且寫快取的時間還要一定晚於寫請求,比起最開始的那種情況來說,條件已經是非常的嚴格了。

如果完全不能容忍,可以通過 2PC 的模式去保證資料的一致性,也可以通過將請求序列化的方式來解決,但這樣的代價就是會犧牲併發量。

End

其實還有其他的幾種方案,比如 Read Throught PatternWrite Through PatternWrite AroundWrite Behind Caching Pattern 等等。但是這些相對於 Cache Aside Pattern 來說比較簡單,可以自己去了解一下就好。

好了以上就是本篇部落格的全部內容了,歡迎微信搜尋關注【SH的全棧筆記】,回覆【佇列】獲取MQ學習資料,包含基礎概念解析和RocketMQ詳細的原始碼解析,持續更新中。

如果你覺得這篇文章對你有幫助,還麻煩點個贊關個注分個享留個言

相關文章