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