緩存數(shù)據(jù)一致性探究
緩存是一種較低成本提升系統(tǒng)性能的方式,自它面世第一天起就備受廣大開(kāi)發(fā)者的喜愛(ài)。然而正如《人月神話》中的那句經(jīng)典的“沒(méi)有銀彈”中所說(shuō),軟件工程的設(shè)計(jì)沒(méi)有銀彈。
就像每一次發(fā)布上線修復(fù)問(wèn)題的同時(shí),也極易引入新的問(wèn)題,自緩存誕生的第一天起,緩存與數(shù)據(jù)庫(kù)的數(shù)據(jù)一致性問(wèn)題就深深困擾著開(kāi)發(fā)者們。
關(guān)鍵詞:原子性、事務(wù)性、數(shù)據(jù)一致性、雙寫一致性
緩存的查詢
先查詢緩存,如果查詢失敗,那么去查詢DB,之后重建緩存,基本上不存在異議。
緩存的更新
先更新DB還是先更新緩存?是更新緩存還是刪除緩存?在常規(guī)情況下,怎么操作都可以,但一旦面對(duì)高并發(fā)場(chǎng)景,就值得細(xì)細(xì)思量了。
1、先更新數(shù)據(jù)庫(kù)再更新緩存
線程A:更新數(shù)據(jù)庫(kù)(第1s)——> 更新緩存(第10s)
線程B:更新數(shù)據(jù)庫(kù) (第3s)——> 更新緩存(第5s)
并發(fā)場(chǎng)景下,這樣的情況是很容易出現(xiàn)的,每個(gè)線程的操作先后順序不同,這樣就導(dǎo)致請(qǐng)求B的緩存值被請(qǐng)求A給覆蓋了,數(shù)據(jù)庫(kù)中是線程B的新值,緩存中是線程A的舊值,并且會(huì)一直這么臟下去直到緩存失效(如果你設(shè)置了過(guò)期時(shí)間的話)。?
2、先更新緩存再更新數(shù)據(jù)庫(kù)
線程A:更新緩存(第1s)——> 更新數(shù)據(jù)庫(kù)(第10s)
線程B: 更新緩存(第3s)——> 更新數(shù)據(jù)庫(kù)(第5s)
和前面一種情況相反,緩存中是線程B的新值,而數(shù)據(jù)庫(kù)中是線程A的舊值。
?前兩種方式之所以會(huì)在并發(fā)場(chǎng)景下出現(xiàn)異常,本質(zhì)上是因?yàn)楦戮彺婧透聰?shù)據(jù)庫(kù)是兩個(gè)操作,我們沒(méi)有辦法控制并發(fā)場(chǎng)景下兩個(gè)操作之間先后順序,也就是先開(kāi)始操作的線程先完成自己的工作。
如果把它化簡(jiǎn),更新時(shí)只更新數(shù)據(jù)庫(kù),同時(shí)刪除緩存。等待下一次查詢時(shí)命中不到緩存,再去重建緩存,是不是就解決了這個(gè)問(wèn)題?
基于此,后面的兩種方案應(yīng)運(yùn)而生。
3、先刪除緩存再更新數(shù)據(jù)庫(kù)
通過(guò)這種方式,我們很驚喜地發(fā)現(xiàn),前面困擾我們的并發(fā)場(chǎng)景的問(wèn)題確實(shí)被解決了!兩個(gè)線程都只修改數(shù)據(jù)庫(kù),不管誰(shuí)先,數(shù)據(jù)庫(kù)以之后修改的線程為準(zhǔn)。
但這個(gè)時(shí)候,我們來(lái)思考另一個(gè)場(chǎng)景:兩個(gè)并發(fā)操作,一個(gè)是更新操作,另一個(gè)是查詢操作,更新操作刪除緩存后,查詢操作沒(méi)有命中緩存,先把老數(shù)據(jù)讀出來(lái)后放到緩存中,然后更新操作更新了數(shù)據(jù)庫(kù)。于是,在緩存中的數(shù)據(jù)還是老的數(shù)據(jù),導(dǎo)致緩存中的數(shù)據(jù)是臟的。很顯然,這種狀況也不是我們想要的。?
?
延時(shí)雙刪
在這種方案下,拓展出了延時(shí)雙刪的解決手段。
1.刪除緩存
2.更新數(shù)據(jù)庫(kù)
3.睡眠一段時(shí)間
4.再次刪除緩存
加了個(gè)睡眠時(shí)間,主要是為了確保請(qǐng)求 A 在睡眠的時(shí)候,請(qǐng)求 B 能夠在這這一段時(shí)間完成「從數(shù)據(jù)庫(kù)讀取數(shù)據(jù),再把缺失的緩存寫入緩存」的操作,然后請(qǐng)求 A 睡眠完,再刪除緩存。
所以,請(qǐng)求 A 的睡眠時(shí)間就需要大于請(qǐng)求 B 「從數(shù)據(jù)庫(kù)讀取數(shù)據(jù) + 寫入緩存」的時(shí)間。
但是具體睡眠多久其實(shí)是個(gè)玄學(xué),很難評(píng)估出來(lái),所以這個(gè)方案也只是盡可能保證一致性而已,極端情況下,依然也會(huì)出現(xiàn)緩存不一致的現(xiàn)象。
因此,還是不太建議這種方案。
4、先更新數(shù)據(jù)庫(kù)再刪除緩存(cache aside)
這種方式,在方案3的基礎(chǔ)上,又將二者的順序進(jìn)行了調(diào)換。我們?cè)侔亚懊娴膱?chǎng)景在這種方案下進(jìn)行驗(yàn)證:一個(gè)是查詢操作,一個(gè)是更新操作的并發(fā),我們先更新了數(shù)據(jù)庫(kù)中的數(shù)據(jù),此時(shí),緩存依然有效,所以,并發(fā)的查詢操作拿的是沒(méi)有更新的數(shù)據(jù),但是,更新操作馬上讓緩存的失效了,后續(xù)的查詢操作再把數(shù)據(jù)從數(shù)據(jù)庫(kù)中拉出來(lái)。而不會(huì)方案3一樣,后續(xù)的查詢操作一直在取老的數(shù)據(jù)。
而這,也正是緩存使用的標(biāo)準(zhǔn)的design pattern,也就是cache aside。包括Facebook的論文《Scaling Memcache at Facebook》也使用了這個(gè)策略。
那么,是否這種方案就是萬(wàn)無(wú)一失的完美策略呢?其實(shí)也并不然,再來(lái)看看這種場(chǎng)景:一個(gè)是讀操作,但是沒(méi)有命中緩存,然后就到數(shù)據(jù)庫(kù)中取數(shù)據(jù),此時(shí)來(lái)了一個(gè)寫操作,寫完數(shù)據(jù)庫(kù)后,讓緩存失效,然后,之前的那個(gè)讀操作再把老的數(shù)據(jù)放進(jìn)去,所以,會(huì)造成臟數(shù)據(jù)。
但是這個(gè)case理論上會(huì)出現(xiàn),不過(guò),實(shí)際上出現(xiàn)的概率可能非常低,因?yàn)檫@個(gè)條件需要發(fā)生在讀緩存時(shí)緩存失效,而且并發(fā)著有一個(gè)寫操作。而實(shí)際上數(shù)據(jù)庫(kù)的寫操作會(huì)比讀操作慢得多,而且還要鎖表,而讀操作必需在寫操作前進(jìn)入數(shù)據(jù)庫(kù)操作,而又要晚于寫操作更新緩存,所有的這些條件都具備的概率基本并不大。
所以,要么通過(guò)2PC或是Paxos協(xié)議保證一致性,要么就是拼命的降低并發(fā)時(shí)臟數(shù)據(jù)的概率,而Facebook使用了這個(gè)降低概率的玩法,因?yàn)?PC太慢,而Paxos太復(fù)雜。當(dāng)然,最好還是為緩存設(shè)置上過(guò)期時(shí)間,這樣,即使數(shù)據(jù)出現(xiàn)了不一致,也能在一段時(shí)間之后失效,更新上一致的數(shù)據(jù)。?
操作失敗
上面雖然列舉了不少較為復(fù)雜的并發(fā)場(chǎng)景,但實(shí)際上還是理想情況:即,對(duì)數(shù)據(jù)庫(kù)和緩存的操作都是成功的。然而在實(shí)際生產(chǎn)中,由于網(wǎng)絡(luò)抖動(dòng)、服務(wù)下線等等原因,操作是有可能失敗的。
舉例說(shuō)明:應(yīng)用要把數(shù)據(jù) X 的值從 1 更新為 2,先成功更新了數(shù)據(jù)庫(kù),然后在 Redis 緩存中刪除 X 的緩存,但是這個(gè)操作卻失敗了,這個(gè)時(shí)候數(shù)據(jù)庫(kù)中 X 的新值為 2,Redis 中的 X 的緩存值為 1,出現(xiàn)了數(shù)據(jù)庫(kù)和緩存數(shù)據(jù)不一致的問(wèn)題。?
?那么,后續(xù)有訪問(wèn)數(shù)據(jù) X 的請(qǐng)求,會(huì)先在 Redis 中查詢,因?yàn)榫彺娌](méi)有 誒刪除,所以會(huì)緩存命中,但是讀到的卻是舊值 1。
其實(shí)不管是先操作數(shù)據(jù)庫(kù),還是先操作緩存,只要第二個(gè)操作失敗都會(huì)出現(xiàn)數(shù)據(jù)一致的問(wèn)題。
問(wèn)題原因知道了,該怎么解決呢?有兩種方法:
-
重試機(jī)制。
-
訂閱 MySQL binlog,再操作緩存。
重試機(jī)制
我們可以引入消息隊(duì)列,將第二個(gè)操作(刪除緩存)要操作的數(shù)據(jù)加入到消息隊(duì)列,由消費(fèi)者來(lái)操作數(shù)據(jù)。
-
如果應(yīng)用刪除緩存失敗,可以從消息隊(duì)列中重新讀取數(shù)據(jù),然后再次刪除緩存,這個(gè)就是重試機(jī)制。當(dāng)然,如果重試超過(guò)一定次數(shù),還是沒(méi)有成功,我們就需要向業(yè)務(wù)層發(fā)送報(bào)錯(cuò)信息了。
-
如果刪除緩存成功,就要把數(shù)據(jù)從消息隊(duì)列中移除,避免重復(fù)操作,否則就繼續(xù)重試。
舉個(gè)例子,來(lái)說(shuō)明重試機(jī)制的過(guò)程。
?
訂閱 MySQL binlog,再操作緩存
「先更新數(shù)據(jù)庫(kù),再刪緩存」的策略的第一步是更新數(shù)據(jù)庫(kù),那么更新數(shù)據(jù)庫(kù)成功,就會(huì)產(chǎn)生一條變更日志,記錄在 binlog 里。
于是我們就可以通過(guò)訂閱 binlog 日志,拿到具體要操作的數(shù)據(jù),然后再執(zhí)行緩存刪除,阿里巴巴開(kāi)源的 Canal 中間件就是基于這個(gè)實(shí)現(xiàn)的。
Canal 模擬 MySQL 主從復(fù)制的交互協(xié)議,把自己偽裝成一個(gè) MySQL 的從節(jié)點(diǎn),向 MySQL 主節(jié)點(diǎn)發(fā)送 dump 請(qǐng)求,MySQL 收到請(qǐng)求后,就會(huì)開(kāi)始推送 Binlog 給 Canal,Canal 解析 Binlog 字節(jié)流之后,轉(zhuǎn)換為便于讀取的結(jié)構(gòu)化數(shù)據(jù),供下游程序訂閱使用。
下圖是 Canal 的工作原理:
所以,如果要想保證「先更新數(shù)據(jù)庫(kù),再刪緩存」策略第二個(gè)操作能執(zhí)行成功,我們可以使用「消息隊(duì)列來(lái)重試緩存的刪除」,或者「訂閱 MySQL binlog 再操作緩存」,這兩種方法有一個(gè)共同的特點(diǎn),都是采用異步操作緩存。?
總結(jié)
1、cache aside并非萬(wàn)能
雖然說(shuō)catch aside可以被稱之為緩存使用的最佳實(shí)踐,但與此同時(shí),它引入了緩存的命中率降低的問(wèn)題,(每次都刪除緩存自然導(dǎo)致更不容易命中了),因此它更適用于對(duì)緩存命中率要求并不是特別高的場(chǎng)景。如果要求較高的緩存命中率,依然需要采用更新數(shù)據(jù)庫(kù)后同時(shí)更新緩存的方案。
2、緩存數(shù)據(jù)不一致的解決方案
前面已經(jīng)說(shuō)了,在更新數(shù)據(jù)庫(kù)后同時(shí)更新緩存,會(huì)在并發(fā)的場(chǎng)景下出現(xiàn)數(shù)據(jù)不一致,那我們?cè)撛趺匆?guī)避呢?方案也有兩種。
引入分布式鎖
在更新緩存之前嘗試獲取鎖,如果已經(jīng)被占用就先阻塞住線程,等待其他線程釋放鎖后再嘗試更新。但這會(huì)影響并發(fā)操作的性能。
設(shè)置較短緩存時(shí)間
設(shè)置較短的緩存過(guò)期時(shí)間能夠使得數(shù)據(jù)不一致問(wèn)題存在的時(shí)間也比較長(zhǎng),對(duì)業(yè)務(wù)的影響相對(duì)較小。但是與此同時(shí),其實(shí)這也使得緩存命中率降低,又回到了前面的問(wèn)題里...
所以,綜上所述,沒(méi)有永恒的最佳方案,只有不同業(yè)務(wù)場(chǎng)景下的方案取舍。文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-599641.html
行文至此,不由得默念一聲:“There is no silver bullet!”,并再次為《人月神話》作者的精準(zhǔn)洞見(jiàn)而感嘆。文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-599641.html
到了這里,關(guān)于緩存數(shù)據(jù)一致性探究的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!