資料庫和快取的一致性如何保證

寒光瀲灩晴方好 發表於 2022-11-25
資料庫

最近幫組裡做講座預約系統,雖然使用人數不多,但終於還是遇到了一些系統經典問題,比如資料庫與快取的一致性問題,很有意思,好記性不如爛筆頭,學習了一些思路以後決定記錄下來與大家分享。


什麼是資料庫與快取的一致性問題

程式設計師應該沒人不懂這個,但我還是覺得應該寫上,有頭有尾。所謂資料庫與快取的一致性問題,可以說是伴隨著計算機這個東西一路走來的古老問題。

就我所知最早的快取一致性問題經典案例就是CPU的Cache與記憶體之間的快取一致性問題。

學過計組的都知道,每個CPU都有自己獨享的快取記憶體Cache,而Cache本質上是對於記憶體的快取,當多個CPU共享一個記憶體時,該問題就來了,比如A,B兩個CPU的快取記憶體中都儲存了記憶體上0x00001111這個地址的資料,如果此時A處理完了該資料並寫回了記憶體,那麼顯然B的Cache中的該資料就過期了,如果B又讀取了該資料進行處理,那麼就使用了錯誤的資料。我們必須保證所有CPU讀取到的快取中的內容是真實的,不然處理虛假的資料只會造成錯誤的結果。而這一點引申至一切資料庫+快取的結構中都適用。

CPU的解決方案是基於匯流排嗅探機制的 MESI 協議,這個大家也都學過不細說,總之該協議保證了寫傳播和事物的序列化,解決了CPU的快取一致性問題,保證了我們使用計算機所獲得的服務質量。

扯遠了,總之快取一致性問題本質上就是快取資料與資料庫資料之間的同步問題,一旦資料庫中的資料被修改,就必須要讓所有快取了該資料的使用者都知道該資料快取已經失效,需要讀取最新值。


快取一致性的解決方案在不同場景下的分析

解決方案無論好壞我都列在下面,如果你希望找一個靠譜的方案請選後面的,前面的例子主要還是給自己看看理解理解。

  • 利用寫入順序的方案
    • 先寫快取再寫資料庫
    • 先寫資料庫再寫快取
  • 刪除快取方案
    • 先刪除快取,再寫資料庫
    • 先寫資料庫,再刪快取

先寫快取,再寫資料庫

目前沒人會用的方案,先寫快取風險太大,因為要明確當今主流的微服務架構下,任何服務都是不那麼可靠的,如果先寫快取成功,再寫資料庫卻失敗了,這時我們的快取中就出現了假資料,這是不可接受的,所以目前這種方案採用的很少。


先寫資料庫,再寫快取

雖然沒有假資料那麼嚴重但還是存在同樣的問題,如果先寫資料庫成功,再寫快取失敗,那麼資料庫中資料雖然真實但是也讀取不到,還是沒有意義,指望服務自己爭氣不要出錯等同於給自己埋雷。

也有人在這裡會說可以把寫資料庫和寫快取都放在一個事務中,藉助事務的原子性來保證正確。這還是會存在非常多的問題,在小併發量下勉強能用,但是這個做法將會嚴重影響介面效能,不過有時候我很懷疑學校自己的搶課系統是不是就是這麼做的,不然怎麼能每次搶課都那麼卡...

但是一旦併發量起來,這個方案還是會遇到先後順序的問題,比如A,B兩個使用者在幾乎相同的時間開啟了事務準備寫回資料,其中A先寫完了資料庫,但是寫快取時網路波動被延遲,所以又慢於B寫快取,那麼兩個事務執行完,你就會發現資料庫中是B寫的,快取中是A寫的,還是不一致,更不用說每次寫資料庫操作還需要附帶一次寫快取,本身就是對於系統資源的一種浪費。

那如果透過加鎖來防止併發事務出錯,首先你需要在這裡引入分散式鎖問題,相當複雜,其次,這將進一步影響本來就不太行的系統效能,大大折損整個系統的吞吐量,所以總的來說這個方案還是拋棄比較好。


先刪除快取,再寫資料庫

高併發下容易出現問題的方案,老樣子A,B兩個使用者,同時發起請求,A打算寫,B打算讀。

假設A刪除了快取,然後網路卡頓,沒及時更新資料庫。這時B請求,快取未命中,於是請求資料庫,查到了舊值,隨後寫入了快取。此時A的卡頓結束,更新了資料庫。你就會發現資料庫中是新值,快取是舊值,二者不一致。

那麼能不能對這個問題再進行解決呢?還真的有,那就是快取雙刪,很好理解,就是寫之前刪除一次,寫完以後再刪一次,這樣就能保證後面的快取和資料庫的一致性了。不過這裡要注意一點,那就是第二次刪除一定要間隔一段時間,不能一完成資料庫的更新就立馬刪除,因為此時資料庫剛剛更新,可能有別的請求正拿著舊資料還沒寫完快取,你前腳剛刪它後腳就又寫上了,那不是白費力氣嗎?所以這裡必須要隔一段緩衝時間,等讀了舊資料的請求都處理完了,再去第二次刪除快取。

不過這裡還有一個問題,如果雙刪的第二次刪除失敗了怎麼辦呢?這裡先按下不表,後面再聊。


先寫資料庫,再刪除快取

這個看起來非常合理,上面那個方案既然先的那一次刪快取會導致一致性失效,那麼我乾脆不做第一次刪除,更新資料庫後,隔一段時間我再刪除不就可以保證快取一致性了嗎?

沒錯,這種情況下想要出差錯非常困難,只有當滿足以下三個條件時才會發生錯誤

  • 快取剛好過期
  • 在快取過期時發生了查詢請求,且查詢時寫請求還沒完成資料庫更新操作
  • 查詢請求的寫快取操作比寫請求的刪除操作來得更晚
    在滿足上面三種條件的情況下,同樣還是會因為查詢請求讀取並更新了舊值,導致快取一致性遭到破壞的,不過這幾個條件你也可以看出,機率是相當低了。所以這個方案是非常推薦的。

問題:刪除快取的方案如果刪除失敗了怎麼辦?

答:加入重試機制,若更新資料庫成功,但更新快取失敗,我們就需要重試此操作,如果重試成功,那麼一切照舊沒有問題,如果重試到了指定最大次數還沒有成功,那麼我們寫入資料庫並等待後續處理。

但是這裡的重試機制水同樣很深,如果同步重試,併發量高時非常影響效能。而非同步重試就引入了非常多的可能和變數。所以這裡也產生了很多種用於處理刪除快取的方案。

如下

  • 每次重試單獨建立一個執行緒
    • 不建議,高併發時可能會建立大量執行緒引發OOM問題,非常嚴重
  • 將重試任務交給指定執行緒池
    • 解決了可能OOM的問題,但如果當機有丟資料風險
  • 將重試資料寫表,再使用elastic-job這樣的定時任務進行
    • 該方案執行需要先將更新資料寫到重試表中,表中存在已重試次數和重試狀態欄位
    • 然後隔一段指定時間就進行一次重試,若成功則返回,失敗則已重試次數+1,如果超過最大次數,則記錄重試狀態為失敗,等待後續處理
    • 優點:資料落庫,缺點:實時性差
  • 將重試作為事件釋出到MQ中,等待Consumer處理
    • 實時性較高
  • 監聽bin log
    • 直接建立一個訂閱者監聽bin log的改動,每次bin log改動則由該訂閱者全權負責處理刪除,更新請求只需要寫完資料庫就可以走人,bin log訂閱者可以採用上面的那些非同步方式進行處理和重試,是一種優雅的解決方案。