我所理解的Redis系列·第1篇·快取一致性問題的前世今生

語言: CN / TW / HK

「這是我參與2022首次更文挑戰的第5天,活動詳情檢視:2022首次更文挑戰

1. 開篇詞

記得很早之前有個阿里面試官就問我資料庫與快取的一致性問題該怎麼解決,恰逢當時剛刷完極客時間蔣德均老師的《Redis 核心技術與實戰》,裡面有一個章節講的就是快取一致性問題的前世今生。我就從一致性問題的起因到發生資料不一致的各種極限場景都給面試官一一舉例,說得他一愣一愣的,給他好好上了一課。

這輪面試當然是通過了,最後因為薪資沒談攏,拒絕了他們給出的 Offer,可能是緣分未到吧,扯遠了。

本文的主要議題是快取一致性問題,包括 Redis 常用讀寫策略、為什麼會存在快取不一致的場景、如何保證資料庫與快取的一致性。

話不多說,進入正題。

2. 旁路快取模式

我們在使用 Redis 作為快取時,一般會採用旁路快取模式(Cache Aside Pattern)作為 Redis 快取的讀寫策略。

旁路快取模式其實就是在客戶端與資料庫之間加上一層快取,在讀取資料時先讀快取中的值,在寫資料時也需要同時維護快取中的資料。這裡的「維護」可以是刪除快取,也可以是更新快取,但一般我們使用前者,能更大地保證資料的一致性。

為了更好地理解旁路快取模式,這裡畫一下讀資料及寫資料兩種場景的流程圖。

2.1 旁路快取模式讀流程

  • 客戶端發起讀請求,首先查詢快取,若命中快取,直接返回快取資料
  • 若未命中快取,查詢資料庫資料,將資料庫查詢結果更新至快取,然後返回響應結果至客戶端

旁路快取模式1讀資料.png

2.2 旁路快取模式寫流程

  • 客戶端發起寫請求,資料寫入資料庫,然後將快取中對應資料刪除

旁路快取模式2寫資料.png

可能有的同學會問了:為什麼在寫資料的時候是刪除對應的快取內容而不是更新快取資料呢?

這就涉及到快取與資料庫的一致性問題了,我們可以把快取與資料庫看成是兩個毫不相干的中介軟體,他們沒有像資料庫事務中原子性的概念,所以在處理業務的過程中很可能發生資料庫處理成功,快取處理失敗的場景。此外,也可能由於更新快取順序導致快取與資料庫資料不一致的問題。

下面一一列舉。

2.3 更新快取的異常情況

首先列舉由於更新快取順序導致快取與資料庫資料不一致的情況。

假設有一個商品的庫存數為10,分別儲存在資料和快取中,現在有兩個使用者分別買了一件商品,所以需要對庫存進行扣減。使用者1下單使得庫存數由10變為9,使用者2下單使得庫存數由9變為8,最終庫存數應該是8。

按照旁路快取模式寫流程的描述中,在更新完資料庫後是需要更新快取中對應的資料的,但是在兩次請求更新完資料庫後,向 Redis 同步更新資料的過程中發生了一些意外:使用者1更新快取請求要晚於使用者2更新快取的請求。這就導致了最終快取中的庫存數是9,也就發生了緩存於資料庫資料不一致的場景。

上述異常流程如下圖:

mermaid sequenceDiagram autonumber Client ->> Application: 使用者1下單 Client ->> Application: 使用者2下單 Application ->> MySQL: 使用者1請求使得庫存數變為9 Application ->> MySQL: 使用者2請求使得庫存數變為8 Application ->> Redis: 使用者1請求更新快取中庫存數變為8 Application ->> Redis: 使用者2請求更新快取中庫存數變為9

而第二種異常流程是更新完資料庫後,更新快取的請求都失敗了,這最終會導致快取的資料還是10,後續讀請求讀到的庫存資料將是錯誤的,同樣也導致了快取與資料庫資料不一致。

所以,我們在使用旁路快取模式時,一般會採取更新資料庫資料,同時刪除對應快取資料的方法保證資料庫與快取資料的一致性。

但事實上,還是會存在某些極限場景,會導致資料庫與快取不一致,這也是本文討論的重點。

3. 快取一致性

前文中所描述的快取一致性是指資料庫資料與快取資料的一致性,這裡的一致性其實包括了兩種情況:

  • 快取命中時,要保證快取資料與資料庫資料一致
  • 快取未命中時,要保證從資料庫載入的資料應該是最新版本,即不能出現先將資料載入到快取,然後再更新資料庫的情況

4. 資料庫與快取不一致場景

在描述旁路快取模式時,我刻意模糊了寫請求情況下更新資料庫與刪除快取的時序,因為不同的情況會有不同的問題。也就是各種快取不一致的場景,下面來具體分析。

4.1 先刪除快取,再更新資料庫

第一種情況就是先刪除快取,再更新資料庫,這種情況的時序圖如下所示:

mermaid sequenceDiagram autonumber Client ->> Application: 客戶端發起寫請求 Application ->> Redis: 服務端刪除對應快取 Application ->> MySQL: 服務端更新資料庫 Redis -->> Application: 刪除快取響應 MySQL -->> Application: 更新資料庫響應 Application ->> Client: 服務端響應客戶端寫請求

在上面的時序圖中,我畫了兩條虛線,這代表著可能會出現異常的地方,即刪除快取失敗、更新資料庫失敗,在兩種情況下,就會分為多種不同的快取不一致場景:

4.1.1 刪除快取成功,更新資料庫失敗

刪除快取成功,更新資料庫失敗場景的時序圖如下:

mermaid sequenceDiagram autonumber Client ->> Application: 客戶端發起寫請求 Application ->> Redis: 服務端刪除對應快取 Application ->> MySQL: 服務端更新資料庫 Redis ->> Application: 刪除快取成功 MySQL -->> Application: 更新資料庫失敗 Application ->> Client: 服務端響應客戶端寫請求

這種異常場景導致的結果是:快取頻繁失效,資料庫寫入異常。從使用者的角度來說,就是操作完之後資料一直是不變的。

當然這種場景是由於資料庫服務異常導致的,無論有沒有快取,這種異常場景都是需要運維人員到線上環境去檢查資料庫服務的,跟資料一致性問題關係不大,這裡只是作為一種異常情況列舉出來。

4.1.2 刪除快取失敗,更新資料庫成功

刪除快取失敗,更新資料庫成功場景的時序圖如下:

mermaid sequenceDiagram autonumber Client ->> Application: 客戶端發起寫請求 Application ->> Redis: 服務端刪除對應快取 Application ->> MySQL: 服務端更新資料庫 Redis -->> Application: 刪除快取失敗 MySQL ->> Application: 更新資料庫成功 Application ->> Client: 服務端響應客戶端寫請求

這種異常場景導致的結果是:資料庫正常更新,但快取一直是舊值。這裡就是一種資料庫資料與快取資料不一致的場景了。後續再有讀請求時,將會命中快取中儲存的舊值。

當然這種場景是由於快取服務異常導致的,當有大量資料問題反饋時,運維人員需要去線上檢查快取服務。這雖然是屬於快取不一致場景,跟快取讀寫策略沒有太大關係,這裡同樣只是作為一種異常場景列舉出來。

4.1.3 併發場景

上面兩種情況討論的是快取服務或資料庫服務異常的情況,而這種情況一般來說不太可能發生,就算髮生了也有服務提供商背鍋,開發人員無需太過擔憂,只要儘快恢復服務即可。接下來要說的多執行緒併發導致的快取不一致情況才是開發人員真正需要關注的。

假設快取服務和資料庫服務都是正常的,在併發場景下也可能存在異常情況:執行緒A刪除快取成功,但尚未更新資料庫資料,此時有執行緒B發起讀請求,發現快取未命中,然後從資料庫載入資料到快取中,而此時由於執行緒A未完成更新資料庫動作,資料庫中的資料是舊版本資料,即執行緒B讀取到了舊值,然後在旁路快取模式下,該舊值會被寫入快取,隨後執行緒A才繼續進行更新資料庫操作。這樣一來,後續讀操作讀取到的資料將不再是資料庫中的最新值。

以庫存的案例描述這個場景,時序圖如下。

mermaid sequenceDiagram autonumber 執行緒A ->> Application: 執行緒A發起寫請求 Note right of 執行緒A: 期望將庫存值由10改為9 Application ->> Redis: 執行緒A成功刪除庫存快取 Note right of Application: 此時最新庫存值9尚未寫入資料庫 執行緒B -->> Application: 執行緒B發起讀請求 Application -->> Redis: 執行緒B查詢庫存快取 Redis -->> Application: 執行緒B未命中庫存快取 Application -->> MySQL: 執行緒B查詢庫存資料庫 MySQL -->> Application: 資料庫返回庫存值 Note right of 執行緒B: 當前資料庫庫存值為10 Application -->> Redis: 庫存值被寫入快取 Note right of Application: 當前快取庫存值為10 Application -->> 執行緒B: 服務端響應執行緒B讀請求 Note right of Redis: 庫存值響應結果為10 Application ->> MySQL: 執行緒A更新資料庫庫存值 MySQL ->> Application: 執行緒A修改資料庫成功 Note right of 執行緒B: 庫存值被成功更新為9 Application ->> 執行緒A: 執行緒A寫請求成功

4.2 先更新資料庫,再刪除快取

下面討論第二種情況,先更新資料庫,再刪除快取。同樣的,這裡也需要分成三種情況討論,分別是:

  • 更新資料庫成功,刪除快取失敗:與「先刪除快取,再更新資料庫」的第二種情況一致,不再贅述。
  • 更新資料庫失敗,刪除快取成功:與「先刪除快取,再更新資料庫」的第一種情況一致,不再贅述。
  • 併發場景

假設快取服務和資料庫服務都是正常的,在先更新資料庫,再刪除快取時,併發場景下也可能發生異常情況:執行緒A寫資料庫成功,但尚未更新快取資料。此時有執行緒B發起讀請求,快取命中,而此時快取資料為舊版本資料,執行緒B讀取到的值為舊值。然後執行緒A刪除快取資料。這種併發場景下就導致執行緒B讀到的資料並非資料庫中的最新版本資料,即發生快取中資料與資料庫資料不一致的情況。

以庫存的案例描述這個場景,時序圖如下。

mermaid sequenceDiagram autonumber 執行緒A ->> Application: 執行緒A發起寫請求 Note right of 執行緒A: 期望將庫存值由10改為9 Application ->> MySQL: 執行緒A更新資料庫 MySQL ->> Application: 執行緒A修改資料庫成功 Note right of Application: 資料庫庫存值更新為9 執行緒B -->> Application: 執行緒B發起讀請求 Application -->> Redis: 執行緒B查詢庫存快取 Redis -->> Application: 執行緒B命中庫存快取 Note right of 執行緒B: 快取庫存值為10 Application -->> 執行緒B: 服務端響應執行緒B讀請求 Note right of MySQL: 執行緒B讀請求響應結果為10 Application ->> Redis: 執行緒A刪除快取 Redis ->> Application: 執行緒A刪除快取成功 Application ->> 執行緒A: 執行緒A寫請求成功

5. 快取不一致解決方案

說完了快取不一致的情況,接下來說說對於不同快取不一致情況的解決方案:

  • 重試機制:面對寫資料庫失敗或刪除快取失敗的情況時,可以基於重試機制對失敗的操作進行重試,若超過一定次數後依舊失敗,需要回滾資料庫操作,手動回滾快取資料,並丟擲業務性異常。
  • 延時雙刪:面對寫資料庫且刪除快取都成功但是在併發場景下時,由於兩個操作之間併發序列,導致其他讀操作發生在兩者之間,從而導致快取不一致的現象,可以考慮使用延時雙刪來解決,即先刪快取,再寫資料庫,然後延遲一定時間後再次刪快取。

需要注意的是,延時雙刪中的延遲一定時間,需要儘量保證在寫資料庫操作後再進行刪除,這個時間一般只能估算,同時延時雙刪策略在極限場景還是會存在不一致情況:

  • 在第一次刪除快取後,更新資料庫動作完成前,有其他讀操作未命中快取導致快取被更新至舊版本資料時,會發生資料不一致問題

若業務需要保證快取與資料庫的強一致性,則只能基於加鎖來使得讀寫請求序列化,從而實現快取強一致性。

6. 小結

本文討論了旁路快取模式、旁路快取模式的一般使用方式,資料庫與快取資料不一致場景分析以及其解決方案。

7. 參考資料

  • 《Redis 核心技術與實戰》(極客時間)
  • 《Redis 深度歷險:核心原理與應用實踐》

最後,本文收錄於個人語雀知識庫: 我所理解的後端技術,歡迎來訪。