java基礎教學欄位保證快取與資料庫的雙重寫入的一致性
請抬起你的頭,我的公主,不然皇冠會掉下來的。
分散式快取是現在很多分散式應用程式中必不可少的元件,但是用到了分散式緩存,就可能會涉及到快取與資料庫雙儲存雙寫,你只要是雙寫,就一定會有數據一致性的問題,那你該如何解決一致性問題?
Cache Aside Pattern
最經典的快取 資料庫讀寫的模式,就是 Cache Aside Pattern。
讀取的時候,先讀緩存,快取沒有的話,就讀資料庫,然後取出資料後放入緩存,同時回傳回應。
更新的時候,先更新資料庫,然後再刪除快取。
為什麼是刪除緩存,而不是更新快取?
原因很簡單,很多時候,在複雜點的快取場景,快取不單單是資料庫中直接取出的值。
例如可能更新了某個表的一個字段,然後其對應的緩存,是需要查詢另外兩個表的資料並進行運算,才能計算出緩存最新的值的。
另外更新快取的代價有時候是很高的。是不是說,每次修改資料庫的時候,都一定要將其對應的快取更新一份?也許有的場景是這樣,但是對於比較複雜的快取資料計算的場景,就不是這樣了。如果你頻繁修改一個快取涉及的多個表,快取也頻繁更新。但問題在於,這個快取到底會不會被頻繁存取?
舉個栗子,一個快取涉及的表的字段,在1 分鐘內就修改了20 次,或者是100 次,那麼快取更新20 次、100 次;但是這個快取在1 分鐘內只被讀取了1 次,有大量的冷數據。實際上,如果你只是刪除快取的話,那麼在 1 分鐘內,這個快取不過就重新計算一次而已,開銷大幅度降低,用到快取才去算快取。
其實刪除緩存,而不是更新緩存,就是一個lazy 計算的思想,不要每次都重新做複雜的計算,不管它會不會用到,而是讓它到需要被使用的時候再重新計算。像 mybatis,hibernate,都有懶加載思想。查詢一個部門,部門帶了一個員工的 list,沒有必要說每次查詢部門,都裡面的 1000 個員工的資料也同時查出來啊。 80% 的情況,查這個部門,就只是要訪問這個部門的資訊就可以了。先查部門,同時要訪問裡面的員工,那麼這個時候只有在你要訪問裡面的員工的時候,才會去資料庫裡面查詢 1000 個員工。
最初級的快取不一致問題及解決方案
問題:先修改資料庫,再刪除快取。如果刪除快取失敗了,那麼會導致資料庫中是新數據,快取中是舊數據,數據就出現了不一致。
解決想法:先刪除緩存,再修改資料庫。如果資料庫修改失敗了,那麼資料庫中是舊數據,快取中是空的,那麼資料不會不一致。因為讀的時候快取沒有,則讀資料庫中舊數據,然後更新到快取中。
比較複雜的資料不一致問題分析
資料發生了變更,先刪除了緩存,然後要去修改資料庫,此時還沒修改。一個請求過來,去讀緩存,發現緩存空了,去查詢資料庫,查到了修改前的舊數據,放到了緩存中。隨後資料變更的程序完成了資料庫的修改。
完了,資料庫和快取中的資料不一樣了。 。 。
為什麼上億流量高並發場景下,快取會出現這個問題?
只有在對一個資料在並發的進行讀寫的時候,才可能會出現這種問題。其實如果說你的並發量很低的話,特別是讀並發很低,每天訪問量就 1 萬次,那麼很少的情況下,會出現剛才描述的那種不一致的場景。但問題是,如果每天的是上億的流量,每秒並發讀是幾萬,每秒只要有資料更新的請求,就可能會出現上述的資料庫 快取不一致的情況。
解決方案如下:
更新資料的時候,根據資料的唯一標識,將操作路由之後,傳送到一個 jvm 內部佇列。讀取資料的時候,如果發現資料不在快取中,那麼將重新讀取資料 更新快取的操作,根據唯一識別路由之後,也發送同一個 jvm 內部佇列中。
一個佇列對應一個工作線程,每個工作線程串列拿到對應的操作,然後一條一條的執行。這樣的話,一個資料變更的操作,先刪除緩存,然後再去更新資料庫,但還沒完成更新。此時如果一個讀取請求過來,讀到了空的緩存,那麼可以先將緩存更新的請求發送到隊列中,此時會在隊列中積壓,然後同步等待緩存更新完成。
這裡有一個最佳化點,一個佇列中,其實多個更新快取請求串在一起是沒意義的,因此可以做過濾,如果發現佇列中已經有一個更新快取的請求了,那麼就不用再放個更新請求操作進去了,直接等待前面的更新操作請求完成即可。
待那個佇列對應的工作執行緒完成了上一個操作的資料庫的修改之後,才會去執行下一個操作,也就是快取更新的操作,此時會從資料庫中讀取最新的值,然後寫入快取中。
如果請求還在等待時間範圍內,不斷輪詢發現可以取到值了,那麼就直接返回;如果請求等待的時間超過一定時長,那麼這次直接從資料庫中讀取當前的舊值。
高並發的場景下,該解決方案要注意的問題:
由於讀取請求進行了非常輕度的非同步化,所以一定要注意讀取超時的問題,每個讀取請求必須在超時時間範圍內回傳。
該解決方案,最大的風險點在於說,可能資料更新很頻繁,導致佇列中積壓了大量更新操作在裡面,然後讀取請求會發生大量的超時,最後導致大量的請求直接走資料庫.務必通過一些模擬真實的測試,看看更新資料的頻率是怎麼樣的。
另外一點,因為一個佇列中,可能會積壓針對多個資料項目的更新操作,因此需要根據自己的業務情況進行測試,可能需要部署多個服務,每個服務分攤一些資料的更新操作。如果一個記憶體佇列裡居然會擠壓100 個商品的庫存修改操作,每隔庫存修改操作要耗費10ms 去完成,那麼最後一個商品的讀請求,可能等待10 * 100 = 1000ms = 1s 後,才能得到數據,這個時候就導致讀取請求的長時阻塞。
一定要做根據實際業務系統的運行情況,去進行一些壓力測試,和模擬線上環境,去看看最繁忙的時候,內存隊列可能會擠壓多少更新操作,可能會導致最後一個更新操作對應的讀取請求,會hang 多少時間,如果讀取請求在200ms 返回,如果你計算過後,哪怕是最繁忙的時候,積壓10 個更新操作,最多等待200ms,那還可以的。
如果一個記憶體佇列中可能積壓的更新操作特別多,那麼你就要加機器,讓每個機器上部署的服務實例處理更少的數據,那麼每個記憶體佇列中積壓的更新操作就會越少。
其實根據先前的專案經驗,一般來說,資料的寫頻率是很低的,因此實際上正常來說,在佇列中積壓的更新操作應該是很少的。像這種針對讀高並發、讀取快取架構的項目,一般來說寫請求是非常少的,每秒的 QPS 能到幾百就不錯了。
實際粗略測算一下
如果一秒有500 的寫入操作,如果分成5 個時間片,每200ms 就100 個寫入操作,放到20 個記憶體佇列中,每個記憶體佇列,可能就積壓5 個寫入操作。每個寫入操作效能測試後,一般是在 20ms 左右就完成,那麼針對每個記憶體佇列的資料的讀取請求,也就最多 hang 一會兒,200ms 以內肯定能返回了。
經過剛才簡單的測算,我們知道,單機支撐的寫QPS 在幾百是沒問題的,如果寫QPS 擴大了10 倍,那麼就擴容機器,擴容10 倍的機器,每個機器20 個隊列。
這裡也必須做好壓力測試,確保剛好碰上上述情況的時候,還有一個風險,就是突然間大量讀取請求會在幾十毫秒的延時hang 在服務上,看服務能不能扛的住,需要多少機器才能扛住最大的極限情況的峰值。
但是因為不是所有的資料都在同一時間更新,快取也不會同一時間失效,所以每次可能也就是少數資料的快取失效了,然後那些資料對應的讀取請求過來,並發量應該也不會特別大。
可能這個服務部署了多個實例,那麼必須保證說,執行資料更新操作,以及執行快取更新操作的請求,都透過Nginx 伺服器路由到相同的服務實例。
比如說,對同一個商品的讀寫請求,全部路由到同一台機器上。可以自己去做服務間的依照某個請求參數的 hash 路由,也可以用 Nginx 的 hash 路由功能等等。
萬一某個商品的讀寫請求特別高,全部打到相同的機器的相同的隊列裡面去了,可能會造成某台機器的壓力過大。就是說,因為只有在商品資料更新的時候才會清空緩存,然後才會導致讀寫並發,所以其實要根據業務系統去看,如果更新頻率不是太高的話,這個問題的影響並不是特別大,但是的確可能某些機器的負載會高一些。
以上是java實作保證快取與資料庫的雙寫的一致性的詳細內容。更多資訊請關注PHP中文網其他相關文章!