快取一致性最佳實踐

得物技術發表於2022-01-12

背景

概述

最近團隊裡我們在密集的討論Redis快取一致性相關的問題,電商核心的域如商品、營銷、庫存、訂單等實際上在快取的選擇上各有特色,那麼在這些差異的業務背後,我們有沒有一些最佳實踐可供參考呢?本文嘗試著來討論這個問題,並給出一些建議。
在討論之前,有兩個重點我們需要達成一致:

  1. 分散式場景下無法做到強一致:不同於CPU硬體快取體系採用的MESI協議以及硬體的強時鐘控制,分散式場景下我們無法做到快取與底層資料庫的強一致,即把快取和資料庫的資料變更做成一個原子操作。硬體工程師設計了記憶體屏障(Memory Barrier)的概念,提供給軟體開發者不同的一致性選項在效能與一致性上進行權衡。
  2. 就算是達到最終一致性也很難:分散式場景下,要做到最終一致性,就要求快取中儲存的是最新版本的資料(或者快取為空),而且是在資料庫更新後很迅速的就要達到這個一致性的狀態,要做到是極其困難的。我們會面臨硬體、軟體、通訊等等元件非常多的異常情況。


CPU的快取結構*

快取的一致性問題

一般化來說,我們面臨的是這樣的一個問題,如下圖所示,資料庫的資料會有5次更新,產生6個版本,V1~V6,圖中每個方框的長度代表這個版本持續的時間。我們期望,在資料庫中的資料變化後,快取層需要儘快的感知到並作出反應,如下圖所示,快取層方框中的間隔代表這個時間段快取資料不存在,V2、V3以及V5版本在快取中不存在並不會破壞我們的最終一致性要求,只要資料庫的最終版本和快取的最終版本是相同的就可以了。

快取是如何寫入的

快取寫入的程式碼通常情況下都是和快取使用的程式碼放在一起的,包含4個步驟,如下圖所示:W1讀取快取,W2判斷快取是否存在,W3組裝快取資料(這通常需要向資料庫進行查詢),W4寫入快取。每一個步驟間可能會停頓多久是沒有辦法控制的,尤其是W3、W4之間的停頓最為要命,它很可能讓我們將舊版本的資料寫入到快取中。
我們可能會想,W4步的寫入,帶上W2的假設,即使用WriteIfNotExists語義,會不會有所改善?

考慮如下的情形,假設有3個快取寫入的併發執行,由於短時間資料庫大量的更新,它們分別組裝的是V1、V2、V3版本的資料。使用WriteIfNotExists語義,其中必然有2個執行會失敗,哪一個會成功根本無法保證。我們無法簡單的做決策,需要再次將快取讀取出來,然後判斷是否我們即將寫入的一樣,如果一樣那就很簡單;如果不一樣的話,我們有兩種選擇:
1)將快取刪除,讓後續別的請求來處理寫入。
2)使用快取提供的原子操作,僅在我們的資料是較新版本時寫入。
截圖2022-01-11 上午10.27.27.png

如何感知資料庫的變化

資料庫的資料發生變化後,我們如何感知到並進行有效的快取管理呢?通常情況下有如下的3種做法:

使用程式碼執行流

通常我們會在資料庫操作完成後,執行一些快取操作的程式碼。這種方式最大的問題是可靠性不高,應用重啟、機器意外當機等情況都會導致後續的程式碼無法執行。

使用事務訊息

作為使用程式碼執行流的改進,在資料庫操作完成後發出事務訊息,然後在訊息的消費邏輯裡執行快取的管理操作。可靠性的問題就解決了,只是業務側要為此增加事務訊息的邏輯,以及執行成本。

使用資料變更日誌

資料庫產品通常都支援在資料變更後產生變更日誌,比如MySQL的binlog。可以讓中介軟體團隊寫一款產品,在接收到變更後執行快取的管理操作,比如阿里的精衛。可靠性有保證,同時還可以進行某個時間段變更日誌的回放,功能就比較強大了。

最佳實踐一:資料庫變更後失效快取

這是最常用和簡單的方式,應該被作為首選的方案,整體的執行邏輯如下圖所示:

W4步使用最基本的put語義,這裡的假設是寫入較晚的請求往往也是攜帶的最新的資料,這在大多的情形下都是成立的。D1步使用監聽DB binlog的方式來刪除快取,即前述使用資料變更日誌中介紹的方法。

這個方案的缺點是:在資料庫資料存在高併發更新且快取讀取流量較大的情況下,會有小概率存在快取中儲存的是舊版本資料的情況。

通常的解法有四種:
1)限制快取有效時間:設定快取的過期時間,比如15分鐘。即表示我們最多接受快取在15分鐘的時間範圍內是舊的。
2)小概率快取重載入:根據流量比設定一定比例的快取重載入,以保證大流量情況下的快取資料的一致性。比如1%的比例,這同時還可以幫助資料庫得到充分的預熱。
3)結合業務特點:根據業務的特點做一些設計,比如:
針對營銷的場景:在商品詳情頁/確認訂單頁的優惠計算時使用快取,而在下單時不使用快取。這可以讓極端情況發生時,不產生過大的業務損失。
針對庫存的場景:讀取到舊版本的資料只是會在商品已售罄的情況下讓多餘的流量進入到下單而已,下單時的庫存扣減是運算元據庫的,所以不會有業務上的損失。
4)兩次刪除:D1步刪除快取的操作執行兩次,且中間有一定的間隔,比如30秒。這兩次動作的觸發都是由“快取管理元件”發起的,所以可以由它支援。

最佳實踐二:帶版本寫入

針物件商品資訊快取這種更新頻率低、資料一致性要求較高且快取讀取流量很高的場景,通常會採用帶版本更新的方式,整體的執行邏輯如下圖如示:

和“資料庫變更後失效快取”方案最大的差異在W4步和D1步,需要快取層提供帶版本寫入的API,即僅當寫入資料版本較新時可以寫入成功,否則寫入失敗。這同時也要求我們在資料庫增加資料版本的資訊。
這個方案的最終一致性效果比較好,僅在極端情況下(新版本寫入後資料丟失了,後續舊版本的寫入就會成功)存在快取中儲存的是舊版本資料的可能。在D1步使用寫入而不是使用刪除可以極大程度的避免這個極端情況的出現,同時由於該方案適用於快取讀取流量很高的場景,還可以避免快取被刪除後W3步短時間大量請求穿透到DB。

總結與展望

對於快取與資料庫分離的場景,在結合了業界多家公司的實踐經驗以及ROI權衡之後,前述的兩個最佳實踐是被應用的最為廣泛的,尤其是最佳實踐一,應該作為我們日常應用的首選。同時,為了最大限度的避免每個最佳實踐背後可能發生的不一致性問題,我們還需要切合業務的特點,在關鍵的場景上做一些保障一致性的設計(比如前述的營銷在下單時使用資料庫讀而不是快取讀),這也顯得尤為重要(畢竟如“背景”中所述,並不存在完美的技術方案)。

除了快取與資料庫分離的方案,還有兩個業界已經應用的方案也值得我們借鑑:

阿里XKV

簡單來講就是在資料庫上部署一個Memcache的Server,它直接繞過資料庫層直接訪問儲存引擎層(如:InnoDB),同時使用KV client來進行資料的訪問。它的特點是資料實際上與資料庫是強一致的,效能可以比使用SQL訪問資料庫提升5~10倍。缺點也很明顯,只能通過主鍵或者唯一鍵來訪問資料(這只是相對SQL來說的,大多數快取本來也就是KV訪問協議)。

騰訊DCache

不用自行維護快取與資料庫兩套儲存,給開發人員統一的一套資料檢視,由DCache在快取更新後自行持久化資料。缺點是支援的資料結構有限( key-value,k-k-row,list,set,zset ),未來也很難支援形如資料庫表一樣複雜的資料結構。

文/蘇木
關注得物技術,做最潮技術人!

相關文章