靈魂拷問
保證緩存和數(shù)據(jù)庫的一致性很簡單嗎?
有哪些方式能保證緩存和數(shù)據(jù)庫的一致性呢?
如果發(fā)生了緩存和數(shù)據(jù)庫數(shù)據(jù)不一致的情況怎么辦呢?
在上篇文章我們介紹了緩存的定義分類以及優(yōu)缺點等,如果還沒看的同學(xué)可以移步這里
聽說你會緩存?
當(dāng)我們的系統(tǒng)引入緩存組件之后,性能得到了大幅度提升,但是隨之而來的是代碼需要引入一定的復(fù)雜度,比如緩存的更新策略,寫入策略,過期策略等,而其中最可能導(dǎo)致程序員加班的莫過于緩存和數(shù)據(jù)庫的一致性問題了,既:緩存中的數(shù)據(jù)和數(shù)據(jù)庫中的數(shù)據(jù)不一致。
一致性問題
說到一致性問題,這算是分布式系統(tǒng)中不可避免的一個痛點,或者說分布式系統(tǒng)天然就自帶了數(shù)據(jù)一致性問題,雖然可以利用很多分布式事務(wù)解決方案來做到一致性,但是實際的系統(tǒng)架構(gòu)設(shè)計中,我還是推崇避免分布式事務(wù)。緩存和數(shù)據(jù)庫數(shù)據(jù)的一致性在產(chǎn)生原理上和分布式類似,其實可以把他們兩個的關(guān)系看做是分布式系統(tǒng)中的兩個操作節(jié)點。
“凡是處于不同物理位置的兩個操作,如果操作的是相同數(shù)據(jù),都會遇到一致性問題
產(chǎn)生數(shù)據(jù)一致性問題的根本原因是對一個數(shù)據(jù)的多個操作過程,緩存和數(shù)據(jù)庫數(shù)據(jù)的一致性也是這個原理,系統(tǒng)中最常見的操作流程是這樣的:
●數(shù)據(jù)的請求首先查詢緩存中是否存在該數(shù)據(jù)
●如果數(shù)據(jù)命中緩存(在緩存中存在)則直接返回數(shù)據(jù),如果數(shù)據(jù)沒有命中緩存(緩存中不存在),則去數(shù)據(jù)庫中取數(shù)據(jù)
●從數(shù)據(jù)庫中取回數(shù)據(jù),然后把數(shù)據(jù)寫入緩存
好圖
從圖中可以清楚的看到,對數(shù)據(jù)庫的操作和對緩存的操作是兩個不同階段的操作,在任何一個操作過程中都會發(fā)生線程安全問題。比如說:
●當(dāng)兩個線程同時查詢緩存的時候,可能會發(fā)生兩個線程都沒有命中緩存的問題
●如果兩個線程都沒有命中緩存就會發(fā)生同時查詢數(shù)據(jù)庫的問題
●接著就會發(fā)生兩個線程同時回寫緩存的問題
而這還不是最致命的,畢竟兩個線程同時查詢數(shù)據(jù)庫,同時回寫緩存數(shù)據(jù)在多數(shù)情況下緩存數(shù)據(jù)和數(shù)據(jù)庫數(shù)據(jù)還能保持一致。最要命的是如果是兩個線程都進(jìn)行更新操作,最常見的更新過程是先更新數(shù)據(jù)庫,然后更新緩存。下面就以最常見的用戶積分場景為例,每個用戶都有自己的積分,假如發(fā)生以下過程:
●線程A根據(jù)業(yè)務(wù)會把用戶id為1的積分更新成100
●線程B根據(jù)業(yè)務(wù)會把用戶id為1的積分更新成200
●在數(shù)據(jù)庫層面,線程A和線程B肯定不存在并發(fā)情況,因為數(shù)據(jù)庫用鎖來保證了ACID(假如是mysql等關(guān)系型數(shù)據(jù)庫),無論數(shù)據(jù)庫中最終的值是100還是200,我們都假設(shè)正確。
●假設(shè)線程B在A之后更新數(shù)據(jù)庫,則數(shù)據(jù)庫中的值為200
●線程A和線程B在回寫緩存過程中,很可能會發(fā)生線程A在線程B之后操作緩存的情況(因為網(wǎng)絡(luò)調(diào)用存在不確定性),這個時候緩存內(nèi)的值會被更新成100,發(fā)生了緩存和數(shù)據(jù)庫不一致的情況
“通過以上案例可見,解決緩存和數(shù)據(jù)庫數(shù)據(jù)不一致的根本解決方案是需要把兩個操作合并成邏輯上能保證事務(wù)的一個操作
兩個操作看做一個操作
分布式鎖
在平時開發(fā)中,利用分布式鎖可能算是比較常見的解決方案了。利用分布式鎖把緩存操作和數(shù)據(jù)庫操作封裝為邏輯上的一個操作可以保證數(shù)據(jù)的一致性,具體流程為:
每個想要操作緩存和數(shù)據(jù)庫的線程都必須先申請分布式鎖
如果成功獲得鎖,則進(jìn)行數(shù)據(jù)庫和緩存操作,操作完畢釋放鎖
如果沒有獲得鎖,根據(jù)不同業(yè)務(wù)可以選擇阻塞等待或者輪訓(xùn),或者直接返回的策略
image
利用分布式鎖是解決分布式事務(wù)的一種方案,但是在一定程度上會降低系統(tǒng)的性能,而且分布式鎖的設(shè)計要考慮到down機(jī)和死鎖的意外情況,而最常見的分布式鎖就是利用redis,但是也會有不少坑,具體可以參考之前的文章
redis做分布式鎖可能不那么簡單
刪除緩存
相對于分布式鎖的方案,而程序員實際中最喜歡使用的還是刪除緩存的方式,在一個可能會發(fā)生不一致的場景下,我們會以數(shù)據(jù)庫為主,在操作完數(shù)據(jù)庫之后,不去更新緩存,而是刪除緩存。這在一定意義上相當(dāng)于只操作數(shù)據(jù)庫,把需要維護(hù)的兩個數(shù)據(jù)源變成了一個數(shù)據(jù)源。
image
這種方式要求必須先操作數(shù)據(jù)庫,后操作緩存,不然的話發(fā)生不一致的幾率會大很多。為什么這么說呢?因為就算是先操作數(shù)據(jù)庫也會有發(fā)生不一致的幾率,但是畢竟在整個操作過程中,刪除緩存的操作只占整個流程時間的一小部分而已,而且我們可以利用緩存的過期時間來保證數(shù)據(jù)的最終一致性,所以在一些可以容忍數(shù)據(jù)短暫不一致的場景下可以采用這種方案的。
刪除緩存方案帶來的另外一個劣勢是:如果同樣的數(shù)據(jù)會被頻繁更新,緩存會被頻繁刪除,當(dāng)有讀請求的時候又會被頻繁的從數(shù)據(jù)庫加載,所以這種方案適用于那種對緩存命中率不敏感的系統(tǒng)中。
單線程
發(fā)生緩存和數(shù)據(jù)庫不一致的原因在于多個線程的同時操作,如果相同的數(shù)據(jù)始終只會有一個線程去操作,不一致的情況就會避免了,比如nodejs,可以充分利用nodejs單線程的優(yōu)勢。提到單線程不能不提一下Actor模型,actor模型在對于同樣的對象上可以看做是單線程模式,具體有興趣的同學(xué)可以查看之前的推文
分布式高并發(fā)下Actor模型如此優(yōu)秀
單線程的模式基本上和分布式鎖的方案類似,只不過單線程不需要鎖就可以實現(xiàn)操作的順序化,這也是單線程的優(yōu)勢所在。
其他方案
如果是以緩存為主呢?假如我們的應(yīng)用程序只和緩存組件通信,至于持久化數(shù)據(jù)庫由專門的程序負(fù)責(zé),這樣行不行呢?在理論上是可以的
image
不過這種方案需要考慮幾個方面:
數(shù)據(jù)從緩存持久化到數(shù)據(jù)采用什么樣的解決方案,是同步進(jìn)行還是異步進(jìn)行呢?
在新數(shù)據(jù)請求的時候,如果緩存不存在,要采用什么樣的方式來填充數(shù)據(jù)
如果緩存模塊掛掉了該怎么辦?
以緩存為主的方案的優(yōu)勢是數(shù)據(jù)優(yōu)先進(jìn)入IO速度快的設(shè)備,對于那些請求量大,但是可以容忍一定數(shù)據(jù)丟失的應(yīng)用非常合適,比如應(yīng)用log數(shù)據(jù)的收集系統(tǒng),這種系統(tǒng)其中一個最大的特點就是可以容忍一定數(shù)據(jù)的丟失,但是并發(fā)的請求數(shù)會非常大。所以我們就可以利用緩存設(shè)備前置的方案來應(yīng)對這種應(yīng)用場景。