億級流量,如何保證Redis與MySQL的一致性?失敗如何設(shè)計補償?
說在前面
只要使用到緩存,無論是本地緩存還是使用Redis做緩存,那么就會存在數(shù)據(jù)同步不一致的問題。
1、 先讀取緩存,緩存數(shù)據(jù)有,則立即返回結(jié)果;
2、 如果緩存中沒有數(shù)據(jù),則從數(shù)據(jù)庫中讀取數(shù)據(jù);
3、 把讀取到的數(shù)據(jù)同步到緩存中,提供下次讀請求返回數(shù)據(jù);
這樣的作法是大多數(shù)人使用緩存的方式,這樣能有效減輕數(shù)據(jù)庫壓力,但是如果修改刪除數(shù)據(jù),因為緩存無法感知到數(shù)據(jù)在數(shù)據(jù)庫中的修改。
這樣就會造成數(shù)據(jù)庫中的數(shù)據(jù)與緩存中數(shù)據(jù)不一致。
那么該如何解決呢?
有下面4種解決方案:
1、 先更新緩存,再更新數(shù)據(jù)庫;
2、 先更新數(shù)據(jù)庫,再更新緩存;
3、 先刪除緩存,后更新數(shù)據(jù)庫;
4、 先更新數(shù)據(jù)庫,后刪除緩存;
下面我們一一來看下每個方案的可行性:
一、先更新緩存,再更新數(shù)據(jù)庫
這個方案我們一般不考慮。原因是更新緩存成功,但是更新數(shù)據(jù)庫出現(xiàn)異常了。
會導致緩存數(shù)據(jù)與數(shù)據(jù)庫數(shù)據(jù)完全不一致,而且很難察覺,因為緩存中的數(shù)據(jù)一直都存在。
二、先更新DB,再更新緩存
這個方案我們一般也是不考慮,原因跟方案1一樣,數(shù)據(jù)庫更新成功了,緩存更新失敗,同樣會出現(xiàn)數(shù)據(jù)不一致問題,且不容易被發(fā)現(xiàn),因為緩存中一直存在數(shù)據(jù)。
三、先刪除緩存,后更新DB
這個方案再并發(fā)場景下也會出問題,具體出現(xiàn)的原因如下:
兩個并發(fā)請求:請求A(更新操作)和請求B(讀取操作)
1、 請求A會先刪除Redis中的數(shù)據(jù),然后去更新數(shù)據(jù)庫;
2、 此時請求B看到Redis中的數(shù)據(jù)是空的,回去數(shù)據(jù)庫中查詢該值,補充到Redis緩存中;
3、 此時請求A并沒有更新成功,或者是事務(wù)還未提交(MySQL的事務(wù)隔離級別,會導致未提交的事務(wù)數(shù)據(jù)不會被另一個線程看到),請求B去數(shù)據(jù)庫查詢得到舊值.;
這時候就會產(chǎn)生數(shù)據(jù)庫和Redis數(shù)據(jù)不一致的問題。
因此一般也不建議這種方式
雖然不建議,但是如果你是采用了這種方式,該如何解決數(shù)據(jù)不一致的問題呢?
其實最簡單的辦法就是延時雙刪的策略:
1、 先淘汰緩存;
2、 再寫數(shù)據(jù)庫;
3、 休眠1s,再次淘汰緩存;
這樣做,可以將1s內(nèi)所造成的緩存臟數(shù)據(jù),再次刪除。
但是,但是,這個1s怎么確定的,具體該休眠多久呢?
1、 自行評估自己的項目的讀數(shù)據(jù)業(yè)務(wù)邏輯的耗時(這個我們可以利用SkyWalking等監(jiān)控工具評估耗時);
2、 評估寫數(shù)據(jù)的休眠時間(在讀數(shù)據(jù)業(yè)務(wù)耗時的基礎(chǔ)上,加幾百ms即可);
這樣做的目的,就是確保讀請求結(jié)束,寫請求可以刪除讀請求造成的緩存臟數(shù)據(jù)。
延時雙刪就能徹底解決不一致嗎?如果面試官這樣問你,你千萬不能回答是的。
第一,我們評估的延時時間(讀請求耗時+幾百毫秒),并不能完全代表實際運行過程中的耗時,運行過程如果因為系統(tǒng)壓力過大,我們評估的耗時就是不準確,仍然會導致數(shù)據(jù)不一致的出現(xiàn)
第二,延時雙刪雖然在保證事務(wù)提交完以后再進行刪除緩存,但是如果你使用的是MySQL的讀寫分離的機構(gòu),主從同步之間其實也會有時間差。
此時該如何解決呢?
解決辦法有兩個:
1、 還是使用延時雙刪策略,只是睡眠時間改為在主從同步的延時時間基礎(chǔ)上,加幾百毫秒(讀接口耗時+主從延遲時間+幾百毫秒);
2、 對Redis進行填充數(shù)據(jù)查詢(更新緩存時查詢數(shù)據(jù)庫),強制走主庫查詢,那么我們延時雙刪就沒必要增加主從延時時間了(增加個主從延時時間也會增加更大的不確定性,因為主從延時時間也是不穩(wěn)定的);
如果面試官繼續(xù)深入的問你,采用這種同步延時雙刪的淘汰策略,接口的吞吐量降低怎么辦?(數(shù)據(jù)變更時,更新接口都要多休眠一個延時時間)
既然同步會降低吞吐量,那就同步改異步(性能優(yōu)化的常用手段)。
將第二次刪除的操作,異步起一個線程,異步刪除,這樣寫的請求就不用沉睡一段時間后才能返回了。
總的來說,先刪除緩存,再更新數(shù)據(jù)庫的方式,還是瑕疵較多,發(fā)生數(shù)據(jù)一致性的問題和性能問題的概率更大。比如:
1、 先刪除緩存可能導致讀請求因緩存缺失而大量訪問數(shù)據(jù)庫(尤其是高并發(fā)場景的電商,可能一瞬間就把數(shù)據(jù)庫打掛了);
2、 讀請求接口的耗時和寫緩存的時間,估算不夠準確,會導致延遲雙刪中的sleep時間不好設(shè)置;
下面我們來看最后一種解決方案,這個解決方式是4個方案中發(fā)生數(shù)據(jù)不一致性的概率最低的。
四、先更新DB,后刪除緩存
讀的時候,先讀緩存,緩存沒有的話,就讀數(shù)據(jù)庫,然后取出數(shù)據(jù)后放入緩
存,同時返回響應。更新的時候,先更新數(shù)據(jù)庫,然后再刪除緩存。
這種方案下就不存在數(shù)據(jù)不一致性的問題了么?
其實是依然存在的,尤其是在大型互聯(lián)網(wǎng)電商,高并發(fā)系統(tǒng)中,并發(fā)問題導致的數(shù)據(jù)一致性的數(shù)據(jù)量非常大。
假設(shè)兩個請求,請求A和請求B,請求A做查詢操作(讀請求),請求B做更新操作(寫請求)
當高并發(fā)場景下,會有如下情形出現(xiàn):
1、 緩存剛好失效;
2、 請求A查詢數(shù)據(jù)庫,得到一個舊值;
3、 請求B將新值寫入數(shù)據(jù)庫;
4、 請求B刪除緩存;
5、 請求A將查到的舊值寫入緩存;
高并發(fā)場景下,確實有可能會發(fā)生上述的情況,產(chǎn)生臟數(shù)據(jù)。
然而,發(fā)生這種的概率又有多少呢?
?
發(fā)生上述情況的一個先天性條件,就是步驟(3)的寫數(shù)據(jù)庫操作比步驟(2)的讀數(shù)據(jù)庫操作耗時更短,才有可能使得步驟(4)先于步驟(5)。
可是,大家想想,數(shù)據(jù)庫的讀操作的速度遠快于寫操作的(不然做讀寫分離干嘛,做讀寫分離的意義就是因為讀操作比較快,耗資源少)。
因此步驟(3)耗時比步驟(2) 更短,這一情形很難出現(xiàn)。
但是,如果面試官問你:如果我的業(yè)務(wù)屬性要求一定要解決怎么辦?那么如何解決上述并發(fā)問題?
首先,給緩存設(shè)置過期時間是一種有效的方案。
如果你的業(yè)務(wù)數(shù)據(jù)對實時性要求不是很高,可以接受數(shù)據(jù)的短時間數(shù)據(jù)不一致的場景,我們此種方案就可以解決了(比如商品詳情中的描述、屬性等)
其次,仍可以采用異步延時刪除的策略。
參考方案3中的異步延時刪除策略方案,刪除的方案其實還有問題,這個我們放在后面說
一般采用這些手段幾乎就已經(jīng)把Redis緩存和數(shù)據(jù)庫數(shù)據(jù)不一致的概率降到了極低。
如果非要強一致性,極低的數(shù)據(jù)不一致的概率都不能接受,那么該如何解決呢?
其實也有解決方案:那就是加鎖,在讀請求加一個讀鎖,所有的讀請求不阻塞,在寫請求加一個寫鎖,一旦有寫請求,則暫時阻塞讀,等寫請求處理完,刪除完緩存再放開讀。
如果你的業(yè)務(wù)并發(fā)要求不高,讀多寫少,且對數(shù)據(jù)一致性有很高的要求,可以采用這種方案,但是保證強一致性的同時,就會損失一些性能,所以該不該用這種方案,大家可以根據(jù)自己業(yè)務(wù)的屬性做好權(quán)衡。
方案補充(重要)
3、 4都屬于刪除緩存類,其實刪除緩存類都會有一個共同的問題,那就是在刪除緩存的階段出錯了怎么辦?此時再讀取緩存的時候每次都是錯誤的數(shù)據(jù)了;
此時解決方案有兩個:
一、利用消息隊列進行刪除失敗的補償
具體的業(yè)務(wù)邏輯如下:
1、 請求A先對數(shù)據(jù)庫進行更新操作;
2、 在對Redis進行刪除操作的時候發(fā)現(xiàn)報錯,刪除失敗;
3、 此時將Redis的key作為消息體發(fā)送到消息隊列中;
4、 系統(tǒng)接收到消息隊列發(fā)送的消息后;
5、 再次對Redis進行刪除操作;
但是這個方案會有一個缺點,就是會對業(yè)務(wù)代碼造成大量的侵入,深深的耦合在一起。
所以還有一個優(yōu)化的方案
二、訂閱MySQL的binlog日志,異步刪除
我們知道對 Mysql 數(shù)據(jù)庫更新操作后 ,在 binlog日志中我們都能夠找到相應的操作,那么我們可以訂閱 Mysql數(shù)據(jù)庫 的 binlog日志對緩存進行操作,這樣就達到了一個解耦的目的了。
業(yè)務(wù)代碼流程如下:
1、 更新數(shù)據(jù)庫,更新完成后,觸發(fā)binlog消息;
2、 經(jīng)常B(消費者)訂閱binlog消息,執(zhí)行緩存刪除操作;
3、 緩存刪除失敗,將刪除任務(wù)丟到消息隊列中;
4、 進程B獲取刪除失敗任務(wù);
5、 執(zhí)行二次刪除redis緩存;
說到底就是通過數(shù)據(jù)庫的 binlog 來異步淘汰 key,利用工具(canal)將 binlog
日志采集發(fā)送到 MQ 中,然后通過 ACK 機制確認處理刪除緩存。
先更新DB,后刪除緩存,這種方式,被稱為 Cache Aside Pattern,屬于緩存更新的經(jīng)典設(shè)計模式之一。
所以如果大家做緩存與數(shù)據(jù)庫的同步,推薦大家選擇這一種方式。
總結(jié)
至此,億級電商流量,高并發(fā)下Redis與MySQL的數(shù)據(jù)一致性如何保證的方案,非常圓滿了。以上的內(nèi)容,如果大家能爛熟于心、對答如流、如數(shù)家珍,基本上 面試官會被你 震驚到、吸引到。