天天看點

8種方案,保證緩存和資料庫的最終一緻性

前言

我們通常使用緩存機制來提升系統的性能,緩存系統下的讀寫操作,一般都需要操作資料庫與緩存。

對于讀操作,一般是先查詢緩存,查詢不到再查詢資料庫,最後回寫進緩存。

8種方案,保證緩存和資料庫的最終一緻性

而對于寫操作,究竟是先删除(更新)緩存,再更新資料庫,還是先更新資料庫,再删除(更新)緩存呢?

由于對資料庫以及緩存的整體操作,并不是原子性的,再加上讀寫并發,究竟什麼樣的方案可以保證資料庫與緩存的一緻性呢?

下面介紹8種方案,配合讀寫時序圖,希望你能從其中了解到保證一緻性的設計要點。

方案1   給緩存設定過期時間

這種方案适用于對資料一緻性要求較低或者寫請求很少的業務,當讀請求沒有命中緩存時,就從資料庫中讀,之後回寫到緩存裡,同時設定一個過期時間。

寫請求直接更改資料庫,不用操作緩存。是以當一個key沒過期時,寫請求更改了資料庫,之後的讀還是讀取到舊資料。這個時候确實發生了不一緻,但業務并不敏感。

方案2   先更新資料庫,再更新緩存

8種方案,保證緩存和資料庫的最終一緻性

如果利用到緩存,那麼肯定是讀多寫少的場景。但不能否定的是,可能會存在突發的寫多讀少的階段。

在這個特殊的階段中,會頻繁地更改資料庫與緩存,但緩存不會被頻繁地讀,更新緩存是在做無用功。

該方案可能還會有将髒資料寫回到緩存中的風險:

8種方案,保證緩存和資料庫的最終一緻性

當再有讀請求過來時,會直接從緩存中查詢到1,而資料庫中的值為3,造成不一緻。

是以,該方案的不足在于:

  • 寫多讀少時,頻繁更新緩存會降低性能
  • 并發情況下可能存在将髒資料寫回緩存的風險

方案3    先更新緩存,再更新資料庫

和方案2類似,也會存在相同的問題。

方案4    先更新資料庫,再删除緩存

既然方案2與方案3都是更新緩存,這裡不妨直接删除緩存呢?

8種方案,保證緩存和資料庫的最終一緻性

當讀寫串行時,不會發生不一緻的情況,貌似是一種比較好的方案。

不過看一下這個例子:

8種方案,保證緩存和資料庫的最終一緻性

首先系統處于一個緩存過期的初始狀态,接着讀寫并發。由于讀請求讀到了資料庫的舊值,而由于某種原因,回寫發生在寫請求執行完畢之後,造成了刷髒的問題。

這種問題發生的機率較低,首先緩存得過期,再者讀請求的整條鍊路的執行速度慢于寫請求。一般來說,讀肯定是快于寫的。

方案5    先删除緩存,再更新資料庫

8種方案,保證緩存和資料庫的最終一緻性

同樣,當存在讀寫并發時,事情就不會往預料的方向上發展了,看下面這個例子:

8種方案,保證緩存和資料庫的最終一緻性

寫請求删除緩存後,讀請求無法命中緩存,是以讀到資料庫的舊值2。寫請求更新完資料庫後,讀請求再将1回寫進緩存,同樣存在刷髒的風險。

如果a永不過期的且後續沒有執行寫請求的話,那麼讀到的一直都是髒資料,是以我們一般都會設定緩存的過期時間,作為一種兜底政策。在a過期後,就會重新從資料庫中讀取。

該問題發生的機率一般會高于方案4,那如何去解決呢?

可不可以主動讓髒資料過期,也就是讓寫請求再删一次緩存呢?

可以的,這種方案稱作為延時雙删。

方案6    延時雙删

在方案5的第2個案例圖上進行修改:在讀請求刷髒後,寫請求再次删除緩存。

8種方案,保證緩存和資料庫的最終一緻性

此方案的難點在于,sleep的時間該怎麼去确定。如果偏大,同步删除的話會造成吞吐量的降低與查髒。如果偏小,則有可能第二次删除在刷髒之前發生,起不到“雙删”的作用。

是以,我們需要結合業務對sleep的時間做出評估。一般來說,sleep的時間應該稍大于讀請求查詢資料與回寫緩存的時間。

延時雙删,對使用讀寫分離,主從同步的資料庫也有奇效。

在主從同步正常且沒有出現讀寫并發的情況下,資料庫與緩存是一緻的

8種方案,保證緩存和資料庫的最終一緻性

如果主從同步存在延遲呢?導緻讀請求讀到a=2,最終會造成不一緻的情況

8種方案,保證緩存和資料庫的最終一緻性

如果使用延時雙删,就可以有效解決

8種方案,保證緩存和資料庫的最終一緻性

不過這裡的sleep時間=讀請求的查詢從庫時間+回寫緩存時間+主從同步的延遲時間

不過為了規避主從同步延遲造成的資料庫與緩存的不一緻,可以強迫寫之後的快速讀走主庫。

不過這裡還是希望大家,多去了解可能造成主從同步延遲的原因,例如從庫配置差,本地重放sql進度慢;從庫數量少,造成大量讀之下占用全部cpu;從庫是否正在執行DDL語句或者慢查詢等。

延時雙删看起來趨于完美了,但較真的同學始終不認賬。

  • 延時是使用同步的延時,造成吞吐量降低怎麼辦?
  • 雙删中第二次删除怎麼辦?

對于第一個問題,可以将第二次删除改為異步的。

對于第二個問題,可以将第二次删除改為可重試的。

其實第二個問題,也存在于方案4中,即先更新資料庫,再删除緩存。

我們拿方案4進行優化,可以引入消息中間件。

方案7    消息隊列

先更新資料庫,接着将删除緩存的消息投遞到mq中。自身拿到消息後,嘗試進行删除緩存。如果失敗,則不斷進行重試。

8種方案,保證緩存和資料庫的最終一緻性

引入了消息隊列,系統的複雜性提升,可用性降低。

也會帶來各種各樣的問題,例如消息丢失、亂序與重複消費等。亂序與重複消費的問題,在删除緩存的場景下,不會造成任何問題。

不過如果一條删除緩存的消息的丢失,将會導緻在緩存過期前出現資料不一緻的情況。

這裡稍微帶一下mq中如何保證消息不丢失的措施:需要生産端、mq自身與消費端共同去保障。

  • 生産端,對生産的消息進行狀态标記,開啟confirm機制,依據mq的響應來更新消息狀态,使用定時任務重新投遞逾時的消息,多次投遞失敗進行報警。
  • mq自身,開啟持久化,并在落盤後再進行ack。如果是鏡像部署模式,需要在同步到多個副本之後再進行ack。
  • 消費端,開啟手動ack模式,在業務處理完成後再進行ack,并且需要保證幂等。

通過以上的處理,理論上不存在消息丢失的情況,但是系統的吞吐量以及性能有所下降。

如果想要詳細了解如何在各個階段保證消息不丢失,可以移步我的另外一篇文章​​RabbitMQ如何在各個環節保證消息不丢失​​

引入消息隊列,帶來了可以異步重試的好處,但同時需要通過多種機制去保證删除消息不丢失。此外,該方案會對業務代碼造成一定的侵入。

方案8    消息隊列+訂閱binlog

業務代碼隻操作資料庫,不操作緩存。同時啟動一個訂閱binlog的程式去監聽删除操作,然後投遞到消息隊列中。再啟動一個消費者,根據消息去删除緩存。

對binlog不熟悉的同學,可以參考我的另外一篇文章​​資料庫日志——binlog、redo log、undo log掃盲​​

8種方案,保證緩存和資料庫的最終一緻性

在MySQL中,可以使用canal中間件來訂閱binlog。

在該方案中,再次使用一個中間件來幫我們完成解耦工作,但系統的複雜度确實也在逐漸上升。

總結

給緩存設定過期時間

簡單直接,适用于對資料一緻性要求較低或者寫請求很少的業務

先更新資料庫,再更新緩存

先更新緩存,再更新資料庫

  • 寫多讀少時,頻繁更新緩存會降低性能
  • 并發情況下可能存在将髒資料寫回緩存的風險

先更新資料庫,再删除緩存

  • 極低機率在讀寫并發時發生刷髒

先删除緩存,再更新資料庫

  • 較低機率在讀寫并發時發生刷髒

延時雙删

  • sleep的由業務評估,稍大于讀請求的查詢資料庫與回寫緩存的時間
  • 對主從同步延遲也有奇效
  • 存在第二次删除失敗的情況

消息隊列

  • 對删除失敗的消息進行異步重試
  • 會對業務代碼造成一定的侵入

消息隊列+訂閱binlog

  • 解耦
  • 系統複雜度上升

最後

以上的所有方案,都是盡可能的保證資料庫與緩存的一緻性,也就是最終一緻性。

如果使用CAP理論來看待這個由業務代碼+資料庫+緩存組成的分布式系統,首先該系統必須要能容忍網絡分區,其次對于覺得部分的場景,該分布式系統應當也需要滿足可用性。也就是說,緩存節點當機後或出現網絡閃斷,整個系統應當還能夠對外提供服務。根據CAP定理,該系統就無法滿足強一緻性。對CAP不熟悉的同學,可以參考我的另外一篇文章​​常說的分布式系統核心理論CAP與BASE到底是什麼​​

如果就要保證強一緻性,例如使用Raft方案來做強一緻。如果能做到強一緻,那麼整個系統的性能就會大打折扣。使用到緩存,就會為了提升性能。是以,強一緻一般與提升性能是背道而馳的。當然,緩存是有過期時間的,這種兜底操作将徹底避免永遠出現不一緻的情況。

對分布式一緻性算法Raft不了解的同學,可以參考我的另外一篇文章​​22張圖,帶你入門分布式一緻性算法Raft​​

從方案1到方案8,系統的複雜性逐漸上升,但确實能解決一些痛點,例如同步删除性能差,第二次删除失敗等等。