今天來說一個老生常談的問題,來看一個實際案例:業務中往往都會通過緩存來提高查詢效率,降低資料庫的壓力,尤其是在分散式高併發場景下,大量的請求直接訪問Mysql很容易造成性能問題。 ...
今天來說一個老生常談的問題,來看一個實際案例:
現有業務中往往都會通過緩存來提高查詢效率,降低資料庫的壓力,尤其是在分散式高併發場景下,大量的請求直接訪問Mysql很容易造成性能問題。
有一天老闆找到了你......
老闆:聽說你會緩存?
你:來看我操作。
你設計了一個最常見的緩存方案,基於這種方案,開始對用戶積分功能進行優化,但當你睡的正酣時,系統悄悄進行了下麵操作:
1、線程A根據業務會把用戶id為1的積分更新成100
2、 線程B根據業務會把用戶id為1的積分更新成200
3、在資料庫層面,由於資料庫用鎖來保證了ACID,線程A和線程B不存在併發情況,,無論資料庫中最終的值是100還是200,我們都假設正確
4、假設線程B在A之後更新資料庫,則資料庫中的值為200
5、線程A和線程B在回寫緩存過程中,很可能會發生線程A線上程B之後操作緩存的情況(因為網路調用存在不確定性),這個時候緩存內的值會被更新成100,發生了緩存和資料庫不一致的情況。
第二天早上你收到了用戶投訴,怎麼辦?人工修改積分值還是刪庫跑路?
凡是處於不同物理位置的兩個操作,如果操作的是相同數據,都會遇到一致性問題,這是分散式系統不可避免的一個痛點。
1 什麼是數據一致性?
數據一致性通常講的主要是數據存儲系統,主從mysql、分散式存儲系統等,如何保證數據一致性,
比如說主從一致性,副本一致性,保證不同的時間或者相同的請求訪問這種主從資料庫時訪問的數據是一致性的,不會這次訪問是結果A下次是結果B。
2 CAP定理
說到數據一致性,就必須說CAP定理。
CAP定理是2000年由Brewer提出的,他認為分散式系統在設計和部署時,面臨3個核心問題:
Consistency:一致性。資料庫ACID操作是在一個事務中對數據加以約束,使得執行後仍處於一致狀態,而分散式系統在進行更新操作時所有的用戶都應該讀到最新值。
Availability:可用性。每一個操作總是能夠在一定時間內返回結果。結果可以是成功或失敗,一定時間是給定的時間。
Partition Tolerance:分區容忍性。考慮系統效能和可伸縮性,是否可進行數據分區。
CAP定理認為,一個提供數據服務的存儲系統無法同時滿足數據一致性、數據可用性、分區容忍性。
為什麼?如果採用分區,分散式節點之間就需要進行通信,涉及到通信,就會存在某一時刻這一節點只完成一部分業務操作,在通信完成的這一段時間內,數據就是不一致的。如果要保證一致性,就要 在通信完成的這段時間內保護數據,使得對訪問這些數據的操作都不可用。
反過來思考,如果想保證一致性和可用性,那麼數據就不能夠分區。一個簡單的理解就是所有的數據就必須存放在一個資料庫裡面,不能進行資料庫拆分。這個對於大數據量、高併發的互聯網應用來說,是不可接受的。
3 數據一致性模型
基於CAP定理,一些分散式系統通過複製數據來提高系統的可靠性和容錯性,也就是將數據的不同副本存放在不同的機器。常用的一致性模型有:
強一致性: 數據更新完成後,任何後續訪問將會返回最新的數據。這在分散式網路環境幾乎不可能實現。
弱一致性:系統不保證數據更新後的訪問會得到最新的數據。客戶端獲取最新的數據之前需要滿足一些特殊條件。
最終一致性:是弱一致性的一種特例,保證用戶最終能夠讀取到某操作對系統特定數據的更新。
4 如何保證數據一致性?
針對剛開始的問題,如果加以思考,你可能會發現不管是先寫MySQL資料庫,再刪除Redis緩存;還是先刪除緩存,再寫庫,都有可能出現數據不一致的情況。
(1)先刪除緩存
1、如果先刪除Redis緩存數據,然而還沒有來得及寫入MySQL,另一個線程就來讀取;
2、這個時候發現緩存為空,則去Mysql資料庫中讀取舊數據寫入緩存,此時緩存中為臟數據;
3、然後資料庫更新後發現Redis和Mysql出現了數據不一致的問題。
(2)後刪除緩存
1、如果先寫了庫,然後再刪除緩存,不幸的寫庫的線程掛了,導致了緩存沒有刪除;
2、這個時候就會直接讀取舊緩存,最終也導致了數據不一致情況;
3、因為寫和讀是併發的,沒法保證順序,就會出現緩存和資料庫的數據不一致的問題。
解決方案1:分散式鎖
在平時開發中,利用分散式鎖可能算是比較常見的解決方案了。利用分散式鎖把緩存操作和資料庫操作封裝為邏輯上的一個操作可以保證數據的一致性,具體流程為:
1、每個想要操作緩存和資料庫的線程都必須先申請分散式鎖;
2、如果成功獲得鎖,則進行資料庫和緩存操作,操作完畢釋放鎖;
3、如果沒有獲得鎖,根據不同業務可以選擇阻塞等待或者輪訓,或者直接返回的策略。
流程見下圖:
利用分散式鎖是解決分散式事務的一種方案,但是在一定程度上會降低系統的性能,而且分散式鎖的設計要考慮到down機和死鎖的意外情況。
解決方案2:延遲雙刪
在寫庫前後都進行redis.del(key)操作,並且設定合理的超時時間。
偽代碼如下:
public void write( String key, Object data ){
redis.delKey( key );
db.updateData( data );
Thread.sleep( 500 );
redis.delKey( key );
}
具體步驟:
1、先刪除緩存
2、再寫資料庫
3、休眠500毫秒(這個根據讀取的業務時間來定)
4、再次刪除緩存
來看之前的案例在這種方案下的情景:
T1線程線刪除緩存再更新db , T1線程更新db完成之前T2線程如果讀取到db舊的數據, 會再把舊的數據寫入Redis緩存。
此時T1線程延遲一段時間後再刪除Redis緩存操作. 當其他線程再讀取緩存為null時會查詢db最新數據重新進行緩存, 保證了Mysql和Redis緩存的數據一致性。
在此基礎上,緩存也要設置過期時間,來保證最終數據的一致性。 只要緩存過期,就去讀資料庫然後重新緩存。
這種雙刪+緩存超時的策略,最差的情況是在緩存過期時間內發生數據存在不一致,而且寫的時候增加了耗時。
但是這種方案還會出現一個問題,如何保證寫入庫後,再次刪除緩存成功?
如果刪除失敗,還有可能出現數據不一致的情況。這時候需要提供一個重試方案。
解決方案3:非同步更新緩存(基於Mysql binlog的同步機制)
1、涉及到更新的數據操作,利用Mysql binlog 進行增量訂閱消費;
2、將消息發送到消息隊列;
3、通過消息隊列消費將增量數據更新到Redis上。
這樣的效果是:
讀取Redis緩存:熱數據都在Redis上;
寫Mysql:增刪改都是在Mysql進行操作;
更新Redis數據:Mysql的數據操作都記錄到binlog,通過消息隊列及時更新到Redis上。
這樣一旦MySQL中產生了新的寫入、更新、刪除等操作,就可以把binlog相關的消息推送至Redis,Redis再根據binlog中的記錄,對Redis進行更新。
其實這種機制,很類似MySQL的主從備份機制,因為MySQL的主備也是通過binlog來實現的數據一致性。
方案2中的重試方案就可以藉助方案3,啟動一個訂閱程式訂閱資料庫的binlog,提取所需要的數據和key,另起代碼獲取這些信息。如果嘗試刪除緩存失敗,就發送消息給消息隊列,重新從消息隊列獲取數據,重試刪除操作。
參考文檔:
- https://mp.weixin.qq.com/s/k38MZRAGmZ8EhDB5DcVhhQ
- https://baijiahao.baidu.com/s?id=1678826754388688520&wfr=spider&for=pc
- https://www.php.cn/faq/415782.html
- https://blog.csdn.net/u013256816/article/details/50698167
- https://blog.csdn.net/My_Best_bala/article/details/121977033?spm=1001.2101.3001.6661.1&utm_medium=distribute.pc_relevant_t0.none-task-blog-2defaultCTRLISTRate-1.pc_relevant_antiscan&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-2defaultCTRLISTRate-1.pc_relevant_antiscan&utm_relevant_index=1
感謝閱讀~
作者:京東零售 李澤陽
來源:京東雲開發者社區 轉載請註明來源