如何保證MySQL和Redis的數據一致性?
原創圖片來自 包圖網
【51CTO.com原創稿件】今天給大家剖析一下工作中常見的 MySQL 和 Redis 數據一致性問題。
什么是數據的一致性
一致性就是數據保持一致,在分布式系統中,可以理解為多個節點中數據的值是一致的。
而一致性又可以分為強一致性與弱一致性。強一致性可以理解為在任意時刻,所有節點中的數據是一樣的。
同一時間點,你在節點 A 中獲取到的值與在節點 B 中獲取到的值應該都是一樣的。
弱一致性包含很多種不同的實現,目前分布式系統中廣泛實現的是最終一致性。
所謂最終一致性,就是不保證在任意時刻任意節點上的同一份數據都是相同的,但是隨著時間的遷移,不同節點上的同一份數據總是在向趨同的方向變化。
也可以簡單的理解為在一段時間后,節點間的數據會最終達到一致狀態。
當下互聯網絕大部分公司都進行了數據庫拆分和服務化(SOA)微服務。在這種情況下,完成某一個業務功能可能需要橫跨多個服務,操作多個數據庫(包含關系型數據庫,非關系型數據庫)。
這就涉及到需要操作的資源位于多個資源服務器上,而應用需要保證對于多個資源服務器的數據的操作,要么全部成功,要么全部失敗,因此我們必須保證不同資源服務器的數據一致性。
那么數據一致性有哪些類型呢?我在這里給他做個具體的分類,讓大家實現數據一致性到底在什么場景下需要實現數據一致性。
①跨庫數據一致性
庫數據量比較大或者預期未來的數據量比較大,都會進行分庫分表存儲。那就意味著同一個表的數據可能存儲在不同庫中。此時也存儲分布式場景下數據一致性問題。
②微服務拆分
現在互聯網企業都使用微服務架構,服務被拆分成很多不同的相互獨立的系統,系統之間通過網絡進行通信,每一個服務都自己獨立的數據庫。
例如:某個應用同時操作了多個庫,這樣的應用業務邏輯必然非常復雜,對于開發人員是極大的挑戰,應該拆分成不同的獨立服務,以簡化業務邏輯。拆分后,獨立服務之間通過 RPC 框架來進行遠程調用,實現彼此的通信。
此時上圖所描述的架構中對應 2 個對應分布式事務處理點:
- 多個服務之間事務處理(一個服務調用多個服務)
- 多數據源事務處理(一個服務訪問多個數據源)
Service A 完成某個功能需要直接操作數據庫,同時需要調用 Service B 和 Service C,而 Service B 又同時操作了 2 個數據庫,Service C 也操作了一個庫。
需要保證這些跨服務的對多個數據庫的操作要不都成功,要不都失敗,實際上這可能是最典型的數據一致性場景。
③基于不同類型數據存儲
數據一致性另一個場景就是同時操作不同的種類的數據庫,但同時還需要滿足不同的數據庫的數據一致性問題。
緩存數據一致基本上是指:如果緩存中有數據,那么緩存的數據值等于數據庫中的值。
但是根據緩存中是有數據為依據,則”一致“可以包含以下的兩種情況:
- 緩存中有數據,那么緩存的數據值等同于數據庫中的值(需均為最新值,本文將“舊值的一致”歸類為“不一致狀態”)。
- 緩存中本沒有數據,那么數據庫中的值等同于最新值(有請求查詢數據庫時,會將數據寫入緩存,則變為上面的“一致”狀態)。
數據不一致:緩存的數據值不等同于數據庫中的值;緩存或者數據庫中存在舊值,導致其他線程讀到舊數據。
本文將會帶大家詳細了解一下緩存一致性如何實現,以及緩存一致性的原理是什么樣的。
數據不一致情況及應對策略
根據是否接收寫請求,可以把緩存分成讀寫緩存和只讀緩存:
- 只讀緩存:只在緩存進行數據查找,即可以使用 “更新數據庫+刪除緩存” 策略。
- 讀寫緩存:需要在緩存中對數據進行增刪改查,即可以使用 “更新數據庫+更新緩存”策略。
①針對只讀緩存
只讀緩存:新增數據時,直接寫入數據庫;更新(修改/刪除)數據時,先刪除緩存。
后續,訪問這些增刪改的數據時,會發生緩存缺失,進而查詢數據庫,更新緩存。
新增數據時,寫入數據庫;訪問數據時,緩存缺失,查數據庫,更新緩存(始終是處于”數據一致“的狀態,不會發生數據不一致性問題)。
更新(修改/刪除)數據時,會有個時序問題:更新數據庫與刪除緩存的順序(這個過程會發生數據不一致性問題)。
在更新數據的過程中,可能會有如下問題:
- 無并發請求下,其中一個操作失敗的情況。
- 并發請求下,其他線程可能會讀到舊值。
因此,要想達到數據一致性,需要保證兩點:
- 無并發請求下,保證 a 和 b 步驟都能成功執行。
- 并發請求下,在 a 和 b 步驟的間隔中,避免或消除其他線程的影響。
接下來,我們針對有/無并發場景,進行分析并使用不同的策略。
②無并發情況
無并發請求下,在更新數據庫和刪除緩存值的過程中,因為操作被拆分成兩步,那么就很有可能存在“步驟 1 成功,步驟 2 失敗” 的情況發生。
由于單線程中步驟 1 和步驟 2 是串行執行的,不太可能會發生 “步驟 2 成功,步驟 1 失敗” 的情況。
先刪除緩存,再更新數據庫:
先更新數據庫,再刪除緩存:
因此,如果先刪除緩存,后更新數據庫,那么刪除緩存成功,更新數據庫失敗,以致于請求無法命中緩存,讀取數據庫舊值,存在一致性問題。
如果先更新數據庫,后刪除緩存,那么更新數據庫成功,刪除緩存失敗,以致于請求命中緩存,讀取命中緩存舊值,也存在一致性問題
那么它的解決策略是什么呢?消息隊列+異步重試。
無論使用哪一種執行時序,可以在執行步驟 1 時,將步驟 2 的請求寫入消息隊列,當步驟 2 失敗時,就可以使用重試策略,對失敗操作進行 “補償”。
③高并發情況
使用以上策略后,可以保證在單線程/無并發場景下的數據一致性。但是,在高并發場景下,由于數據庫層面的讀寫并發,會引發的數據庫與緩存數據不一致的問題(本質是后發生的讀請求先返回了)。
(1) 先刪除緩存,再更新數據庫
假設線程 1 刪除緩存值后,由于網絡延遲等原因導致未及更新數據庫,而此時,線程 2 開始讀取數據時會發現緩存缺失,進而去查詢數據庫。
而當線程 2 從數據庫讀取完數據、更新了緩存后,線程 1 才開始更新數據庫,此時,會導致緩存中的數據是舊值,而數據庫中的是最新值,產生“數據不一致”。
其本質就是,本應后發生的“線程 2-讀請求” 先于 “線程 1-寫請求” 執行并返回了。
那么針對這種問題,我們的解決策略如下所示:
設置緩存過期時間 + 延時雙刪:通過設置緩存過期時間,若發生上述淘汰緩存失敗的情況,則在緩存過期后,讀請求仍然可以從 DB 中讀取最新數據并更新緩存,可減小數據不一致的影響范圍。雖然在一定時間范圍內數據有差異,但可以保證數據的最終一致性。
此外,還可以通過延時雙刪進行保障:在線程 1 更新完數據庫值以后,讓它先 sleep 一小段時間,確保線程 2 能夠先從數據庫讀取數據,再把缺失的數據寫入緩存,然后,線程 1 再進行刪除。
后續,其它線程讀取數據時,發現緩存缺失,會從數據庫中讀取最新值。
- redis.delKey(X)
- db.update(X)
- Thread.sleep(N)
- redis.delKey(X)
sleep 時間:在業務程序運行的時候,統計下線程讀數據和寫緩存的操作時間,以此為基礎來進行估算。
(2) 先更新數據庫,再刪除緩存
如果線程 1 更新了數據庫中的值,但還沒來得及刪除緩存值,線程 2 就開始讀取數據了,那么此時,線程 2 查詢緩存時,發現緩存命中,就會直接從緩存中讀取舊值。
其本質也是,本應后發生的“2 線程-讀請求” 先于 “1 線程-刪除緩存” 執行并返回了。
或者,在”先更新數據庫,再刪除緩存”方案下,“讀寫分離+主從庫延遲”也會導致不一致。
以上問題的解決方案如下所示:
延遲消息:憑借經驗發送「延遲消息」到隊列中,延遲刪除緩存,同時也要控制主從庫延遲,盡可能降低不一致發生的概率。
訂閱 binlog,異步刪除:通過數據庫的 binlog 來異步淘汰 key,利用工具(canal)將 binlog 日志采集發送到 MQ 中,然后通過 ACK 機制確認處理刪除緩存。
刪除消息寫入數據庫:通過比對數據庫中的數據,進行刪除確認 先更新數據庫再刪除緩存,有可能導致請求因緩存缺失而訪問數據庫,給數據庫帶來壓力,也就是緩存穿透的問題。針對緩存穿透問題,可以用緩存空結果、布隆過濾器進行解決。
加鎖:更新數據時,加寫鎖;查詢數據時,加讀鎖 保證兩步操作的“原子性”,使得操作可以串行執行。“原子性”的本質是什么?不可分割只是外在表現,其本質是多個資源間有一致性的要求,操作的中間狀態對外不可見。
建議,優先使用“先更新數據庫再刪除緩存”的執行時序,原因主要有兩個:
- 先刪除緩存值再更新數據庫,有可能導致請求因緩存缺失而訪問數據庫,給數據庫帶來壓力。
- 業務應用中讀取數據庫和寫緩存的時間有時不好估算,進而導致延遲雙刪中的 sleep 時間不好設置。
④針對讀寫緩存
讀寫緩存:增刪改在緩存中進行,并采取相應的回寫策略,同步數據到數據庫中
同步直寫:使用事務,保證緩存和數據更新的原子性,并進行失敗重試(如果 Redis 本身出現故障,會降低服務的性能和可用性)。
異步回寫:寫緩存時不同步寫數據庫,等到數據從緩存中淘汰時,再寫回數據庫(沒寫回數據庫前,緩存發生故障,會造成數據丟失) 該策略在秒殺場中有見到過,業務層直接對緩存中的秒殺商品庫存信息進行操作,一段時間后再回寫數據庫。
一致性:同步直寫>異步回寫,因此,對于讀寫緩存,要保持數據強一致性的主要思路是:利用同步直寫,同步直寫也存在兩個操作的時序問題:更新數據庫和更新緩存。
無并發情況:
高并發情況,有四種場景會造成數據不一致:
針對場景 1 和 2 的解決方案是:保存請求對緩存的讀取記錄,延時消息比較,發現不一致后,做業務補償。
針對場景 3 和 4 的解決方案是:對于寫請求,需要配合分布式鎖使用。
寫請求進來時,針對同一個資源的修改操作,先加分布式鎖,保證同一時間只有一個線程去更新數據庫和緩存;沒有拿到鎖的線程把操作放入到隊列中,延時處理。用這種方式保證多個線程操作同一資源的順序性,以此保證一致性。
其中,分布式鎖的實現可以使用以下策略:
- 樂觀鎖:使用版本號、updatetime;緩存中只容許高版本覆蓋低版本。
- Watch 實現 Redis 樂觀鎖:Watch 監控 Rediskey 的狀態值,創建 Redis 事務,key+1,執行事務,key 被修改過則回滾。
- Setnx:獲取鎖:set/setnx;釋放鎖:del/lua。
Redisson 分布式鎖:利用 Redis 的 hash 結構作為儲存單元,將業務指定的名稱作為 key,將隨機 UUID 和線程 ID 作為 fleld,最后將加鎖的次數作為 value 來儲存,線程安全。
⑤強一致性策略
上述策略只能保證數據的最終一致性。要想做到強一致,最常見的方案是 2PC、3PC、Paxos、Raft 這類一致性協議,但它們的性能往往比較差,而且這些方案也比較復雜,還要考慮各種容錯問題。
如果業務層要求必須讀取數據的強一致性,可以采取以下策略:
暫存并發讀請求:在更新數據庫時,先在 Redis 緩存客戶端暫存并發讀請求,等數據庫更新完、緩存值刪除后,再讀取數據,從而保證數據一致性。
串行化:讀寫請求入隊列,工作線程從隊列中取任務來依次執行,修改服務 Service 連接池,id 取模選取服務連接,能夠保證同一個數據的讀寫都落在同一個后端服務上。
修改數據庫 DB 連接池,id 取模選取 DB 連接,能夠保證同一個數據的讀寫在數據庫層面是串行的。
使用 Redis 分布式讀寫鎖:將淘汰緩存與更新庫表放入同一把寫鎖中,與其他讀請求互斥,防止其間產生舊數據。
讀寫互斥、寫寫互斥、讀讀共享,可滿足讀多寫少的場景數據一致,也保證了并發性。并根據邏輯平均運行時間、響應超時時間來確定過期時間。
作者:JackHu
簡介:水滴健康基礎架構資深技術專家
編輯:陶家龍
征稿:有投稿、尋求報道意向技術人請聯絡 editor@51cto.com
【51CTO原創稿件,合作站點轉載請注明原文作者和出處為51CTO.com】