關於如何更新緩存的探討

寫這篇文章的原因

現在我們的系統都需要使用緩存提高性能,使用緩存就需要對緩存進行維護,那麼當數據發生變化時我們應該先操作緩存還是先操作數據庫呢?網上有兩篇很好的文章,一篇是來自58沈劍的架構師之路系列之緩存架構設計緩存架構設計,一篇來自於左耳朵耗子陳皓的緩存更新的套路,兩位老師給出了很好的分析,這裏分別總結一下,希望能對看過的同學有所幫助。

架構師之路

先來說一下沈劍老師的文章。

當數據庫執行更新操作時,我們會進行緩存的淘汰,由於操作緩存與操作數據庫並不能保證原子性,所以解題思路就是:如果出現不一致,誰先做對業務的影響較小,就誰先執行。分別分析下

先寫數據庫的情況:第一步寫數據庫操作成功,第二步淘汰緩存失敗,則會出現DB中是新數據,Cache中是舊數據,數據不一致。

先淘汰緩存的情況:第一步淘汰緩存成功,第二步寫數據庫失敗,則只會引發一次Cache miss。

所以結論是:先淘汰緩存,再寫數據庫

陳皓-酷殼

剛開始看的時候讓我驚訝的是,陳皓老師文章開篇就指出了先淘汰緩存再更新數據庫的做法是錯誤的。

給出的理由如下:兩個併發操作,一個是更新操作,另一個是查詢操作,更新操作刪除緩存後,查詢操作沒有命中緩存,先把老數據讀出來後放到緩存中,然後更新操作更新了數據庫。於是,在緩存中的數據還是老的數據,導致緩存中的數據是髒的,而且還一直這樣髒下去了(好像沒毛病)

接下來列舉了幾個常用的緩存模式

首先是Cache aside

以下是對Cache aside幾種緩存狀態的處理:

失效:應用程序先從cache取數據,沒有得到,則從數據庫中取數據,成功後,放到緩存中。

命中:應用程序從cache中取數據,取到後返回。

更新:先把數據存到數據庫中,成功後,再讓緩存失效。

這個更新操作就不會發生陳皓老師開篇時提到的問題。舉個🌰,一個查詢操作和一個更新操作的併發,首先,沒有了刪除cache數據的操作了,而是先更新了數據庫中的數據,此時,緩存依然有效,所以,併發的查詢操作拿的是沒有更新的數據,但是,更新操作馬上讓緩存的失效了,後續的查詢操作再把數據從數據庫中拉出來。這樣後續的查詢操作就會拉取最新的數據。

並且陳皓老師也指出,Facebook的論文《Scaling Memcache at Facebook》也使用了這個策略。這樣做的目的主要是避免兩個併發的寫操作導致髒數據。

但是隨後又指出這個模式也會出現不一致的情況,舉個🌰,一個是讀操作,但是沒有命中緩存,然後就到數據庫中取數據,此時來了一個寫操作,寫完數據庫後,讓緩存失效,然後,之前的那個讀操作再把老的數據放進去,所以,會造成髒數據。這個case理論上會出現,不過出現的概率可能非常低,因爲這個條件需要發生在讀緩存時緩存失效,而且併發着有一個寫操作。而實際上數據庫的寫操作會比讀操作慢得多,而且還要鎖表,而讀操作必需在寫操作前進入數據庫操作,而又要晚於寫操作更新緩存,所有的這些條件都具備的概率基本並不大。

所以使用先操作數據庫後操作緩存的方法會大大降低併發時髒數據的概率,並且爲了儘量避免上文的低概率事件,最好爲緩存設置過期時間。

這裏陳皓老師得出了與沈劍老師相反的結論,陳皓老師在文章的末尾給出了答案“上面,我們沒有考慮緩存(Cache)和持久層(Repository)的整體事務的問題”,假設原子性得以保障(可以使用2PC,3PC,Paxos等算法進行保障),那麼先操作數據庫則是最優的選擇。兩位老師的結論先放一邊,繼續。

陳皓老師又給我們開了點小竈,介紹了其他常用的緩存模式。

Read/Write Through Pattern

Read Through 套路就是在查詢操作中更新緩存,也就是說,當緩存失效的時候(過期或LRU換出),Cache Aside是由調用方負責把數據加載入緩存,而Read Through則用緩存服務自己來加載,從而對應用方是透明的。

Write Through 套路和Read Through相仿,不過是在更新數據時發生。當有數據更新的時候,如果沒有命中緩存,直接更新數據庫,然後返回。如果命中了緩存,則更新緩存,然後再由Cache自己更新數據庫(這是一個同步操作)

Write Behind Caching Pattern

Write Behind 又叫 Write Back。Write Back一句說就是,在更新數據的時候,只更新緩存,不更新數據庫,而我們的緩存會異步地批量更新數據庫。這個設計的好處就是讓數據的I/O操作飛快無比,因爲異步,write backg還可以合併對同一個數據的多次操作,所以性能的提高是相當可觀的。

但是,其帶來的問題是,數據不是強一致性的,而且可能會丟失(我們知道Unix/Linux非正常關機會導致數據丟失,就是因爲這個事)。另外,Write Back實現邏輯比較複雜,因爲他需要track有哪數據是被更新了的,需要刷到持久層上。操作系統的write back會在僅當這個cache需要失效的時候,纔會被真正持久起來,比如,內存不夠了,或是進程退出了等情況,這又叫lazy write。

轉折

本來看到這裏我本以爲沈劍老師沒有考慮到併發讀寫的問題,導致文章出了紕漏,直到我看到了他的第二篇文章數據與緩存一致性優化

文章開頭給出了讀寫併發時導致數據不一致的case,同陳皓老師舉的🌰一樣,就不多說了。不過文章後半部分對於先操作緩存,後操作數據庫的做法給出了優化。

讓同一個數據的訪問能串行化

在一個服務中如何做到“讓同一個數據的訪問串行化”,只需要“讓同一個數據的訪問通過同一條DB連接執行”就行。如何做到“讓同一個數據的訪問通過同一條DB連接執行”,只需要“在DB連接池層面稍微修改,按數據取連接即可”。將從連接池獲取數據庫連接的操作修改爲CPool.GetDBConnection(longid)【返回id取模相關聯的DB連接】。

當有多份服務時,方案同上,想辦法讓對同一數據的訪問落在同一服務上即可。同樣CPool.GetServiceConnection(longid)【返回id取模相關聯的Service連接】。

總結一下:

(1)修改服務Service連接池,id取模選取服務連接,能夠保證同一個數據的讀寫都落在同一個後端服務上

(2)修改數據庫DB連接池,id取模選取DB連接,能夠保證同一個數據的讀寫在數據庫層面是串行的

自己的一些思考

總結完了兩位老師的文章,最後是自己的一些感悟與思考。

因爲我上學的時候就已經看過沈劍老師的第一篇文章,當時看完有種豁然開朗,吊吊吊的感覺,從那以後就一直把先操作緩存後更新數據的做法當做了最標準的做法(實際上工作之後發現項目裏也基本都是這樣做的)。直到有一天看到了酷殼上陳皓老師的文章,和我認爲的“標準做法”完全相反啊,這是怎麼回事?後來經過對文章的仔細閱讀才理清楚,看到了沈劍老師的第二篇文章也才明白第一篇文章只是個上集,原來還有下集。總結一點,學知識不能快餐文化,也不能“逆來順受”,更不能“淺嘗輒止”,我們需要有自己的思考,需要自己的總結。(寫博客就是挺好的一種總結方式)

最後關於緩存更新倆種方案該選擇哪一種,我認爲,如果系統併發量較小,那麼選擇先淘汰緩存的做法(不做後續連接取模等操作)是比較好的。如果併發量較大,並且緩存系統做了集羣,網絡極少發生抖動(也就是極大程度可以保證原子性),那麼選擇先操作數據庫後操作緩存的做法較好。而關於做連接取模與使用2PC等方案保證數據一致性,個人感覺沒有必要,徒增複雜性,因爲涉及庫存等重要的數據操作無論如何最後都要查詢真實的DB,給緩存數據設置過期時間減少不一致發生的概率與存在時間即可。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章