Redis與MySQL雙寫一致性是指在使用快取和資料庫同時儲存資料的場景下( 主要是存在高並發的情況) ,如何保證兩者的資料一致性(內容相同或盡可能接近)。
正常業務流程:
#閱讀並不成問題,問題所在在於寫入作業(更新)。這時就可能出現幾個問題,我們需要先更新資料庫,然後再進行快取操作。在處理快取時,應該考慮要進行快取更新還是刪除緩存,或是先更新快取再更新資料庫
總結一下就是到底先操作快取再操作資料庫,還是先操作資料庫再操作快取?
帶著這幾個問題接著往下講。
先講一下操作緩存,包括兩種:更新緩存和刪除緩存,如何選擇?
假設都先更新資料庫(因為先操作快取再操作資料庫問題較大,後面會講)
更新緩存
先更新資料庫,再更新快取。
當兩個請求同時對同一筆資料進行修改時,快取中可能會存在舊數據,因為它們的先後順序可能會顛倒。之後的讀取請求讀到的都是舊數據,只有當快取失效後,才能從資料庫中得到正確的值。
刪除快取
#先更新資料庫,再刪除快取。
快取失效時,可能出現請求B從資料庫查詢資料並得到舊值的情況。此時請求A更新資料庫,將新值寫入資料庫,並刪除快取。而請求B又將舊值寫入快取中,導致髒資料
從上面看出現髒資料的要求要比更新快取的要求更多,必須滿足以下幾個條件:
快取失效
讀取請求寫請求並發
更新資料庫刪除快取的時間要比讀取資料庫寫入快取時間短
前面兩個很好滿足,我們再看第三點,這個真的會出現嗎?
資料庫在更新時一般是加鎖的,讀取操作的速度遠快於寫操作的,所以第三點發生機率極低(當然也可能發生)
#註:這裡我其實不是很理解,單純看確實發生機率低,但如果出現網路延遲等情況呢,不也會發生嗎?希望好心人解惑,我反正沒理解。
因此,在選擇刪除快取時,還需要結合其他技術來優化效能和一致性。例如:
使用訊息佇列來非同步刪除或更新緩存,避免阻塞主執行緒或遺失訊息。
使用延時雙刪來增加刪除成功率和減少不一致時間視窗。即在資料庫更新後立即清除一次緩存,然後在一定的時間間隔後再次進行清除。
比較
在更新快取中, 每次去更新緩存,但快取中的資料不一定會被馬上讀取,這會導致快取中可能存放了很多不常存取的數據,浪費快取資源。而且很多情況下,寫到快取中的值,並不是與資料庫中的值一一對應的,很有可能是先查詢資料庫,再經過一系列「計算」得出一個值,才把這個值才寫到快取中。
由此可見,這種更新快取的方案,不僅快取利用率不高,還會造成機器效能的浪費。所以我們一般會考慮刪除快取
#在更新資料時,先將新資料寫入快取(Redis ),再將新資料寫入資料庫(MySQL)
但其存在問題:
快取更新成功,但資料庫更新失敗,導致資料不一致
範例:使用者修改了自己的暱稱,系統先將新的暱稱寫入緩存,然後再更新資料庫.但是在更新資料庫的過程中,發生了網路故障或資料庫宕機等異常情況,導致資料庫中的暱稱沒有被修改。這樣就會出現快取中的暱稱和資料庫中的暱稱不一致的情況。
快取更新成功,但資料庫更新延遲,導致其他請求讀取到舊的資料
範例:使用者下單了一個商品,系統先將訂單狀態寫入緩存,然後再更新資料庫。但是在更新資料庫的過程中,由於並發量大或其他原因,導致資料庫的寫入速度慢於快取的寫入速度。這樣就會出現其他請求從快取中讀取到訂單狀態為已支付,而從資料庫讀取到訂單狀態為未支付的情況。
快取更新成功,但在資料庫更新之前有其他請求查詢了快取和資料庫,並將舊的資料寫回緩存,覆蓋了新的資料
範例:使用者A修改了自己的頭像,並上傳到伺服器。系統先將新的頭像位址寫入緩存,並回傳給用戶A顯示。然後再將新的頭像位址更新到資料庫中。但是在這個過程中,用戶B訪問了用戶A的個人主頁,並從快取中讀取到了新的頭像位址。快取失效可能是由於快取過期策略或其他原因,例如重新啟動操作,導致快取被清空或過期。這時候用戶B再次存取用戶A 的個人主頁,並從資料庫中讀取到了舊的頭像位址,並將其寫回快取中。這樣可能導致快取中的頭像位址與資料庫中的位址不符。
上面說了一堆,其實總結就是快取更新成功了,資料庫沒更新(更新失敗),導致快取存的是最新值,資料庫存的是舊值。如果快取失效了,就會拿到資料庫中的舊值。
後面我自己也搞疑惑了,既然是因為資料庫更新失敗導致的問題,那我是不是只要保證資料庫更新成功就可以解決資料不一致的問題,當資料庫更新失敗時,不停的重試更新資料庫,直到資料庫更新完成。
後面發現自己太天真,其中存在很多問題,例如:
如果資料庫更新失敗的原因是資料庫宕機或網路故障,那麼你不停地重試更新資料庫可能會造成更大的壓力和延遲,甚至導致資料庫復原困難。
如果資料庫更新失敗的原因是資料衝突或是業務邏輯錯誤,那麼你不停地重試更新資料庫可能會導致資料遺失或資料錯亂,甚至影響其他使用者的數據。
如果你不停地重試更新資料庫,那麼你需要考慮如何保證重試的冪等性和順序性,以及如何處理重試過程中發生的異常情況。
所以,這個方法並不是一個很好的解決方案。
當有一個更新操作時,先更新資料庫數據,然後再更新對應的快取資料
#但是,這種方案也有一些問題和風險,例如:
如果更新資料庫成功了,但是更新快取失敗了,那麼就會導致快取中就會保留舊的數據,而資料庫中已經是新的數據,即髒數據。
如果在更新資料庫和更新快取之間,有其他請求查詢了同一個數據,並且發現快取存在,那麼就會從快取中讀取舊的資料。這樣也會造成快取和資料庫之間的不一致性。
因此,在使用更新快取作業時,無論誰先誰後,但凡後者發生異常,就會對業務造成影響。 (還是上面那張圖)
那麼如何處理異常情況來保證資料一致性呢
這些問題的源頭都是多執行緒並發所導致的,所以最簡單的方法就是加鎖(分散式鎖)。兩個線程要修改同一條數據,每個線程在改之前,先去申請分散式鎖,拿到鎖的線程才允許更新資料庫和緩存,拿不到鎖的線程,返回失敗,等待下次重試。這麼做的原因是限制只有一個執行緒可以操作資料和緩存,以預防並發問題。
但加鎖費時費力,絕對不推薦。並且,每次去更新緩存,但是緩存中的數據不一定會被馬上讀取,這就會導致緩存中可能存放了很多不常訪問的數據,浪費緩存資源。而且很多情況下,寫到快取中的值,並不是與資料庫中的值一一對應的,很有可能是先查詢資料庫,再經過一系列「計算」得出一個值,才把這個值才寫到快取中。
由此可見,這種更新資料庫 更新快取的方案,不僅快取使用率不高,還會造成機器效能的浪費。
所以此時我們需要考慮另一個方案:刪除快取
##當有一個更新操作時,先刪除對應的快取數據,然後再更新資料庫資料但是,這個方案也有一些問題和風險,例如:
當有更新操作時,先更新資料庫資料,再刪除快取上面其實講過了,我再重複一次吧快取失效時,可能出現請求B從資料庫中查詢資料並得到舊值的情況。此時請求A更新資料庫,將新值寫入資料庫,並刪除快取。而請求B又將舊值寫入快取中,導致髒資料 從上面看出現髒資料的要求要比更新快取的要求更多,必須滿足以下幾個條件:
快取失效
讀取請求寫請求並發
更新資料庫刪除快取的時間要比讀取資料庫寫入快取時間短
下面講幾個常見的方法以保證雙寫一致性解決方案1.重試上面也提到過,當第二步操作失敗時,我就重試嘛,盡可能地補救,但重試的成本太大,上面講過就不重複了。 2. 非同步重試既然重試方法佔用資源,那我就做非同步。在刪除或更新快取時,如果操作失敗,不立即傳回錯誤,而是透過一些機制(如訊息佇列、定時任務、訂閱binlog等)來觸發快取的重試操作。雖然這種方法可以避免同步重試快取時的效能損耗和阻塞問題,但是會延長快取和資料庫資料不一致的時間。 2.1 使用訊息佇列實作重試
訊息佇列保證可靠性:寫到佇列中的訊息,成功消費之前不會遺失(重啟專案也不擔心)
訊息佇列保證訊息成功投遞:下游從佇列拉取訊息,成功消費後才會刪除訊息,否則還會繼續投遞訊息給消費者(符合我們重試的需求)
使用訊息佇列非同步重試快取的情況是指,當資訊改變時,先更新資料庫,然後刪緩存,如果刪除成功就皆大歡喜,如果刪除失敗,則需要刪除的key發送到訊息佇列。另外,一個消費者執行緒會從訊息佇列中取出要刪除的鍵,並依據該鍵刪除或更新Redis快取。如果操作失敗,則重新傳送至訊息佇列中進行重試。
註:也可以不先嘗試刪除,直接傳送給訊息佇列,讓訊息佇列舉個例子,如果有一個使用者資訊表,想要將使用者資訊儲存在Redis裡面。以下是可以執行的步驟,以採用訊息佇列非同步重試快取的方案為例:
當使用者資訊改變時,先更新資料庫,並返回成功結果給前端。
嘗試去刪除緩存,成功則結束操作,失敗則將要刪除或更新快取的操作產生一個訊息(例如包含key和操作類型),並發送到訊息佇列(例如使用Kafka或RabbitMQ)。
另外有一個消費者執行緒從訊息佇列中訂閱並取得這些訊息,並根據訊息內容刪除或更新Redis中的對應資訊。
如果刪除或更新快取成功,則把這個訊息從訊息佇列中移除(丟棄),以免重複操作。
如果刪除或更新快取失敗,則執行失敗策略,例如設定延遲時間或一個重試次數限制,然後重新傳送這個訊息到訊息佇列中進行重試。
如果重試超過一定次數仍然失敗,則向業務層發送報錯訊息,並記錄日誌。
使用binlog實作一致性的基本想法是利用binlog日誌記錄資料庫的變更操作,然後透過主從複製或增量備份的方式來同步或還原資料。
舉例來說,如果我們有一個主資料庫和一個從資料庫,我們可以在主資料庫上開啟binlog日誌,並設定從資料庫作為它的複製節點。這樣,當主資料庫上發生任何變更操作時,它會將對應的binlog日誌傳送給從資料庫,從資料庫則會根據binlog日誌來執行相同的操作,從而保證資料一致性。
另外,如果我們需要還原某個時間點之前的數據,我們也可以利用binlog日誌來實現。首先,我們需要找到對應時間點之前的最近一個全量備份文件,並將其恢復到目標資料庫。然後,我們需要找到對應時間點之前的所有增量備份檔案(即binlog日誌檔案),並按照順序將其套用到目標資料庫。這樣,我們就可以恢復出目標時間點之前的資料狀態了。
使用 Binlog 即時更新/刪除 Redis 快取。利用Canal,即將負責更新快取的服務偽裝成一個MySQL 的從節點,從MySQL 接收Binlog,解析Binlog 之後,得到即時的資料變更訊息,然後根據變更資訊去更新/刪除Redis 快取;
MQ Canal 策略,將Canal Server 接收的Binlog 資料直接投遞到MQ 進行解耦,使用MQ 非同步消費Binlog 日誌,以此進行資料同步;
#MySQL的binlog日誌記錄了資料庫的變更操作,例如插入、更新、刪除等。 binlog日誌有兩個主要作用,一個是主從複製,另一個是增量備份。
主從複製是透過將一個主資料庫的資料同步到一個或多個從資料庫來實現資料同步的過程。主資料庫會將自己的binlog日誌傳送給從資料庫,從資料庫則會根據binlog日誌來執行相同的操作,從而確保資料一致性。透過實施這種方法,可以增強資料的可用性和可靠性,並且能夠實現負載平衡和故障復原。
增量備份是指在全量備份的基礎上,定期備份資料庫的變更操作。全量備份是指將整個資料庫的資料完整地備份到一個檔案中。目的是將資料庫中的最新變更與先前的備份合併,以便恢復到最新狀態。這樣做不僅可以節省備份所佔用的空間和時間,同時也可以方便地將資料還原至任意時間點。
至此,我們可以得出結論,想要保證資料庫和快取一致性,推薦採用「先更新資料庫,再刪除快取」方案,並配合「訊息佇列」或「訂閱變更日誌」的方式來做。
我們專注於將先更新資料庫,在刪除快取。那如果我要先刪除緩存,再更新資料庫呢?
回顧之前講的先刪除緩存,再更新資料庫,它會出現舊值覆蓋緩存的問題,那好辦,我們直接把這個舊值給刪了不就完了嗎,延時雙刪就是這個原理,它的基本想法是:
先刪除快取
再更新資料庫
休眠一段時間(根據系統情況確定)
再次刪除快取
為了避免更新資料庫後,其它執行緒讀取到已過期的快取資料並將其寫回快取導致資料不一致,採取了這種措施。
舉個例子:假設有一個使用者資訊表,其中有一個欄位是使用者積分。現在有兩個線程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的雙寫一致性問題怎麼解決的詳細內容。更多資訊請關注PHP中文網其他相關文章!