Redis中緩存和數(shù)據(jù)庫雙寫數(shù)據(jù)不一致的原因及解決方案
先更新數(shù)據(jù)庫,還是先更新緩存?
1.先更新數(shù)據(jù)庫,再更新緩存
2.先更新緩存,再更新數(shù)據(jù)庫
1.先更新數(shù)據(jù)庫,再更新緩存
舉個例子,比如【請求A】和【請求B】兩個請求,同時更新【同一條】數(shù)據(jù),則可能出現(xiàn)圖中的順序:
【請求A】先將數(shù)據(jù)庫的數(shù)據(jù)更新為1,然后在更新緩存前,【請求B】將數(shù)據(jù)庫的數(shù)據(jù)更新為2,緊接著把緩存更新為2,然后【請求A】更新緩存為1.此時,數(shù)據(jù)庫中的數(shù)據(jù)是2,而緩存中的數(shù)據(jù)卻是1,出現(xiàn)了緩存和數(shù)據(jù)庫中的數(shù)據(jù)不一致的現(xiàn)象
2.先更新緩存,再更新數(shù)據(jù)庫。
舉個例子,【請求A】和【請求B】兩個請求,同時更新【同一條】數(shù)據(jù),則可能出現(xiàn)這樣的順序:
【請求A】先將緩存的數(shù)據(jù)更新為1,然后在更新數(shù)據(jù)庫前,【請求B】來了,將緩存的數(shù)據(jù)更新為2,緊接著把把數(shù)據(jù)庫更新為2,然后【請求A】將數(shù)據(jù)庫的數(shù)據(jù)更新為1.此時,數(shù)據(jù)庫中的數(shù)據(jù)是1,而緩存中的數(shù)據(jù)卻是2,出現(xiàn)了緩存和數(shù)據(jù)庫中的數(shù)據(jù)不一致的現(xiàn)象
結(jié)論
所以,無論是【先更新數(shù)據(jù)庫,再更新緩存】,還是【先更新緩存,再更新數(shù)據(jù)庫】,這兩個方案都存在并發(fā)問題,當兩個請求并發(fā)更新同一條數(shù)據(jù)的時候,可能會出現(xiàn)緩存和數(shù)據(jù)庫中的數(shù)據(jù)不一致的現(xiàn)象
Cache Aside策略
Cache Aside(旁路緩存)策略,該策略可以細分為【讀策略】和【寫策略】
寫策略的步驟:
1.更新數(shù)據(jù)庫中的數(shù)據(jù);
2.刪除緩存中的數(shù)據(jù)
讀策略的步驟:
1.如果讀取的數(shù)據(jù)命中了緩存,則直接返回數(shù)據(jù)
2.如果讀取的數(shù)據(jù)沒有命中緩存,則從數(shù)據(jù)庫中讀取數(shù)據(jù),然后將數(shù)據(jù)寫入到緩存,并且返回給用戶
但是【寫策略】中的數(shù)據(jù)庫和緩存操作又有不同的順序:
1.先刪除緩存,再更新數(shù)據(jù)庫
2.先更新數(shù)據(jù)庫,再刪除緩存
1.先刪除緩存,再更新數(shù)據(jù)庫。
舉個例子,以用戶表的場景來分析。
假設(shè)某個用戶的年齡是20,請求A要更新用戶年齡為21,所以它會刪除緩存中的內(nèi)容。這時,另一個請求B要讀取這個用戶的年齡,它查詢緩存發(fā)現(xiàn)未命中后,會從數(shù)據(jù)庫中讀取到年齡為20,并且寫入到緩存中,然后請求A繼續(xù)更改數(shù)據(jù)庫,將用戶的年齡更新為21.
最終,該用戶年齡在緩存中是20(舊值),在數(shù)據(jù)庫中是21(新值),緩存和數(shù)據(jù)庫的數(shù)據(jù)不一致。可以看到,先刪除緩存,再更新數(shù)據(jù)庫,在【讀+寫】并發(fā)的時候,還是會出現(xiàn)緩存和數(shù)據(jù)庫的數(shù)據(jù)不一致的問題
解決方案:
針對【先刪除緩存,再更新數(shù)據(jù)庫】方法在【讀+寫】并發(fā)請求而造成緩存不一致的解決辦法是【延遲雙刪】:
偽代碼示例。加了個睡眠時間,主要是為了確保請求A在睡眠的時候,請求B能夠在這一段時間內(nèi)完成【從數(shù)據(jù)庫讀取數(shù)據(jù),再把缺失的緩存寫入緩存】的操作,然后請求A睡眠完,再刪除緩存。所以請求A的睡眠時間就需要大于請求B【從數(shù)據(jù)庫讀取數(shù)據(jù)+寫入緩存】的時間。但是具體睡眠多久其實我們是沒法準確預(yù)估的,需要進行統(tǒng)計,所以這個方案盡可能保證一致性而已,極端情況下,依然也會出現(xiàn)緩存不一致的現(xiàn)象,因此,還是比較建議用【先更新數(shù)據(jù)庫,再刪除緩存】的方案
#刪除緩存 redis.delKey(X); #更新數(shù)據(jù)庫 db.update(X); #睡眠 Thread.sleep(N); #再刪除緩存 redis.delKey(X);
2.先更新數(shù)據(jù)庫,再刪除緩存
繼續(xù)用【讀+寫】請求的并發(fā)的場景來分析。
假如某個用戶數(shù)據(jù)在緩存中不存在,請求A讀取讀取數(shù)據(jù)時從數(shù)據(jù)庫中查詢到年齡為20,在未寫入緩存中時另一個請求B更新數(shù)據(jù)。它更新數(shù)據(jù)庫中的年齡為21,并且清空緩存。這時請求A把數(shù)據(jù)庫中讀到的年齡為20的數(shù)據(jù)寫入到緩存中。最終,該用戶年齡在緩存中是20,數(shù)據(jù)庫中是21,緩存和數(shù)據(jù)庫數(shù)據(jù)不一致。
分析
從上面的理論上分析,先更新數(shù)據(jù)庫,再刪除緩存也是會出現(xiàn)數(shù)據(jù)不一致性的問題,但是在實際中,這個問題出現(xiàn)的概率并不高。因為緩存的寫入通常要遠遠快于數(shù)據(jù)庫的寫入,所以在實際中很難出現(xiàn)請求B已經(jīng)更新了數(shù)據(jù)庫并且刪除了緩存,請求A才更新完緩存的情況。而一旦請求A早于請求B刪除緩存之前更新了緩存,那么接下來的請求就會因為緩存不命中而從數(shù)據(jù)庫中重新讀取數(shù)據(jù),所以不會出現(xiàn)這種不一致的情況。所以,【先更新數(shù)據(jù)庫+再刪除緩存】的方案,是可以保證數(shù)據(jù)一致性的,再加上一個【過期時間】,就算在這期間存在緩存數(shù)據(jù)不一直,有過期時間來兜底,這樣也能達到最終一致。
【先更新數(shù)據(jù)庫,再刪除緩存】存在的問題:
前面的分析都是建立再這兩個操作都能同時執(zhí)行成功的情況下,如果在刪除緩存(第二個操作)的時候失敗了,導(dǎo)致緩存中的數(shù)據(jù)是舊值,如果沒有前面的過期時間兜底的話,后續(xù)的請求就會一直是緩存中的就數(shù)據(jù)
【先更新數(shù)據(jù)庫,再刪除緩存】的方案雖然保證了數(shù)據(jù)庫與緩存的數(shù)據(jù)一致性,但是每次更新數(shù)據(jù)的時候,緩存的數(shù)據(jù)都會被刪除,這樣會對緩存的命中率帶來影響。所以,如果業(yè)務(wù)對緩存命中率有很高的要求,可以采用【更新數(shù)據(jù)庫+更新緩存】的方案,因為更新緩存并不會出現(xiàn)緩存未命中的情況,但是這個方案,前面提到,在兩個更新請求并發(fā)執(zhí)行的時候,會出現(xiàn)數(shù)據(jù)不一致的問題,因為更新數(shù)據(jù)庫和更新緩存這兩個操作是獨立的,我們又沒有對操作做任何并發(fā)控制,那么當兩個線程并發(fā)更新它們的話,就會因為寫入順序的不同造成數(shù)據(jù)不一致需要增加一些手段來解決這個問題,有兩種做法
- 1.在更新緩存前先加個分布式鎖,保證同一時間之運行一個請求更新緩存,就不會產(chǎn)生并發(fā)問題了,但是引入鎖之后,對于寫入性能就會帶來影響
- 2.在更新完緩存時,給緩存加上較短的過期時間,這樣即時出現(xiàn)緩存不一致的情況,緩存的數(shù)據(jù)也會很快過期,對業(yè)務(wù)來說也可以接受
如何保證【先更新數(shù)據(jù)庫,再刪除緩存】這兩個操作能執(zhí)行成功?
舉個例子:
應(yīng)用要把數(shù)據(jù)X的值從1更新為2,先成功更新了數(shù)據(jù)庫,然后在Redis緩存中刪除X的緩存,但是這個操作卻失敗了,這個時候數(shù)據(jù)庫中的X的新值為2,Redis中的X的緩存值為1,出現(xiàn)了數(shù)據(jù)庫和緩存數(shù)據(jù)不一致的問題。那么后續(xù)有訪問數(shù)據(jù)X的請求,會先在Redis中查詢,因為緩存中并沒有刪除,所以緩存命中,但是讀到的卻是舊值1.其實不管先操作數(shù)據(jù)庫,還是先操作緩存,只要第二個操作失敗都會出現(xiàn)數(shù)據(jù)不一致的問題,解決方案有兩種:
- 1.重試機制
- 2.訂閱MySQL binlog,再操作緩存
1.重試機制。
我們可以引入消息隊列,將第二個操作(刪除緩存)要操作的數(shù)據(jù)加入到消息隊列,由消費者來操作數(shù)據(jù)。
- 1.1 如果應(yīng)用刪除緩存失敗,可以從消息隊列中重新讀取數(shù)據(jù),然后再次刪除緩存,這個就是重試機制。當然,如果重試超過一定的次數(shù),還是沒有成功,就需要向業(yè)務(wù)層發(fā)送報錯消息了
- 1.2 如果刪除緩存成功,就要把數(shù)據(jù)從消息隊列中移除,避免重復(fù)操作,否則就繼續(xù)重試
2.訂閱MySQL binlog,再操作緩存
【先更新數(shù)據(jù)庫,再刪除緩存】的策略第一步是更新數(shù)據(jù)庫,那么更新數(shù)據(jù)庫成功,就會產(chǎn)生一條變更日志,記錄在binlog里。于是我們就可以通過訂閱binlog日志,拿到具體要操作的數(shù)據(jù),然后再執(zhí)行緩存刪除,阿里開源的Cannal中間件就是基于這個實現(xiàn)的。
Cannal模擬MySQL主從復(fù)制的交互協(xié)議,把自己偽裝成一個MySQL的從節(jié)點,向MySQL主節(jié)點發(fā)送dump請求,MySQL收到請求后,就會開始推送binlog給Cannal,Cannal解析binlog字節(jié)流之后,轉(zhuǎn)換為便于讀取的結(jié)構(gòu)化數(shù)據(jù),供下游程序訂閱使用.
所以如果要想保證【先更新數(shù)據(jù)庫,再刪除緩存】策略第二個操作能執(zhí)行成功,我們可以使用【消息隊列來重試緩存的刪除】,或者【訂閱MySQL binlog再操作緩存】,這兩種方法有一個共同的特點,都是采用異步操作緩存
疑問
為什么是刪除緩存,而不是更新緩存?
刪除一個數(shù)據(jù),相比更新一個數(shù)據(jù)更加輕量級,出問題的概率更小。在實際業(yè)務(wù)中,緩存的數(shù)據(jù)可能不是直接來自數(shù)據(jù)庫表,也許來自多張底層數(shù)據(jù)表的聚合。比如商品詳情信息,在底層可能會關(guān)聯(lián)商品表、價格表、庫存表等,如果更新了一個價格字段,那么就要更新整個數(shù)據(jù)庫,還要關(guān)聯(lián)的去查詢和匯總各個周邊業(yè)務(wù)系統(tǒng)的數(shù)據(jù),這個操作會非常耗時。從另外一個角度,不是所有的緩存數(shù)據(jù)都是頻繁訪問的,更新后的緩存可能會長事件不被訪問,所以說,從計算資源和整體性能的考慮,更新的時候刪除緩存,等到下次查詢命中再填充緩存,是一個更好的方案。
系統(tǒng)設(shè)計中有一個設(shè)計叫Lazy Loading,適用于那些加載代價大的操作,刪除緩存而不是更新緩存,就是懶加載思想的一個應(yīng)用
以上就是Redis中緩存和數(shù)據(jù)庫雙寫數(shù)據(jù)不一致的原因及解決方案的詳細內(nèi)容,更多關(guān)于Redis緩存和數(shù)據(jù)庫雙寫不一致的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
redis學(xué)習(xí)之RDB、AOF與復(fù)制時對過期鍵的處理教程
這篇文章主要給大家介紹了關(guān)于redis學(xué)習(xí)之RDB、AOF與復(fù)制時對過期鍵處理的相關(guān)資料,文中通過示例代碼介紹的非常詳細,對大家學(xué)習(xí)或者使用redis具有一定的參考學(xué)習(xí)價值,需要的朋友們下面來一起學(xué)習(xí)學(xué)習(xí)吧2019-11-11基于Redis+Lua腳本實現(xiàn)分布式限流組件封裝的方法
這篇文章主要介紹了基于Redis+Lua腳本實現(xiàn)分布式限流組件封裝,本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-10-10