深入理解緩存之緩存和數據庫的一致性

產生原因

主要有兩種情況,會導致緩存和 DB 的一致性問題:

  1. 併發的場景下,導致讀取老的 DB 數據,更新到緩存中。

  2. 緩存和 DB 的操作,不在一個事務中,可能只有一個操作成功,而另一個操作失敗,導致不一致。

當然,有一點我們要注意,緩存和 DB 的一致性,我們指的更多的是最終一致性。我們使用緩存只要是提高讀操作的性能,真正在寫操作的業務邏輯,還是以數據庫爲準。例如說,我們可能緩存用戶錢包的餘額在緩存中,在前端查詢錢包餘額時,讀取緩存,在使用錢包餘額時,讀取數據庫。

更新緩存的設計模式

1.Cache Aside Pattern(旁路緩存)

這是最常用最常用的pattern了。其具體邏輯如下:

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

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

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

一個是查詢操作,一個是更新操作的併發,首先,沒有了刪除cache數據的操作了,而是先更新了數據庫中的數據,此時,緩存依然有效,所以,併發的查詢操作拿的是沒有更新的數據,但是,更新操作馬上讓緩存的失效了,後續的查詢操作再把數據從數據庫中拉出來。而不會像文章開頭的那個邏輯產生的問題,後續的查詢操作一直都在取老的數據。

要麼通過2PC或是Paxos協議保證一致性,要麼就是拼命的降低併發時髒數據的概率,而Facebook使用了這個降低概率的玩法,因爲2PC太慢,而Paxos太複雜。當然,最好還是爲緩存設置上過期時間。

2.Read/Write Through Pattern

在上面的Cache Aside套路中,我們的應用代碼需要維護兩個數據存儲,一個是緩存(Cache),一個是數據庫(Repository)。所以,應用程序比較囉嗦。而Read/Write Through套路是把更新數據庫(Repository)的操作由緩存自己代理了,所以,對於應用層來說,就簡單很多了。可以理解爲,應用認爲後端就是一個單一的存儲,而存儲自己維護自己的Cache。

Read Through

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

Write Through

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

下圖自來Wikipedia的Cache詞條。其中的Memory你可以理解爲就是我們例子裏的數據庫。

3.Write Behind Caching Pattern

Write Behind 又叫 Write Back。write back就是Linux文件系統的Page Cache的算法

Write Back套路,一句說就是,在更新數據的時候,只更新緩存,不更新數據庫,而我們的緩存會異步地批量更新數據庫。

這個設計的好處就是讓數據的I/O操作飛快無比(因爲直接操作內存嘛 ),因爲異步,write backg還可以合併對同一個數據的多次操作,所以性能的提高是相當可觀的。

但是,其帶來的問題是,數據不是強一致性的,而且可能會丟失(我們知道Unix/Linux非正常關機會導致數據丟失,就是因爲這個事)。在軟件設計上,我們基本上不可能做出一個沒有缺陷的設計,就像算法設計中的時間換空間,空間換時間一個道理,有時候,強一致性和高性能,高可用和高性性是有衝突的。軟件設計從來都是取捨Trade-Off。

另外,Write Back實現邏輯比較複雜,因爲他需要track有哪數據是被更新了的,需要刷到持久層上。操作系統的write back會在僅當這個cache需要失效的時候,纔會被真正持久起來,比如,內存不夠了,或是進程退出了等情況,這又叫lazy write。

在wikipedia上有一張write back的流程圖,基本邏輯如下:

參照:左耳朵耗子《緩存更新的套路》

緩存架構設計:

1.更新緩存 VS 淘汰緩存

更新緩存:數據不但寫入數據庫,還會寫入緩存;優點:緩存不會增加一次miss,命中率高

淘汰緩存:數據只會寫入數據庫,不會寫入緩存,只會把數據淘汰掉;優點:簡單

這兩者的選擇主要取決於“更新緩存的複雜度”。

例如,上述場景,只是簡單的把餘額money設置成一個值,那麼:

(1)淘汰緩存的操作爲deleteCache(uid)

(2)更新緩存的操作爲setCache(uid, money)

更新緩存的代價很小,此時我們應該更傾向於更新緩存,以保證更高的緩存命中率

如果餘額是通過很複雜的數據計算得出來的,例如業務上除了賬戶表account,還有商品表product,折扣表discount

account(uid, money)

product(pid, type, price, pinfo)

discount(type, zhekou)

業務場景是用戶買了一個商品product,這個商品的價格是price,這個商品從屬於type類商品,type類商品在做促銷活動要打折扣zhekou,購買了商品過後,這個餘額的計算就複雜了,需要:

(1)先把商品的品類,價格取出來:SELECT type, price FROM product WHERE pid=XXX

(2)再把這個品類的折扣取出來:SELECT zhekou FROM discount WHERE type=XXX

(3)再把原有餘額從緩存中查詢出來money = getCache(uid)

(4)再把新的餘額寫入到緩存中去setCache(uid, money-price*zhekou)

更新緩存的代價很大,此時我們應該更傾向於淘汰緩存。

總之,淘汰緩存操作簡單,並且帶來的副作用只是增加了一次cache miss,建議作爲通用的處理方式。

2.先操作數據庫 vs 先操作緩存

當寫操作發生時,假設淘汰緩存作爲對緩存通用的處理方式,又面臨兩種抉擇:

(1)先寫數據庫,再淘汰緩存

(2)先淘汰緩存,再寫數據庫

對於一個不能保證事務性的操作,一定涉及“哪個任務先做,哪個任務後做”的問題,解決這個問題的方向是:如果出現不一致,誰先做對業務的影響較小,就誰先執行。

由於寫數據庫與淘汰緩存不能保證原子性,誰先誰後同樣要遵循上述原則。

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

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

結論:數據和緩存的操作時序:先淘汰緩存,再寫數據庫。

3.緩存架構優化

上述緩存架構有一個缺點:業務方需要同時關注緩存與DB,主要有兩種優化方案:

一種方案是服務化:加入一個服務層,向上遊提供帥氣的數據訪問接口,向上遊屏蔽底層數據存儲的細節,這樣業務線不需要關注數據是來自於cache還是DB。

另一種方案是異步緩存更新:業務線所有的寫操作都走數據庫,所有的讀操作都總緩存,由一個異步的工具來做數據庫與緩存之間數據的同步,具體細節是:

(1)要有一個init cache的過程,將需要緩存的數據全量寫入cache

(2)如果DB有寫操作,異步更新程序讀取binlog,更新cache

在(1)和(2)的合作下,cache中有全部的數據,這樣:

(a)業務線讀cache,一定能夠hit(很短的時間內,可能有髒數據),無需關注數據庫

(b)業務線寫DB,cache中能得到異步更新,無需關注緩存

這樣將大大簡化業務線的調用邏輯,存在的缺點是,如果緩存的數據業務邏輯比較複雜,async-update異步更新的邏輯可能也會比較複雜。

4.結論:

(1)淘汰緩存是一種通用的緩存處理方式

(2)先淘汰緩存,再寫數據庫

(3)服務化是向業務方屏蔽底層數據庫與緩存複雜性的一種通用方式

參照:沈劍《緩存架構設計細節二三事》

緩存和DB一致性的解決方案

1)先淘汰緩存,再寫數據庫

因爲先淘汰緩存,所以數據的最終一致性是可以得到有效的保證的。因爲先淘汰緩存,即使寫數據庫發生異常,也就是下次緩存讀取時,多讀取一次數據庫。

但是,這種方案會存在緩存和 DB 的數據會不一致的情況,參照《緩存與數據庫一致性優化》 所說。

我們需要解決緩存並行寫,實現串行寫。比較簡單的方式,引入分佈式鎖。

  • 在寫請求時,先淘汰緩存之前,獲取該分佈式鎖。

  • 在讀請求時,發現緩存不存在時,先獲取分佈式鎖。

這樣,緩存的並行寫就成功的變成串行寫落。寫請求時,是否主動更新緩存,根據自己業務的需要,是否有,都沒問題。

2)先寫數據庫,再更新緩存

按照“先寫數據庫,再更新緩存”,我們要保證 DB 和緩存的操作,能夠在“同一個事務”中,從而實現最終一致性。

基於定時任務來實現

  • 首先,寫入數據庫。

  • 然後,在寫入數據庫所在的事務中,插入一條記錄到任務表。該記錄會存儲需要更新的緩存 KEY 和 VALUE 。

  • 【異步】最後,定時任務每秒掃描任務表,更新到緩存中,之後刪除該記錄。

基於消息隊列來實現

  • 首先,寫入數據庫。

  • 然後,發送帶有緩存 KEY 和 VALUE 的事務消息。此時,需要有支持事務消息特性的消息隊列,或者我們自己封裝消息隊列,支持事務消息。

  • 【異步】最後,消費者消費該消息,更新到緩存中。

這兩種方式,可以進一步優化,可以先嚐試更新緩存,如果失敗,則插入任務表,或者事務消息。

另外,極端情況下,如果併發寫執行時,先更新成功 DB 的,結果後更新緩存:

 

  • 理論來說,希望的更新緩存順序是,線程 1 快於線程 2 ,但是實際線程1 晚於線程 2 ,導致數據不一致。

  • 圖中一直是基於定時任務或消息隊列來實現異步更新緩存,如果網絡抖動,導致【插入任務表,或者事務消息】的順序不一致。

  • 那麼怎麼解決呢?需要做如下三件事情:

    • 1、在緩存值中,拼接上數據版本號或者時間戳。例如說:value = {value: 原值, version: xxx}

    • 2、在任務表的記錄,或者事務消息中,增加上數據版本號或者時間戳的字段。

    • 3、在定時任務或消息隊列執行更新緩存時,先讀取緩存,對比版本號或時間戳,大於才進行更新。 當然,此處也會有併發問題,所以還是得引入分佈式鎖或 CAS 操作。

3) 基於數據庫的 binlog 日誌

1、重客戶端

寫入緩存:

  • 應用同時更新數據庫和緩存

  • 如果數據庫更新成功,則開始更新緩存,否則如果數據庫更新失敗,則整個更新過程失敗。

  • 判斷更新緩存是否成功,如果成功則返回

  • 如果緩存沒有更新成功,則將數據發到MQ中

  • 應用監控MQ通道,收到消息後繼續更新Redis。

問題點:如果更新Redis失敗,同時在將數據發到MQ之前的時間,應用重啓了,這時候MQ就沒有需要更新的數據,如果Redis對所有數據沒有設置過期時間,同時在讀多寫少的場景下,只能通過人工介入來更新緩存。

讀緩存:

如何來解決這個問題?那麼在寫入Redis數據的時候,在數據中增加一個時間戳插入到Redis中。在從Redis中讀取數據的時候,首先要判斷一下當前時間有沒有過期,如果沒有則從緩存中讀取,如果過期了則從數據庫中讀取最新數據覆蓋當前Redis數據並更新時間戳。具體過程如下圖所示:

2、客戶端數據庫與緩存解耦

上述方案對於應用的研發人員來講比較重,需要研發人員同時考慮數據庫和Redis是否成功來做不同方案,如何讓研發人員只關注數據庫層面,而不用關心緩存層呢?請看下圖:

  • 應用直接寫數據到數據庫中。

  • 數據庫更新binlog日誌。

  • 利用Canal中間件讀取binlog日誌。

  • Canal藉助於限流組件按頻率將數據發到MQ中。

  • 應用監控MQ通道,將MQ的數據更新到Redis緩存中。

可以看到這種方案對研發人員來說比較輕量,不用關心緩存層面,而且這個方案雖然比較重,但是卻容易形成統一的解決方案。

參照: 《技術專題討論第五期:論系統架構設計中緩存的重要性》

PS:下面這兩種比較實用

  • 先淘汰緩存,再寫數據庫”的方案,並且無需引入分佈式鎖。

  • 先寫數據庫,再更新緩存”的方案,並且無需引入定時任務或者消息隊列。

使用緩存過程中,經常會遇到緩存數據的不一致性和髒讀現象。一般情況下,採取緩存雙淘汰機制,在更新數據庫的淘汰緩存。此外,設定超時時間,例如三十分鐘。

極端場景下,即使有髒數據進入緩存,這個髒數據也最存在一段時間後自動銷燬。

另外,在 DB 主從架構下,方案會更加複雜。詳細可以看看 《主從 DB 與 cache 一致性優化》

 

 

 

 

 

 

 

 

 

 

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