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

陈夏松發表於2024-04-30

備忘,轉自https://www.javaboy.org/2022/0329/redis_mysql.html

很多小夥伴在面試的時候,應該都遇到過類似的問題,如何確保快取和資料庫的一致性?

如果你對這個問題有過研究,應該可以發現這個問題其實很好回答,如果第一次聽到或者第一次遇到這個問題,估計會有點懵,今天我們來聊聊這個話題。

1. 問題分析

首先我們來看看為什麼會有這個問題!

我們在日常開發中,為了提高資料響應速度,可能會將一些熱點資料儲存在快取中,這樣就不用每次都去資料庫中查詢了,可以有效提高服務端的響應速度,那麼目前我們最常使用的快取就是 Redis 了。

用 Redis 做快取,並不是一說快取就是 Redis,還是要結合業務的具體情況,我們可以根據不同業務對資料要求的實時性不同,將資料分為三級,以電商專案為例:

  • 第 1 級:訂單資料和支付流水資料:這兩塊資料對實時性和精確性要求很高,所以一般是不需要新增快取的,直接運算元據庫即可。
  • 第 2 級:使用者相關資料:這些資料和使用者相關,具有讀多寫少的特徵,所以我們使用 redis 進行快取。
  • 第 3 級:支付配置資訊:這些資料和使用者無關,具有資料量小,頻繁讀,幾乎不修改的特徵,所以我們使用本地記憶體進行快取。

選中合適的資料存入 Redis 之後,接下來,每當要讀取資料的時候,就先去 Redis 中看看有沒有,如果有就直接返回;如果沒有,則去資料庫中讀取,並且將從資料庫中讀取到的資料快取到 Redis 中,大致上就是這樣一個流程,讀取資料的這個流程實際上是比較清晰也比較簡單的,沒啥好說的。

然而,當資料存入快取之後,如果需要更新的話,往往會來帶另外的問題:

  1. 當有資料需要更新的時候,先更新快取還是先更新資料庫?如何確保更新快取和更新資料庫這兩個操作的原子性?
  2. 更新快取的時候該怎麼更新?修改還是刪除?

怎麼辦?正常來說,我們有四種方案:

  1. 先更新快取,再更新資料庫。
  2. 先更新資料庫,再更新快取。
  3. 先淘汰快取,再更新資料庫。
  4. 先更新資料庫,再淘汰快取。

到底使用哪種?

在回答這個問題之前,我們不妨先來看看三個經典的快取模式:

  1. Cache-Aside
  2. Read-Through/Write through
  3. Write Behind

2. Cache-Aside

Cache-Aside,中文也叫旁路快取模式,如果我們能夠在專案中採用 Cache-Aside,那麼就能夠儘可能的解決快取與資料庫資料不一致的問題,注意是儘可能的解決,並無法做到絕對解決。

Cache-Aside 又分為讀快取和寫快取兩種情況,我們分別來看。

2.1 讀快取

先來看一張流程圖:

它的流程是這樣:

  1. 讀取資料。
  2. 檢查快取中是否有需要的資料,如果命中快取(Cache Hit),則直接返回資料。
  3. 如果沒有命中快取,即 Cache Miss,那麼就先去訪問資料庫。
  4. 將從資料庫中讀取到的資料設定到快取中。
  5. 返回資料。

這是 Cache-Aside 的讀快取流程。

其實對於讀快取的流程而言,大家一般都沒什麼異議,有異議的主要是寫流程,我們繼續來看。

2.2 寫快取

先來看一張流程圖:

這個寫快取的流程就比較簡單,先更新資料庫中的資料,然後刪除舊的快取即可。

流程雖然簡單,但是卻引伸出來兩個問題:

  1. 為什麼是刪除舊快取而不是更新舊快取?
  2. 為什麼不先刪除舊的快取,然後再更新資料庫?

我們來分別回答這兩個問題。

為什麼是刪除舊快取而不是更新舊快取?

  1. 更新快取,說著容易做起來並不容易。很多時候我們更新快取並不是簡簡單單更新一個 Bean。很多時候,我們快取的都是一些複雜操作或者計算(例如大量聯表操作、一些分組計算)的結果,如果不加快取,不但無法滿足高併發量,同時也會給 MySQL 資料庫帶來巨大的負擔。那麼對於這樣的快取,更新起來實際上並不容易,此時選擇刪除快取效果會更好一些。
  2. 對於一些寫頻繁的應用,如果按照更新快取->更新資料庫的模式來,比較浪費效能,因為首先寫快取很麻煩,其次每次都要寫快取,但是可能寫了十次,只讀了一次,讀的時候讀到的快取資料是第十次的,前面九次寫快取都是無效的,對於這種情況不如採取先寫資料庫再刪除快取的策略。
  3. 在多執行緒環境下,這樣的更新策略還有可能會導致資料邏輯錯誤,來看如下一張流程圖:

可以看到,有兩個併發的執行緒 A 和 B:

  • 首先 A 執行緒更新了資料庫。
  • 接下來 B 執行緒更新了資料庫。
  • 由於網路等原因,B 執行緒先更新了快取。
  • A 執行緒更新了快取。

那麼此時,快取中儲存的資料就是不正確的,而如果採用了刪除快取的方式,就不會發生這種問題了。

為什麼不先刪除舊的快取,然後再更新資料庫?

這個也是考慮到併發請求,假設我們先刪除舊的快取,然後再更新資料庫,那麼就有可能出現如下這種情況:

這個操作是這樣的,有兩個執行緒,A 和 B,其中 A 寫資料,B 讀資料,具體流程如下:

  1. A 執行緒首先刪除快取。
  2. B 執行緒讀取快取,發現快取中沒有資料。
  3. B 執行緒讀取資料庫。
  4. B 執行緒將從資料庫中讀取到的資料寫入快取。
  5. A 執行緒更新資料庫。

一套操作下來,我們發現資料庫和快取中的資料不一致了!所以,在 Cache-Aside 中是先更新資料庫,再刪除快取。

2.3 延遲雙刪

其實無論是先更新資料庫再刪除快取,還是先刪除快取再更新資料庫,在併發環境下都有可能存在問題:

假設有 A、B 兩個併發請求:

  • 先更新資料庫再刪除快取:當請求 A 更新資料庫之後,還未來得及進行快取清除,此時請求 B 查詢到並使用了 Cache 中的舊資料。
  • 先刪除快取再更新資料庫:當請求 A 執行清除快取後,還未進行資料庫更新,此時請求 B 進行查詢,查到了舊資料並寫入了 Cache。

當然我們前面已經分析過了,儘量先運算元據庫再操作快取,但是即使這樣也還是有可能存在問題,解決問題的辦法就是延遲雙刪。

延遲雙刪是這樣:先執行快取清除操作,再執行資料庫更新操作,延遲 N 秒之後再執行一次快取清除操作,這樣就不用擔心快取中的資料和資料庫中的資料不一致了。

那麼這個延遲 N 秒,N 是多大比較合適呢?一般來說,N 要大於一次寫操作的時間,如果延遲時間小於寫入快取的時間,會導致請求 A 已經延遲清除了快取,但是此時請求 B 快取還未寫入,具體是多少,就要結合自己的業務來統計這個數值了。

2.4 如何確保原子性

但是更新資料庫和刪除快取畢竟不是一個原子操作,要是資料庫更新完畢後,刪除快取失敗了咋辦?

對於這種情況,一種常見的解決方案就是使用訊息中介軟體來實現刪除的重試。大家知道,MQ 一般都自帶消費失敗重試的機制,當我們要刪除快取的時候,就往 MQ 中扔一條訊息,快取服務讀取該訊息並嘗試刪除快取,刪除失敗了就會自動重試。如果小夥伴們還不懂 RabbitMQ 的使用,可以在公眾號江南一點雨後臺回覆 rabbitmq,有免費的影片+文件。

3. Read-Through/Write-Through

這種快取操作模式,松哥印象最深的是在 Oracle Coherence 中有應用,不知道小夥伴們有沒有用過 Oracle Coherence,這是一個記憶體資料網格,透過這個,應用開發人員和管理人員可快速訪問鍵值資料,Coherence 可提供叢集式低延遲資料儲存、多語言網格計算和非同步事件流處理,從而為客戶企業應用賦予超高水平的可擴充套件性和效能。

Oracle Coherence 我們就不討論了,我們就來說說 Read-Through。

3.1 Read-Through

這裡為了省事,我就不自己畫圖了,網上找了一張圖片,如下:

乍一看,很多人感覺這和 Cache-Aside 一樣呀,沒啥區別!是的,單看流程是不太容易看到區別。

Read-Through 是一種類似於 Cache-Aside 的快取方法,區別在於,在 Cache-Aside 中,由應用程式決定去讀取快取還是讀取資料庫,這樣就會導致應用程式中出現了很多業務無關的程式碼;而在 Read-Through 中,相當於多出來了一箇中間層 Cache Middleware,由它去讀取快取或者資料庫,應用層的程式碼得到了簡化,松哥之前寫過 Spring Cache 的用法,大家回憶下 Spring Cache 中的 @Cacheable 註解,感覺像不像 Read-Through?

我畫一個簡單的流程圖大家來看下:

可以看到,和 Cache-Aside 相比,其實就相當於是多了一個 Cache Middleware,這樣我們在應用程式中就只需要正常的讀寫資料就行了,並不用管底層的具體邏輯,相當於把快取相關的程式碼從應用程式中剝離出來了,應用程式只需要專注於業務就行了。

3.2 Write-Through

Write-Through 其實也是差不多,所有的操作都交給 Cache Middleware 來完成,應用程式中就是一句簡單的更新就行了,我們來看看流程:

在 Write-Through 策略中,所有的寫操作都經過 Cache Middleware,每次寫入時,Cache Middleware 會將資料儲存在 DB 和 Cache 中,這兩個操作發生在一個事務中,因此,只有兩個都寫入成功,一切才會成功。

這種寫資料的優勢在於,應用程式只與 Cache Middleware 對話,所以它的程式碼更加乾淨和簡單。

4. Write Behind

Write-Behind 快取策略類似於 Write-Through 快取,應用程式僅與 Cache Middleware 通訊,Cache Middleware 會預留一個與應用程式通訊的介面。

Write-Behind 與 Write-Through 最大的區別在於,前者是資料首先寫入快取,一段時間後(或透過其他觸發器)再將資料寫入 Database,並且這裡涉及到的寫入是一個非同步操作。這種方式下,Cache 和 DB 資料的一致性不強,對一致性要求高的系統要謹慎使用,如果有人在資料尚未寫入資料來源的情況下直接從資料來源獲取資料,則可能導致獲取過期資料,不過對於頻繁寫入的場景,這個其實非常適用。

將資料寫入 DB 可以透過多種方式完成:

  • 一種是收集所有寫入操作,然後在某個時間點(例如,當 DB 負載較低時)對資料來源進行批次寫入。
  • 另一種方法是將寫入合併成更小的批次,例如每次收集五個寫入操作,然後對資料來源進行批次寫入。

這個流程圖就不想畫了,在網上找了一張,小夥伴們參考下:

好啦,和小夥伴們簡單聊了下雙寫一致性的問題,有問題歡迎留言討論。

參考資料:

  • https://www.jianshu.com/p/a8eb1412471f
  • https://catsincode.com/caching-strategy/

相關文章