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