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