如何解決緩存系統(tǒng)的數(shù)據(jù)不一致問題
本文轉(zhuǎn)載自微信公眾號「后端技術(shù)指南針」,作者大白。轉(zhuǎn)載本文請聯(lián)系后端技術(shù)指南針公眾號。
1緩存系統(tǒng)交互
緩存系統(tǒng)設(shè)計是后端開發(fā)人員的必備技能,也是實現(xiàn)高并發(fā)的重要武器。
對于讀多寫少的場景,我們通常使用內(nèi)存型數(shù)據(jù)庫作為緩存,關(guān)系型數(shù)據(jù)庫作為主存儲,從而形成兩層相互依賴的存儲體系。
共識:我們將使用Redis和MySQL作為緩存和主存的實體,展開今天的話題。
緩存系統(tǒng)的讀取場景和更新場景:
- 讀取時只要之前MySQL和Redis中的數(shù)據(jù)是一致的,后續(xù)只要沒有更新操作就不會有什么問題,同時借助于內(nèi)存來提高并發(fā)能力,這也是我們設(shè)計緩存系統(tǒng)的初衷。
- 對于讀多寫少的業(yè)務(wù)模型,由于操作MySQL和Redis并非天然的原子操作,會造成數(shù)據(jù)的不一致,需要特殊處理。
讀取過程示意:
讀取過程:讀請求優(yōu)先從緩存中獲取數(shù)據(jù),拿到后即可返回;如緩存無數(shù)據(jù),則從主存儲拿數(shù)據(jù),并且將數(shù)據(jù)更新到緩存中,為后續(xù)的讀取請求做鋪墊。
更新過程之所以會出現(xiàn)數(shù)據(jù)不一致問題,有內(nèi)外兩大原因:
- 內(nèi)部原因:Redis和MySQL的更新不是天然的原子操作,非事務(wù)性的組合拳。
- 外部原因:實際中的讀寫請求是并發(fā)且無序的,可預(yù)測性很差,完全不可控。
2數(shù)據(jù)不一致的感知
我們來看個實際中的例子,進一步了解緩存系統(tǒng)的數(shù)據(jù)不一致問題。
平時上下班擠地鐵的時候,我們經(jīng)常會聽網(wǎng)易云,比如我喜歡聽民謠,所有會關(guān)注官方發(fā)布的一些民謠歌曲榜單,如圖:
歌單是網(wǎng)易云的運營同學(xué)配置的,作為用戶我們是無法修改的歌單的內(nèi)容的,所以這是個非常典型的讀多寫少的場景。
所以假如我是網(wǎng)易云的后端同學(xué),我肯定會把歌單的信息存儲在Redis中,緩存下來提高性能,大概可以是這個樣子:
假如因為版權(quán)問題,運營刪除了一首歌,此時更新了MySQL,但是Redis中的數(shù)據(jù)并沒有及時被更新,那么就會有一少部分用戶在歌單中看到本已被刪除的歌曲,點擊時可能無法播放等。
畫外音:這就是緩存和主存儲的數(shù)據(jù)不一致的現(xiàn)象,當(dāng)然具體網(wǎng)易云是咋實現(xiàn)的,咱也不清楚,上述的場景純屬作者腦補來說明不一致問題的直觀實例。
3理性看待不一致問題
數(shù)據(jù)一致性可以說是分布式系統(tǒng)中必然存在的問題,數(shù)據(jù)一致性可以分為:
- 強一致性:時時刻刻保持一致。
- 最終一致性:允許短暫的不一致,但是最后還是一致的。
要實現(xiàn)緩存和主存儲的強一致性,需要借助于復(fù)雜的分布式一致性協(xié)議等,倒不如不用緩存,畢竟緩存的優(yōu)勢還是讀多寫少的場景。
畫外音:緩存并不是什么萬金油,對于寫多讀少的場景,或許并不是適合用緩存。
工程和學(xué)術(shù)是有區(qū)別的,因此我們后續(xù)的問題都是圍繞最終一致性展開的,因為這才是有意義的問題。
進而我們將問題轉(zhuǎn)化為:
研究重點:在保證數(shù)據(jù)最終一致性的前提下,如何把數(shù)據(jù)不一致帶來的影響降低到業(yè)務(wù)可接受的范圍內(nèi)?
4更新還是刪除是個問題
當(dāng)MySQL被更新時,我們?nèi)绾翁幚鞷edis呢?
- 直接將key淘汰掉,是否再次被加載由后續(xù)讀請求決定。
- 直接update發(fā)生變化的key,相當(dāng)于幫后面的請求做了加載的操作。
可以明確一點刪除操作直接操作就行(簡單明了),但是更新操作可能涉及的處理步驟更多,也就是update可能比delete更復(fù)雜。
另外,我們需要盡量保證Redis中的數(shù)據(jù)都是熱數(shù)據(jù),update每次都會使得數(shù)據(jù)駐留在Redis中,或許這是沒有必要的,因為這些可能是冷數(shù)據(jù),至于要加載哪些數(shù)據(jù),還是交給后面的請求比較合適,各司其職。
綜上,我們更傾向于將delete作為通用的選擇,因此后續(xù)都是基于淘汰緩存來展開的。
5如何解決不一致問題
Redis和MySQL的數(shù)據(jù)不一致產(chǎn)生的根源是業(yè)務(wù)需要進行更新(寫入)操作。
先操作Redis 還是 先操作MySQL是個問題,操作時序不同產(chǎn)生的影響也不同。
尺有所短,寸有所長,說到底是一種權(quán)衡,哪一種組合產(chǎn)生的負面影響對業(yè)務(wù)最小,就傾向于哪種方案。
緩存系統(tǒng)的數(shù)據(jù)不一致問題,是個經(jīng)典的問題,因此肯定有很多解決問題的套路,所以讓我們帶著分析和思考去看看,各個方案的利弊。
思路一:設(shè)置緩存過期時間
當(dāng)向Redis寫入一條數(shù)據(jù)時,同時設(shè)置過期時間x秒,業(yè)務(wù)不同過期時間不同。
過期時間到達時Redis就會刪掉這條數(shù)據(jù),后續(xù)讀請求Redis出現(xiàn)Cache Miss,進而讀取MySQL,然后把數(shù)據(jù)寫到Redis。
如果發(fā)生更新操作時,只操作MySQL,那么Redis中的數(shù)據(jù)更新就只是依賴于過期時間來保底,淘汰后再被加載就是新數(shù)據(jù)了。
畫外音:這種方案是最簡單的,如果業(yè)務(wù)對短時間不一致問題并不在意,設(shè)置過期時間的方案就足夠了,沒有必要搞太復(fù)雜。
思路二:先淘汰緩存&再更新主存
進行更新操作時,為了防止其他線程讀到緩存中的舊數(shù)據(jù),干脆淘汰掉,然后把數(shù)據(jù)更新到主存儲,后續(xù)的請求再次讀取時觸發(fā)Cache Miss,從而讀取MySQL再將新數(shù)據(jù)更新到Redis。
- 在T1時刻:Redis和MySQL對于age的值都是18,二者一致;
- 在T2時刻:有更新請求需要設(shè)置age=20,此時Redis中就沒有age這個數(shù)據(jù)了;在完成Redis淘汰后,進行MySQL數(shù)據(jù)更新age=20;
這個方案聽著還不錯的樣子,但是讀寫請求都是并發(fā)的,先后順序完全無法預(yù)測,甚至后發(fā)出的請求先處理完成,也是很常見的。
可見一個明顯的漏洞:在淘汰Redis的數(shù)據(jù)完成后,更新MySQL完成之前,這個時間段內(nèi)如果有新的讀請求過來,發(fā)現(xiàn)Cache Miss了,就會把舊數(shù)據(jù)重新寫到Redis中,再次造成不一致,并且毫無察覺后續(xù)讀的都是舊數(shù)據(jù)。
畫外音:這個方案其實不能說完全沒有用,但是至少不完美吧。
思路三:先更新主存&再淘汰緩存
進行更新操作時,先更新MySQL,成功之后,淘汰緩存,后續(xù)讀取請求時觸發(fā)Cache Miss再將新數(shù)據(jù)回寫Redis。
這種模式在更新MySQL和淘汰Redis這段時間內(nèi),請求讀取的還是Redis的舊數(shù)據(jù),不過等MySQL更新完成,就可以立刻恢復(fù)一致,影響相對比較小。
上述是在緩存中有數(shù)據(jù)的情況,也就是T2時刻的讀請求沒有觸發(fā)Cache Miss,也就不會更新緩存,因此問題不大。
但是,假如T2時刻讀取的數(shù)據(jù)在緩存沒有,那么觸發(fā)Cache Miss后會產(chǎn)生回寫,假如這個回寫動作是在T4時刻完成,那么寫入的還是老數(shù)據(jù),如圖:
這種情況確實有問題,但是真是太巧了吧,分析一下:
- 事件A:淘汰Redis前來了一個讀請求;
- 事件B:T2時刻的讀請求觸發(fā)了Cache Miss;
- 事件C:回寫Redis發(fā)生在淘汰緩存之后;
那么發(fā)生問題的概率就是P(A)*P(B)*P(C),從實際考慮這種綜合事件發(fā)生的概率非常低,因為寫操作遠慢于讀操作,也就是圖上的T4事件大概率是發(fā)生在T3事件之前的。
畫外音:先更新MySQL再淘汰Redis的方案,雖然存在小概率不一致問題,但是總體來說工程上是可用的,比如非要說寫完MySQL掛了,Redis就沒淘汰,這種情況只能說確實有問題。
思路四:延時雙刪(淘汰)
前面提到的思路二和思路三都只有一次Redis淘汰操作,這里要說的延時雙刪本質(zhì)上是思路二和思路三的結(jié)合:
說實話個人覺得,這個方案有點堆操作的感覺,而且設(shè)置延時的目的是為了避免思路三的小概率問題,延時設(shè)置多久不好確定,二來延時降低了并發(fā)性能,同時前置的刪除緩存操作起到的作用并不大。
這個方案倒是透露出一種思想:多刪幾次,可能一致性更有保證,那確實如此,但是命中率也就低了,命中率和一致性看來也是一對矛盾。
畫外音:這個方案也不是說不行,其實有點麻煩,并且在復(fù)雜高并發(fā)場景中反而影響性能,要是一般的場景或許也能用起來。
思路五:異步更新緩存
既然直接操作MySQL和Redis都多少存在一些問題,那么能不能引入中間層來解決問題呢?
把MySQL的更新操作完成后不直接操作Redis,而是把這個操作命令(消息)扔到一個中間層,然后由Redis自己來消費更新數(shù)據(jù),這是一種解耦的異步方案。
單純?yōu)榱烁戮彺嬉胫虚g件確實有些復(fù)雜,但是像MySQL提供了binlog的同步機制,此時Redis就作為Slave進行主從同步,實現(xiàn)數(shù)據(jù)的更新,成本也還可以接受。
畫外音:引入中間層思想真是萬金油啊!
6 總結(jié)一下
本文主要介紹了以下幾個關(guān)鍵內(nèi)容:
- 緩存系統(tǒng)適用的場景:讀多寫少。
- 緩存系統(tǒng)的讀寫基本交互過程,讀很簡單,寫有點復(fù)雜。
- 緩存系統(tǒng)寫時的不一致問題有內(nèi)外兩個因素:外部讀寫的并發(fā)無序性和內(nèi)部操作非原子性。
- 使用緩存系統(tǒng),我們就需要接受最終一致性的前提,否則不建議用緩存。
- 解決緩存數(shù)據(jù)不一致的思路有很多,或多或少都有不足,具體用哪種,需要根據(jù)實際業(yè)務(wù)場景,沒有哪種方案是普遍適用的。