首頁 > 軟體

Redis與MySQL的雙寫一致性問題

2023-03-29 06:02:40

Redis與MySQL雙寫一致性是指在使用快取和資料庫同時儲存資料的場景下( 主要是存在高並行的情況)如何保證兩者的資料一致性(內容相同或者儘可能接近)

 正常業務流程

讀沒什麼問題,關鍵就在於寫(更新)操作,這就會出現幾個問題了,這裡是先更新資料庫,然後對快取操作。但對於快取操作,是更新快取還是刪除快取呢?或者為什麼不是先操作(刪除、更新)快取在更新資料庫呢?

總結一下就是到底先操作快取再運算元據庫,還是先運算元據庫再操作快取?

帶著這幾個問題接著往下講。

首先講一下操作快取,包括兩種:更新快取和刪除快取,如何選擇?

更新快取? 刪除快取?

假設都先更新資料庫(因為先操作快取再運算元據庫問題較大,後面會講)

  •  更新快取

先更新資料庫,再更新快取。

如果兩個請求同時對同一條資料進行修改,那麼可能出現先後順序顛倒,導致快取中的資料是舊的。之後的讀請求讀到的都是舊資料,只有當快取失效後,才能從資料庫中得到正確的值。

  • 刪除快取

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

會有這樣一種情況:快取剛好失效,請求B從資料庫中查詢資料,得到舊值。此時請求A更新資料庫,將新值寫入資料庫,並刪除快取。而請求B又將舊值寫入快取中,導致髒資料

從上面看出現髒資料的要求要比更新快取的要求更多,必須滿足以下幾個條件:

  1. 快取失效
  2. 讀請求 + 寫請求並行
  3. 更新資料庫 + 刪除快取的時間要比讀資料庫 + 寫快取時間

前面兩個很好滿足,我們再看看第三點,這個真的會出現嗎?

資料庫在更新時一般是加鎖的,讀操作的速度遠快於寫操作的,所以第三點發生概率極低(當然也可能發生)

注:這裡我其實不是很理解,單純看確實發生概率低,但如果出現網路延遲等情況呢,不也會發生嗎?希望好心人解惑,我反正沒理解。

因此,在選擇刪除快取時,還需要結合其他技術來優化效能和一致性。例如:

  • 使用訊息佇列來非同步地刪除或更新快取,避免阻塞主執行緒或者丟失訊息。
  • 使用延時雙刪來增加刪除成功率和減少不一致時間視窗。即在更新資料庫後立即刪除一次快取,並在一定時間間隔後再次刪除一次。

 對比

在更新快取中, 每次去更新快取,但是快取中的資料不一定會被馬上讀取,這就會導致快取中可能存放了很多不常存取的資料,浪費快取資源。而且很多情況下,寫到快取中的值,並不是與資料庫中的值一一對應的,很有可能是先查詢資料庫,再經過一系列「計算」得出一個值,才把這個值才寫到快取中。

​ 由此可見,這種更新快取的方案,不僅快取利用率不高,還會造成機器效能的浪費。所以我們一般考慮刪除快取

先更新快取再更新資料庫

在更新資料時,先將新資料寫入快取(Redis),再將新資料寫入資料庫(MySQL)

 但其存在一下問題:

  • 快取更新成功,但資料庫更新失敗,導致資料不一致

 :使用者修改了自己的暱稱,系統先將新的暱稱寫入快取,然後再更新資料庫。但是在更新資料庫的過程中,發生了網路故障或者資料庫宕機等異常情況,導致資料庫中的暱稱沒有被修改。這樣就會出現快取中的暱稱和資料庫中的暱稱不一致的情況。

  • 快取更新成功,但資料庫更新延遲,導致其他請求讀取到舊的資料

 :使用者下單了一個商品,系統先將訂單狀態寫入快取,然後再更新資料庫。但是在更新資料庫的過程中,由於並行量大或者其他原因,導致資料庫的寫入速度慢於快取的寫入速度。這樣就會出現其他請求從快取中讀取到訂單狀態為已支付,而從資料庫中讀取到訂單狀態為未支付的情況。

  • 快取更新成功,但在資料庫更新之前有其他請求查詢了快取和資料庫,並將舊的資料寫回快取,覆蓋了新的資料

 例:使用者A修改了自己的頭像,並上傳到伺服器上。系統先將新的頭像地址寫入快取,並返回給使用者A顯示。然後再將新的頭像地址更新到資料庫中。但是在這個過程中,使用者B存取了使用者A的個人主頁,並從快取中讀取到了新的頭像地址。由於快取失效策略或者其他原因(比如重啟),導致快取被清空或者過期。這時候使用者B再次存取使用者A 的個人主頁,並從資料庫中讀取到了舊的頭像地址,並將其寫回快取中。這樣就會出現快取中 的頭像地址和 資料庫 中 的頭像地址不一致 的情況。

上面說了一堆,其實總結就是快取更新成功了,資料庫沒更新(更新失敗),導致快取存的是最新值,資料庫存的是舊值。如果快取失效了,就會拿到資料庫中的舊值。

 後面我自己也搞疑惑了,既然是因為資料庫更新失敗導致的問題,那我是不是隻要保證資料庫更新成功就可以解決資料不一致的問題,當資料庫更新失敗時,不停的重試更新資料庫,直到資料庫更新完成。

後面發現自己太天真,其中存在很多問題,比如:

  • 如果資料庫更新失敗的原因是資料庫宕機或者網路故障,那麼你不停地重試更新資料庫可能會造成更大的壓力和延遲,甚至導致資料庫恢復困難。
  • 如果資料庫更新失敗的原因是資料衝突或者業務邏輯錯誤,那麼你不停地重試更新資料庫可能會導致資料丟失或者資料錯亂,甚至影響其他使用者的資料。
  • 如果你不停地重試更新資料庫,那麼你需要考慮如何保證重試的冪等性和順序性,以及如何處理重試過程中發生的異常情況。

 所以,這種方法並不是一個很好的解決方案。

先更新資料庫,再更新快取

當有一個更新操作時,先更新資料庫資料,然後再更新對應的快取資料

 但是,這種方案也有一些問題和風險,比如:

  • 如果更新資料庫成功了,但是更新快取失敗了,那麼就會導致快取中就會保留舊的資料,而資料庫中已經是新的資料,即髒資料。
  • 如果在更新資料庫和更新快取之間,有其他請求查詢了同一個資料,並且發現快取存在,那麼就會從快取中讀取舊的資料。這樣也會造成快取和資料庫之間的不一致性。

 因此,在使用更新快取操作時,無論誰先誰後,但凡後者發生異常,就會對業務造成影響。(還是上面那張圖)

那麼如何處理異常情況來保證資料一致性呢

這些問題的源頭都是多執行緒並行所導致的,所以最簡單的方法就是加鎖(分散式鎖)。兩個執行緒要修改同一條資料,每個執行緒在改之前,先去申請分散式鎖,拿到鎖的執行緒才允許更新資料庫和快取,拿不到鎖的執行緒,返回失敗,等待下次重試。這麼做的目的,就是為了只允許一個執行緒去運算元據和快取,避免並行問題。

​ 但加鎖費時費力,肯定不推薦。並且,每次去更新快取,但是快取中的資料不一定會被馬上讀取,這就會導致快取中可能存放了很多不常存取的資料,浪費快取資源。而且很多情況下,寫到快取中的值,並不是與資料庫中的值一一對應的,很有可能是先查詢資料庫,再經過一系列「計算」得出一個值,才把這個值才寫到快取中。

​ 由此可見,這種更新資料庫 + 更新快取的方案,不僅快取利用率不高,還會造成機器效能的浪費。

所以此時我們需要考慮另外一種方案:刪除快取

先刪除快取再更新資料庫

當有一個更新操作時,先刪除對應的快取資料,然後再更新資料庫資料

 但是,這種方案也有一些問題和風險,比如:

  • 如果刪除快取後,更新資料庫失敗了,那麼就會導致快取丟失,下次查詢時需要重新從資料庫載入資料,增加了資料庫壓力和響應時間。
  • 如果在刪除快取和更新資料庫之間,有其他請求查詢了同一個資料,並且發現快取不存在,那麼就會從資料庫中讀取舊的資料,並寫入到快取中。這樣就會造成快取和資料庫之間的不一致性。

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

當有一個更新操作時,先更新資料庫資料,再刪除快取

上面其實講過了,我再重複一遍吧

會有這樣一種情況:快取剛好失效,請求B從資料庫中查詢資料,得到舊值。此時請求A更新資料庫,將新值寫入資料庫,並刪除快取。而請求B又將舊值寫入快取中,導致髒資料

從上面看出現髒資料的要求要比更新快取的要求更多,必須滿足以下幾個條件:

  1. 快取失效
  2. 讀請求 + 寫請求並行
  3. 更新資料庫 + 刪除快取的時間要比讀資料庫 + 寫快取時間

前面兩個很好滿足,我們再看看第三點,這個真的會出現嗎?

資料庫在更新時一般是加鎖的,讀操作的速度遠快於寫操作的,所以第三點發生概率極低

所以,解決雙寫問題更適合的方法是先更新資料庫,再刪除快取,當然具體場景具體分析,不定說一定就是這個。

講解了這些操作後會出現的問題,那麼為了避免這些問題,如何做呢?

  • 先刪除快取再更新資料庫,然後使用非同步執行緒或訊息佇列來重建快取。
  • 先更新資料庫再刪除快取,並設定一個合理的過期時間來保證快取的有效性。
  • 使用分散式鎖或樂觀鎖來控制並行存取,並保證每次只有一個請求能夠操作快取和資料庫

 ……

下面講幾種常見的方法以保證雙寫一致性

解決方案

1. 重試

上面也提到過,當第二步操作失敗時,我就重試嘛,儘可能地補救,但重試的成本太大,上面講過就不重複了。

2. 非同步重試

​ 既然重試方法佔用資源,那我就做非同步。在刪除或更新快取時,如果操作失敗,不立即返回錯誤,而是通過一些機制(如訊息佇列、定時任務、訂閱binlog等)來觸發快取的重試操作。這樣可以避免同步重試快取時的效能損耗和阻塞問題,但也可能導致快取和資料庫的資料不一致的時間較長。

2.1 使用訊息佇列實現重試

  • 訊息佇列保證可靠性:寫到佇列中的訊息,成功消費之前不會丟失(重啟專案也不擔心)
  • 訊息佇列保證訊息成功投遞:下游從佇列拉取訊息,成功消費後才會刪除訊息,否則還會繼續投遞訊息給消費者(符合我們重試的需求)

使用訊息佇列非同步重試快取的情況是指,當資訊發生變化時,先更新資料庫,然後刪快取,如果刪除成功就皆大歡喜,如果刪除失敗,則將需要刪除的key傳送到訊息佇列。另外有一個消費者執行緒從訊息佇列中獲取要刪除的key,並根據key刪除或更新Redis中的快取。如果操作失敗,則重新傳送到訊息佇列中進行重試。

注:也可以不先嚐試刪除,直接傳送給訊息佇列,讓訊息佇列

舉例來說,假設有一個使用者資訊表,需要將使用者資訊快取在Redis中。如果採用使用訊息佇列非同步重試快取的方案,可以有以下幾個步驟:

  • 當用戶資訊發生變化時,先更新資料庫,並返回成功結果給前端。
  • 嘗試去刪除快取,成功則結束操作,失敗則將要刪除或更新快取的操作生成一個訊息(比如包含key和操作型別),並行送到訊息佇列中(比如使用Kafka或RabbitMQ)。
  • 另外有一個消費者執行緒從訊息佇列中訂閱並獲取這些訊息,並根據訊息內容刪除或更新Redis中的對應資訊。
  • 如果刪除或更新快取成功,則把這個訊息從訊息佇列中移除(丟棄),以免重複操作。
  • 如果刪除或更新快取失敗,則執行失敗策略,比如設定一個延遲時間或者一個重試次數限制,然後重新傳送這個訊息到訊息佇列中進行重試。
  • 如果重試超過一定次數仍然失敗,則向業務層傳送報錯資訊,並記錄紀錄檔。

2.2 Binlog實現非同步重試刪除

使用binlog實現一致性的基本思路是利用binlog紀錄檔來記錄資料庫的變更操作,然後通過主從複製或者增量備份的方式來同步或者恢復資料。

舉例來說,如果我們有一個主資料庫和一個從資料庫,我們可以在主資料庫上開啟binlog紀錄檔,並設定從資料庫作為它的複製節點。這樣,當主資料庫上發生任何變更操作時,它會將對應的binlog紀錄檔傳送給從資料庫,從資料庫則會根據binlog紀錄檔來執行相同的操作,從而保證資料一致性。

另外,如果我們需要恢復某個時間點之前的資料,我們也可以利用binlog紀錄檔來實現。首先,我們需要找到對應時間點之前的最近一個全量備份檔案,並將其恢復到目標資料庫。然後,我們需要找到對應時間點之前的所有增量備份檔案(即binlog紀錄檔檔案),並按照順序將其應用到目標資料庫。這樣,我們就可以恢復出目標時間點之前的資料狀態了。

  • 使用 Binlog 實時更新/刪除 Redis 快取。利用 Canal,即將負責更新快取的服務偽裝成一個 MySQL 的從節點,從 MySQL 接收 Binlog,解析 Binlog 之後,得到實時的資料變更資訊,然後根據變更資訊去更新/刪除 Redis 快取;
  • MQ+Canal 策略,將 Canal Server 接收到的 Binlog 資料直接投遞到 MQ 進行解耦,使用 MQ 非同步消費 Binlog 紀錄檔,以此進行資料同步;

注:binlog紀錄檔是MySQL的二進位制紀錄檔,它記錄了對資料庫的變更操作,比如插入、更新、刪除等。 binlog紀錄檔有兩個主要作用,一個是主從複製,另一個是增量備份。

主從複製是指在一個主資料庫和一個或多個從資料庫之間實現資料的同步。主資料庫會將自己的binlog紀錄檔傳送給從資料庫,從資料庫則會根據binlog紀錄檔來執行相同的操作,從而保證資料一致性。這樣可以提高資料的可用性和可靠性,也可以實現負載均衡和故障轉移。

增量備份是指在全量備份的基礎上,定期備份資料庫的變更操作。全量備份是指將整個資料庫的資料完整地備份到一個檔案中。增量備份則是指將每次變更操作對應的binlog紀錄檔檔案備份到另一個檔案中。這樣可以減少備份所佔用的空間和時間,也可以實現靈活地恢復資料到任意時間點。

至此,我們可以得出結論,想要保證資料庫和快取一致性,推薦採用「先更新資料庫,再刪除快取」方案,並配合「訊息佇列」或「訂閱變更紀錄檔」的方式來做。

3. 延時雙刪

我們重點在將先更新資料庫,在刪除快取。那如果我要先刪除快取,再更新資料庫呢?

回顧之前講的先刪除快取,再更新資料庫,它會出現舊值覆蓋快取的問題,那好辦,我們直接把這個舊值給刪了不就完了嗎,延時雙刪就是這個原理,它的基本思路是:

  1. 先刪除快取
  2. 再更新資料庫
  3. 休眠一段時間(根據系統情況確定)
  4. 再次刪除快取

這樣做的目的是為了防止在更新資料庫後,有其他執行緒讀取到舊的快取資料,並將其寫回快取,導致資料不一致。

舉個例子:假設有一個使用者資訊表,其中有一個欄位是使用者積分。現在有兩個執行緒A和B同時對使用者積分進行操作:

  • 執行緒A要給使用者增加100積分
  • 執行緒B要給使用者減少50積分

如果使用延時雙刪策略,那麼執行緒A和B的執行過程可能如下:

  • 執行緒A先刪除快取中的使用者資訊
  • 執行緒A再從資料庫中讀取使用者資訊,發現使用者積分為1000
  • 執行緒A將使用者積分加上100,變為1100,並更新到資料庫中
  • 執行緒A休眠5秒(假設這個時間足夠讓資料庫同步)
  • 執行緒A再次刪除快取中的使用者資訊
  • 執行緒B先刪除快取中的使用者資訊
  • 執行緒B再從資料庫中讀取使用者資訊,發現使用者積分為1100(因為執行緒A已經更新了)
  • 執行緒B將使用者積分減去50,變為1050,並更新到資料庫中
  • 執行緒B休眠5秒(假設這個時間足夠讓資料庫同步)
  • 執行緒B再次刪除快取中的使用者資訊

這樣最終結果就是:資料庫中的使用者積分為1050,快取中沒有該使用者資訊。當下次有請求查詢該使用者資訊時,就會從資料庫中讀取並寫入到快取中。這樣就保證了資料一致性。

延時雙刪適用於高並行場景,特別是對資料的修改操作比較頻繁,而查詢操作比較少的情況。這樣可以減輕資料庫的壓力,提高效能,同時保證資料的最終一致性。延時雙刪也適用於資料庫有主從同步延遲的場景,因為它可以避免在更新資料庫後,從庫還沒有同步完成時,讀取到舊的快取資料,並將其寫回快取。

注: 這個休眠時間 = 讀業務邏輯資料的耗時 + 幾百毫秒。 為了確保讀請求結束,寫請求可以刪除讀請求可能帶來的快取髒資料。

總結

好了,總結一下這篇文章的重點。

Redis與MySQL的雙寫一致性問題是指在使用快取和資料庫同時儲存資料的場景下,如何保證兩者的資料一致性。這個問題主要涉及到以下幾個方面:

  • 快取更新策略:快取更新策略有三種,分別是先更新快取再更新資料庫,先更新資料庫再更新快取,先刪除快取再更新資料庫 和先更新資料庫再刪除快取。每種策略都有可能導致資料不一致的情況。
  • 資料庫主從同步延遲:如果使用了主從複製模式來提高資料庫的可用性和讀寫分離能力,那麼就可能存在主從同步延遲的問題。也就是說,在主庫上執行了寫操作後,並不會立即同步到從庫上。這樣,在讀取資料時,如果從主庫讀取,則可能獲取到最新的資料;而如果從從庫讀取,則可能獲取到舊的資料。這樣也會導致與快取中的資料不一致。

為了解決這些問題 , 可以採用以下幾種方法 :

  • 採用先刪除快取,再更新資料庫方案,在並行場景下依舊有不一致問題,解決方案是延遲雙刪,但這個延遲時間很難評估。
  • 採用先更新資料庫,再刪除快取方案,為了保證兩步都成功執行,需配合訊息佇列或訂閱變更紀錄檔的方案來做,本質是通過重試的方式保證資料最終一致性。
  • 採用先更新資料庫,再刪除快取方案,讀寫分離 + 主從庫延遲也會導致快取和資料庫不一致,緩解此問題的方案是延遲雙刪,憑藉經驗傳送延遲訊息到佇列中,延遲刪除快取,同時也要控制主從庫延遲,儘可能降低不一致發生的概率。

總之,根據場景選擇適合自己的方案

 以上就是Redis與MySQL的雙寫一致性問題的詳細內容,更多關於Redis與MySQL的一致性的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com