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