再有人問你數(shù)據(jù)庫緩存一致性的問題,直接把這篇文章發(fā)給他!


我們提到過,在數(shù)據(jù)庫和緩存的操作過程中,可能存在”先寫數(shù)據(jù)庫,后刪緩存”、”先寫數(shù)據(jù)庫,后更新緩存”、”先刪緩存庫,后寫數(shù)據(jù)庫”以及”先更新緩存庫,后寫數(shù)據(jù)庫”這四種。
那么,到底是應(yīng)該刪除緩存好呢,還是更新緩存好呢?到底應(yīng)該先操作數(shù)據(jù)庫呢還是先操作緩存呢?哪種方案更好呢?又該如何選擇呢?
本文就來展開分析一下。
為了保證數(shù)據(jù)庫和緩存里面的數(shù)據(jù)是一致的,很多人會很多人在做數(shù)據(jù)更新的時(shí)候,會同時(shí)更新緩存里面的內(nèi)容。但是我其實(shí)告訴大家,應(yīng)該優(yōu)先選擇刪除緩存而不是更新緩存。
首先,我們暫時(shí)拋開數(shù)據(jù)一致性的問題,單獨(dú)來看看更新緩存和刪除緩存的復(fù)雜的的問題。
我們放到緩存中的數(shù)據(jù),很多時(shí)候可能不只是簡單的一個(gè)字符串類型的值,他還可能是一個(gè)大的JSON串,一個(gè)map類型等等。
舉個(gè)栗子,我們需要通過緩存進(jìn)行扣減庫存的時(shí)候,你可能需要從緩存中查出整個(gè)訂單模型數(shù)據(jù),把他進(jìn)行反序列化之后,再解析出其中的庫存字段,把他修改掉,然后再序列化,最后再更新到緩存中。
可以看到,更新緩存的動作,相比于直接刪除緩存,操作過程比較的復(fù)雜,而且也容易出錯。
還有就是,在數(shù)據(jù)庫和緩存的一致性保證方面,刪除緩存相比更新緩存要更簡單一點(diǎn)。
我們在《為什么會出現(xiàn)數(shù)據(jù)庫和緩存不一致的問題》中介紹過的"寫寫并發(fā)"的場景中,如果同時(shí)更新緩存和數(shù)據(jù)庫,那么很容易會出現(xiàn)因?yàn)椴l(fā)的問題導(dǎo)致數(shù)據(jù)不一致的情況。如:
先寫數(shù)據(jù)庫,再更新緩存

先更新緩存,后寫數(shù)據(jù)庫:

但是,如果是做緩存的刪除的話,在寫寫并發(fā)的情況下,緩存中的數(shù)據(jù)都是要被清除的,所以就不會出現(xiàn)數(shù)據(jù)不一致的問題。
但是,更新緩存相比刪除緩存還是有一個(gè)小的缺點(diǎn),那就是帶來的一次額外的cache miss,也就是說在刪除緩存后的下一次查詢會無法命中緩存,要查詢一下數(shù)據(jù)庫。
這種cache miss在某種程度上可能會導(dǎo)致緩存擊穿,也就是剛好緩存被刪除之后,同一個(gè)Key有大量的請求過來,導(dǎo)致緩存被擊穿,大量請求訪問到數(shù)據(jù)庫。
但是,通過加鎖的方式是可以比較方便的解決緩存擊穿的問題的。
總之,刪除緩存相比較更新緩存,方案更加簡單,而且?guī)淼囊恢滦詥栴}也更少。所以,在刪除和更新緩存之間,我還是偏向于建議大家優(yōu)先選擇刪除緩存。
在確定了優(yōu)先選擇刪除緩存而不是更新緩存之后,留給我們的數(shù)據(jù)庫+緩存更新的可選方案就剩下:"先寫數(shù)據(jù)庫后刪除緩存"和"先刪除緩存后寫數(shù)據(jù)庫了"。
那么,這兩種方式各自有什么優(yōu)缺點(diǎn)呢?該如何選擇呢?
而一般情況下,如果把緩存的刪除動作放到第二步,有一個(gè)好處,那就是緩存刪除失敗的概率還是比較低的,除非是網(wǎng)絡(luò)問題或者緩存服務(wù)器宕機(jī)的問題,否則大部分情況都是可以成功的。
還有就是,先寫數(shù)據(jù)庫后刪除緩存雖然不存在"寫寫并發(fā)"導(dǎo)致的數(shù)據(jù)一致性問題,但是會存在"讀寫并發(fā)"情況下的數(shù)據(jù)一致性問題。
我們知道,當(dāng)我們使用了緩存之后,一個(gè)讀的線程在查詢數(shù)據(jù)的過程是這樣的:
1、查詢緩存,如果緩存中有值,則直接返回
2、查詢數(shù)據(jù)庫
3、把數(shù)據(jù)庫的查詢結(jié)果更新到緩存中
所以,對于一個(gè)讀線程來說,雖然不會寫數(shù)據(jù)庫,但是是會更新緩存的,所以,在一些特殊的并發(fā)場景中,就會導(dǎo)致數(shù)據(jù)不一致的情況。
讀寫并發(fā)的時(shí)序如下:

也就是說,假如一個(gè)讀線程,在讀緩存的時(shí)候沒查到值,他就會去數(shù)據(jù)庫中查詢,但是如果自查詢到結(jié)果之后,更新緩存之前,數(shù)據(jù)庫被更新了,但是這個(gè)讀線程是完全不知道的,那么就導(dǎo)致最終緩存會被重新用一個(gè)"舊值"覆蓋掉。
這也就導(dǎo)致了緩存和數(shù)據(jù)庫的不一致的現(xiàn)象。
但是這種現(xiàn)象其實(shí)發(fā)生的概率比較低,因?yàn)橐话阋粋€(gè)讀操作是很快的,數(shù)據(jù)庫+緩存的讀操作基本在十幾毫秒左右就可以完成了。
而在這期間,更好另一個(gè)線程執(zhí)行了一個(gè)比較耗時(shí)的寫操作的概率確實(shí)比較低。
先刪緩存
那么,如果是先刪除緩存后操作數(shù)據(jù)庫的話,會不會方案更完美一點(diǎn)呢?
首先,如果是選擇先刪除緩存后寫數(shù)據(jù)庫的這種方案,那么第二步的失敗是可以接受的,因?yàn)檫@樣不會有臟數(shù)據(jù),也沒什么影響,只需要重試就好了。
但是,先刪除緩存后寫數(shù)據(jù)庫的這種方式,會無形中放大前面我們提到的"讀寫并發(fā)"導(dǎo)致的數(shù)據(jù)不一致的問題。
因?yàn)檫@種"讀寫并發(fā)"問題發(fā)生的前提是讀線程讀緩存沒讀到值,而先刪緩存的動作一旦發(fā)生,剛好可以讓讀線程就從緩存中讀不到值。
所以,本來一個(gè)小概率會發(fā)生的"讀寫并發(fā)"問題,在先刪緩存的過程中,問題發(fā)生的概率會被放大。
而且這種問題的后果也比較嚴(yán)重,那就是緩存中的值一直是錯的,就會導(dǎo)致后續(xù)的所以命中緩存的查詢結(jié)果都是錯的!
那么,雖然先寫數(shù)據(jù)后刪除緩存的這種情況,可以大大的降低并發(fā)問題的概率,但是,根據(jù)墨菲定律,只要有可能發(fā)生的壞事,那就基本上會發(fā)生。越是龐大的系統(tǒng)發(fā)生的概率越高。
那么,有沒有什么辦法可以來解決一下這種情況帶來的不一致的問題呢?
其實(shí)是有一個(gè)比較常見的方案的,在很多公司內(nèi)用的也比較多,那就是延遲雙刪。
因?yàn)?讀寫并發(fā)"的問題會導(dǎo)致并發(fā)發(fā)生后,緩存中的數(shù)被讀線程寫進(jìn)去臟數(shù)據(jù),那么就只需要在寫線程在寫數(shù)據(jù)庫、刪緩存之后,延遲一段時(shí)間,在執(zhí)行一把刪除動作就行了。
這樣就能保證緩存中的臟數(shù)據(jù)被清理掉,避免后續(xù)的讀操作都讀到臟數(shù)據(jù)。當(dāng)然,這個(gè)延遲的時(shí)長也很講究,到底多久來刪除呢?一般建議設(shè)置1-2s就可以了。
當(dāng)然,這種方案也是有一個(gè)弊端的,那就是可能會導(dǎo)致緩存中準(zhǔn)確的數(shù)據(jù)被刪除掉。當(dāng)然這也問題不大,就像我們前面說過的,只是增加一次cache miss罷了
前面介紹了幾種情況的具體問題和解決方案,那么實(shí)際工作中應(yīng)該如何選擇呢?
我覺得主要還是根據(jù)實(shí)際的業(yè)務(wù)情況來分析。
比如,如果業(yè)務(wù)量不大,并發(fā)不高的情況,可以選擇先刪除緩存,后更新數(shù)據(jù)庫的方式,因?yàn)檫@種方案更加簡單。
但是,如果是業(yè)務(wù)量比較大,并發(fā)度很高的話,那么建議選擇先更新數(shù)據(jù)庫,后刪除緩存的方式,因?yàn)檫@種方式并發(fā)問題更少一些。但是可能會引入加鎖、延遲雙刪等更多機(jī)制,使得整個(gè)方案會更加復(fù)雜。
其實(shí),先操作數(shù)據(jù)庫,后操作緩存,是一種比較典型的設(shè)計(jì)模式——Cache Aside Pattern。
這種模式的主要方案就是先寫數(shù)據(jù)庫,后刪緩存,而且緩存的刪除是可以在旁路異步執(zhí)行的。
這種模式的優(yōu)點(diǎn)就是我們說的,他可以解決"寫寫并發(fā)"導(dǎo)致的數(shù)據(jù)不一致問題,并且可以大大降低"讀寫并發(fā)"的問題,所以這也是Facebook比較推崇的一種模式。
Cache Aside Pattern 這種模式中,我們可以異步的在旁路處理緩存。其實(shí)這種方案在大廠中確實(shí)有的還蠻多的。
主要的方式就是借助數(shù)據(jù)庫的binlog或者基于異步消息訂閱的方式。
也就是說,在代碼的主要邏輯中,先操作數(shù)據(jù)庫就行了,然后數(shù)據(jù)庫操作完,可以發(fā)一個(gè)異步消息出來。
然后再由一個(gè)監(jiān)聽者在接到消息之后,異步的把緩存中的數(shù)據(jù)刪除掉。
或者干脆借助數(shù)據(jù)庫的binlog,訂閱到數(shù)據(jù)庫變更之后,異步的清除緩存。
這兩種方式都會有一定的延時(shí),通常在毫秒級別,一般用于在可接受秒級延遲的業(yè)務(wù)場景中。
前面介紹過了Cache Aside Pattern這種關(guān)于緩存操作的設(shè)計(jì)模式,那么其實(shí)還有幾種其他的設(shè)計(jì)模式,也一起展開介紹一下:
Read/Write Through Pattern
在這兩種模式中,應(yīng)用程序?qū)⒕彺孀鳛橹饕臄?shù)據(jù)源,不需要感知數(shù)據(jù)庫,更新數(shù)據(jù)庫和從數(shù)據(jù)庫的讀取的任務(wù)都交給緩存來代理。
Read Through模式下,是由緩存配置一個(gè)讀模塊,它知道如何將數(shù)據(jù)庫中的數(shù)據(jù)寫入緩存。在數(shù)據(jù)被請求的時(shí)候,如果未命中,則將數(shù)據(jù)從數(shù)據(jù)庫載入緩存。
Write Through模式下,緩存配置一個(gè)寫模塊,它知道如何將數(shù)據(jù)寫入數(shù)據(jù)庫。當(dāng)應(yīng)用要寫入數(shù)據(jù)時(shí),緩存會先存儲數(shù)據(jù),并調(diào)用寫模塊將數(shù)據(jù)寫入數(shù)據(jù)庫。
也就是說,這兩種模式下,不需要應(yīng)用自己去操作數(shù)據(jù)庫,緩存自己就把活干完了。
Write Behind Caching Pattern
這種模式就是在更新數(shù)據(jù)的時(shí)候,只更新緩存,而不更新數(shù)據(jù)庫,然后再異步的定時(shí)把緩存中的數(shù)據(jù)持久化到數(shù)據(jù)庫中。
這種模式的優(yōu)缺點(diǎn)比較明顯,那就是讀寫速度都很快,但是會造成一定的數(shù)據(jù)丟失。
這種比較適合用在比如統(tǒng)計(jì)文章的訪問量、點(diǎn)贊等場景中,允許數(shù)據(jù)少量丟失,但是速度要快。
《人月神話》的作者Fred Brooks在早年有一篇很著名文章《No Silver Bullet》 ,他提到:
在軟件開發(fā)過程里是沒有萬能的終殺性武器的,只有各種方法綜合運(yùn)用,才是解決之道。而各種聲稱如何如何神奇的理論或方法,都不是能殺死“軟件危機(jī)”這頭人狼的銀彈。
也就是說,沒有哪種技術(shù)手段或者方案,是放之四海皆準(zhǔn)的。如果有的話,我們這些工程師也就沒有存在的必要了。
所以,任何的技術(shù)方案,都是一個(gè)權(quán)衡的過程,要權(quán)衡的問題有很多,業(yè)務(wù)的具體情況,實(shí)現(xiàn)的復(fù)雜度、實(shí)現(xiàn)的成本,團(tuán)隊(duì)成員的接受度、可維護(hù)性、容易理解的程度等等。
所以,沒有一個(gè)"完美"的方案,只有"適合"的方案。
但是,如何能選出一個(gè)適合的方案,這里面就需要有很多的輸入來做支撐了。希望本文的內(nèi)容可以為你日后的決策提供一點(diǎn)參考!
歡迎添加小編微信,進(jìn)入交流群
推薦閱讀:
