1. 程式人生 > 實用技巧 >如何保證快取與資料庫的資料一致性

如何保證快取與資料庫的資料一致性

1 面試題

如何保證快取與資料庫的雙寫一致性?

2 考點分析

你只要用快取,就可能會涉及到快取與資料庫雙儲存雙寫,你只要是雙寫,就一定會有資料一致性的問題,那麼你如何解決一致性問題?

3 詳解

一般來說,就是如果你的系統不是嚴格要求快取+資料庫必須一致性的話,快取可以稍微的跟資料庫偶爾有不一致的情況,最好不要做這個方案

讀請求和寫請求序列化,串到一個記憶體佇列裡去,這樣就可以保證一定不會出現不一致的情況

序列化之後,就會導致系統的吞吐量會大幅度的降低,用比正常情況下多幾倍的機器去支撐線上的一個請求。

3.1 Cache Aside Pattern快取+資料庫讀寫模式的分析

最經典的快取+資料庫讀寫的模式 cache aside pattern

3.1.1 Cache Aside Pattern

(1)讀的時候,先讀快取,快取沒有的話,就讀資料庫,然後取出資料後放入快取,同時返回響應

(2)更新的時候,先刪除快取,然後再更新資料庫

  • cache aside pattern
    3.1.2 為什麼是刪除快取,而不是更新快取呢?很多時候,複雜點的快取的場景,因為快取有的時候,不單是資料庫中直接取出來的值

商品詳情頁的系統,修改庫存,只是修改了某個表的某些欄位,但是要真正把這個影響的最終的庫存計算出來,可能還需要從其他表查詢一些資料,然後進行一些複雜的運算,才能最終計算出

現在最新的庫存是多少,然後才能將庫存更新到快取中去

比如可能更新了某個表的一個欄位,然後其對應的快取,是需要查詢另外兩個表的資料,並運算,才能計算出快取最新的值的

更新快取的代價是很高的

是不是說,每次修改資料庫的時候,都一定要將其對應的快取去更新一份?

也許有的場景是這樣的,但是對於比較複雜的快取資料計算的場景,就不是這樣了

如果你頻繁修改一個快取涉及的多個表,那麼這個快取會被頻繁的更新,頻繁的更新快取

但是問題在於,這個快取到底會不會被頻繁訪問到???

舉個例子,一個快取涉及的表的欄位,在1分鐘內就修改了20次,或者是100次,那麼快取更新20次,100次; 但是這個快取在1分鐘內就被讀取了1次,有大量的冷資料

28法則,黃金法則,20%的資料,佔用了80%的訪問量

實際上,如果你只是刪除快取的話,那麼1分鐘內,這個快取不過就重新計算一次而已,開銷大幅度降低

每次資料過來,就只是刪除快取,然後修改資料庫,如果這個快取,在1分鐘內只是被訪問了1次,那麼只有那1次,快取是要被重新計算的,用快取才去算快取

其實刪除快取,而不是更新快取,就是一個惰性延遲計算的思想,不要每次都重新做複雜的計算,不管它會不會用到,而是讓它到需要被使用的時候再重新計算

mybatis,hibernate,懶載入,思想

查詢一個部門,部門帶了一個員工的list,沒有必要說每次查詢部門,都裡面的1000個員工的資料也同時查出來啊

80%的情況,查這個部門,就只是要訪問這個部門的資訊就可以了

先查部門,同時要訪問裡面的員工,那麼這個時候只有在你要訪問裡面的員工的時候,才會去資料庫裡面查詢1000個員工

更多快取設計模式請閱讀

大行快取更新之道.md

3.2 高併發場景下的快取+資料庫雙寫不一致問題分析與解決方案設計

開發業務系統

從哪一步開始做,從比較簡單的那塊開始做,實時性要求比較高的那塊資料的快取去做

實時性比較高的資料快取,就是庫存的服務

庫存可能會修改,每次修改都要去更新這個快取資料; 每次庫存的資料,在快取中一旦過期,或者是被清理掉了,前端的nginx服務都會發送請求給庫存服務,去獲取相應的資料

庫存這一塊,寫資料庫的時候,直接更新redis快取

實際上沒有這麼的簡單,這裡,其實就涉及到了一個問題

資料庫與快取雙寫,資料不一致的問題

圍繞和結合實時性較高的庫存服務,把資料庫與快取雙寫不一致問題以及其解決方案,給大家講解一下

資料庫與快取雙寫不一致,很常見的問題,大型的快取架構中,第一個解決方案

也可能說,有些方案只是適合某些場景,在某些場景下,可能需要你進行方案的優化和調整才能適用於你自己的專案

3.2.1 最初級的快取不一致問題以及解決方案

Q:先修改資料庫,再刪除快取,如果快取刪除失敗,那麼會導致資料庫中是新資料,快取中是舊資料,資料出現不一致!

  • 最初級的資料庫+快取雙寫不一致問題

A:先刪除快取,再修改資料庫,如果刪除快取成功了,如果修改資料庫失敗了,那麼資料庫中是舊資料,快取中是空的,那麼資料不會不一致

因為讀的時候快取沒有,則讀資料庫中舊資料,然後更新到快取中

3.2.2 比較複雜的資料不一致問題分析

資料發生了變更,先刪除了快取,然後要去修改資料庫,此時還沒修改

一個請求過來,去讀快取,發現快取空了,去查詢資料庫,查到了修改前的舊資料,放到了快取中

資料變更的程式完成了資料庫的修改

完了,資料庫和快取中的資料不一樣了。。。。

3.2.3 為什麼上億流量高併發場景下,快取會出現這個問題?

只有在對一個數據在併發讀寫時,才可能會出現這種問題

其實如果說你的併發量很低的話,特別是讀很低,每天訪問量就1萬次,那麼很少會出現剛才描述的那種不一致的場景

但問題是,如果每天的是上億的流量,每秒併發讀是幾萬,每秒只要有資料更新的請求,就可能會出現上述的資料庫+快取不一致的情況

高併發了以後,問題是很多的

3.2.4 資料庫 & 快取更新與讀取 非同步序列化

更新資料的時候,根據資料的唯一標識,將操作路由之後,傳送到一個JVM內部的佇列中

讀資料的時候,如果發現數據不在快取中,那麼將重讀資料+更新快取,根據唯一標識路由之後,也傳送同一個JVM內部的佇列中

一個佇列對應一個工作執行緒

每個工作執行緒序列拿到對應的操作,然後一條一條的執行

這樣的話,一個數據變更的操作,先執行刪除快取,然後再更新資料庫,但是還沒完成更新

此時如果一個讀請求過來,讀到了空快取,則可以先將快取更新的請求傳送到佇列中,此時會在佇列中積壓,然後同步等待快取更新完成

這裡有一個優化點,一個佇列中,其實多個更新快取請求串在一起是沒意義的,因此可以做過濾,如果發現佇列中已經有一個更新快取的請求了,那麼就不用再放個更新請求操作進去了,直接等待前面的更新操作請求完成即可

待那個佇列對應的工作執行緒完成了上一個操作的資料庫的修改之後,才會去執行下一個操作,也就是快取更新的操作,此時會從資料庫中讀取最新的值,然後寫入快取中

  • 如果請求還在等待時間範圍內,輪詢發現可以取到值了,那麼就直接返回
  • 如果請求等待的時間超過一定時長,那麼這一次直接從資料庫中讀取當前的舊值

3.2.5 高併發的場景下,該解決方案要注意的問題

(1)讀請求長時阻塞

由於讀請求進行了非常輕度的非同步化,所以一定要注意讀超時的問題,每個讀請求必須在超時時間範圍內返回

該解決方案,最大的風險點在於,可能資料更新很頻繁,導致佇列中積壓了大量更新操作,然後讀請求會發生大量的超時,最後導致大量的請求直接走資料庫

務必通過一些模擬真實的測試,看看更新資料的頻繁是怎樣的

另外一點,因為一個佇列中,可能會積壓針對多個數據項的更新操作,因此需要根據自己的業務情況進行測試,可能需要部署多個服務,每個服務分攤一些資料的更新操作

如果一個記憶體佇列里居然會擠壓100個商品的庫存修改操作,每隔庫存修改操作要耗費10ms區完成,那麼最後一個商品的讀請求,可能等待10 * 100 = 1000ms = 1s後,才能得到資料

這個時候就導致讀請求的長時阻塞

一定要做根據實際業務系統的執行情況,去進行一些壓力測試,和模擬線上環境,去看看最繁忙的時候,記憶體佇列可能會擠壓多少更新操作,可能會導致最後一個更新操作對應的讀請求,會hang多少時間,如果讀請求在200ms返回,如果你計算過後,哪怕是最繁忙的時候,積壓10個更新操作,最多等待200ms,那還可以的

如果一個記憶體佇列可能積壓的更新操作特別多,那麼你就要加機器,讓每個機器上部署的服務例項處理更少的資料,那麼每個記憶體佇列中積壓的更新操作就會越少

其實根據之前的專案經驗,一般來說資料的寫頻率是很低的,因此實際上正常來說,在佇列中積壓的更新操作應該是很少的

針對讀高併發,讀快取架構的專案,一般寫請求相對讀來說,是非常非常少的,每秒的QPS能到幾百就不錯了

一秒,500的寫操作,5份,每200ms,就100個寫操作

單機器,20個記憶體佇列,每個記憶體佇列,可能就積壓5個寫操作,每個寫操作效能測試後,一般在20ms左右就完成

那麼針對每個記憶體佇列中的資料的讀請求,也就最多hang一會兒,200ms以內肯定能返回了

寫QPS擴大10倍,但是經過剛才的測算,就知道,單機支撐寫QPS幾百沒問題,那麼就擴容機器,擴容10倍的機器,10臺機器,每個機器20個佇列,200個佇列

大部分的情況下,應該是這樣的,大量的讀請求過來,都是直接走快取取到資料的

少量情況下,可能遇到讀跟資料更新衝突的情況,如上所述,那麼此時更新操作如果先入佇列,之後可能會瞬間來了對這個資料大量的讀請求,但是因為做了去重的優化,所以也就一個更新快取的操作跟在它後面

等資料更新完了,讀請求觸發的快取更新操作也完成,然後臨時等待的讀請求全部可以讀到快取中的資料

(2)讀請求併發量過高

這裡還必須做好壓力測試,確保恰巧碰上上述情況的時候,還有一個風險,就是突然間大量讀請求會在幾十毫秒的延時hang在服務上,看服務能不能抗的住,需要多少機器才能抗住最大的極限情況的峰值

但是因為並不是所有的資料都在同一時間更新,快取也不會同一時間失效,所以每次可能也就是少數資料的快取失效了,然後那些資料對應的讀請求過來,併發量應該也不會特別大

按1:99的比例計算讀和寫的請求,每秒5萬的讀QPS,可能只有500次更新操作

如果一秒有500的寫QPS,那麼要測算好,可能寫操作影響的資料有500條,這500條資料在快取中失效後,可能導致多少讀請求,傳送讀請求到庫存服務來,要求更新快取

一般來說,1:1,1:2,1:3,每秒鐘有1000個讀請求,會hang在庫存服務上,每個讀請求最多hang多少時間,200ms就會返回

在同一時間最多hang住的可能也就是單機200個讀請求,同時hang住

單機hang200個讀請求,還是ok的

1:20,每秒更新500條資料,這500秒資料對應的讀請求,會有20 * 500 = 1萬

1萬個讀請求全部hang在庫存服務上,就死定了

(3)多服務例項部署的請求路由

可能這個服務部署了多個例項,那麼必須保證說,執行資料更新操作,以及執行快取更新操作的請求,都通過nginx伺服器路由到相同的服務例項上

(4)熱點商品的路由問題,導致請求的傾斜

萬一某個商品的讀寫請求特別高,全部打到相同的機器的相同的佇列裡面去了,可能造成某臺機器的壓力過大

就是說,因為只有在商品資料更新的時候才會清空快取,然後才會導致讀寫併發,所以更新頻率不是太高的話,這個問題的影響並不是特別大

但是的確可能某些機器的負載會高一些

更多內容請關注JavaEdge公眾號

參考

《Java工程師面試突擊第1季-中華石杉老師》


作者:JavaEdge
連結:http://www.imooc.com/article/289076
來源:慕課網

還有更新快取?兩個寫請求,你快取和資料庫操作又不是原子性,完全可能出現舊資料快取覆蓋新資料,提倡先更庫,後刪快取,為防止刪除失敗,可以使用延時雙刪,再不行就用訊息佇列