我所理解的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 深度歷險:核心原理與應用實踐》

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