在雲服務中,快取是極其重要的一點。所謂快取,其實是一個高速資料儲存層。當快取存在後,日後再次請求該資料就會直接訪問快取,提升資料訪問的速度。但是快取儲存的資料通常是短暫性的,這就需要經常對快取進行更新。而我們操作快取和資料庫,分為讀操作和寫操作。
讀操作的詳細流程為,請求資料,如快取中存在資料則直接讀取並返回,如不存在則從資料庫中讀取,成功之後將資料放到快取中。
寫操作則又分為以下 4 種:
-
先更新快取,再更新資料庫
-
先更新資料庫,再更新快取
-
先刪除快取,再更新資料庫
-
先更新資料庫,再刪除快取
一些一致性要求不高的資料,如點贊數等,可以先更新快取,然後再定時同步到資料庫。而在其它情況下,我們通常會等資料庫操作成功,再操作快取。
下面主要介紹更新資料庫成功後,更新快取和刪除快取這兩個操作的區別和改進方案。
先更新資料庫,再刪除快取
先更新資料庫,再刪除快取,這種模式也叫 cache aside,是目前比較流行的處理快取資料庫一致性的方法。
它的優點是:
-
出現資料不一致的機率極低,實現簡單
-
由於不更新快取,而是刪除快取,在併發寫寫情況下,不會出現資料不一致的情況
出現資料不一致的情況出現在併發讀寫的場景下,詳情可見下圖:
這種情況發生的機率比較低,必須要在某⼀時間區間同時存在兩個或多個寫⼊和多個讀取,所以大部分業務都容忍了這種小機率的不一致。
雖然發生的機率較低,但還是有一些方案可以讓影響降到更低。
最佳化方案
第一種方案為:採用較短的過期時間來減少影響。這種方法有兩個缺點:
-
刪除後,讀請求會 miss
-
如果快取不一致,不一致的時間取決於過期時間設定
第二種方案則是採用延遲雙刪的策略,比如:1分鐘以後刪除快取。這種做法也存在兩個缺點:
-
刪除快取之前的時間裡可能會有不一致
-
刪除後,讀請求會 miss
第三種方案為雙更新策略,思路與延遲雙刪策略差不多。不同的點是,此方案不刪除快取而是更新快取,所以讀請求就不會發生 miss。但是另一個缺點還是存在。
先更新資料庫,再更新快取
相比先更新資料庫再刪除快取的操作,先更新資料庫再更新快取的操作可以避免使用者請求直接打到資料庫,進而導致快取穿透的問題。
此方案是更新快取,我們需要關注併發讀寫和併發寫寫兩個場景下導致的資料不一致。
先來看看併發讀寫的情況,步驟如下圖所示:
可以看到由於 4 和 5 操作步驟都設定了快取,如果步驟4發生在步驟5之前,那麼會出現舊值覆蓋新值的情況,也就是快取不一致的情況。這種情況只需要修改一下步驟5,便可解決。
最佳化方案
可以透過在第五步不要 set cache,改用 add cache,redis 中使用 setnx 命令來進行最佳化。修改後步驟示意圖如下:
解決完了併發讀寫場景導致的資料不一致,再來看看併發寫寫情況導致的資料不一致問題。
出現不一致的情況如下圖所示,Thread A 比 Thread B 先更新完 DB,但是 Thread B 卻先更新完快取,這就導致快取會被 Thread A 的舊值所覆蓋。
這種情況也是有方法可以最佳化的,下面介紹兩個主流方法:
-
使用分散式鎖
-
使用版本號
使用分散式鎖
要解決併發讀寫的問題,第一個思路就是消滅併發寫。而使用分散式鎖,讓寫操作排隊執行,理論上就可以解決併發寫的問題,但現在並沒有可靠的分散式鎖實現方案。
不管是基於 Zookeeper,etcd 還是 redis 實現分散式鎖,為了防止程式掛掉而鎖不能釋放,我們都會給鎖設定租約/過期時間,想象一種場景:如果程式卡頓幾分鐘(雖然機率較低),導致鎖失效,而其它執行緒獲取到鎖,此時就又出現了併發讀寫的場景了,還是有可能會造成資料不一致。
使用版本號
併發寫導致的資料不一致,是因為低版本覆蓋了高版本。那麼我們可以想辦法不讓這種情況發生,一種可行的方案是引入版本號,如果寫入的資料低於現版本號,則放棄覆蓋。
缺點:
-
應用層維護版本的代價很大,大規模落地很難
-
需修改資料模型,新增版本
-
每次需要修改,讓版本自增
不管是更新快取還是刪除快取,最佳化以後都將出現資料不一致的機率降到最低了。但是有沒有一種辦法既簡單,又不會出現資料不一致的場景呢。下面就介紹一下 Rockscache。
Rockscache
簡介
Rockscache 也是一種保持快取一致性的方法,它採用的快取管理策略是:更新資料庫後,將快取標記為刪除。主要透過以下兩個方法來實現:
-
Fetch 函式實現了前面的查詢快取
-
TagAsDeleted 函式實現了標記刪除的邏輯
在執行時只要讀資料時呼叫 Fetch,並且確保更新資料庫之後呼叫 TagAsDeleted,就能夠確保快取最終一致。這一策略有 4 個特點:
-
不需要引入版本,幾乎可以適用於所有快取場景
-
架構上與"更新 DB 後刪除快取”一樣,無額外負擔
-
效能高:變化只是將原來的 GET/SET/DELETE,替換為 Lua 指令碼
-
強一致方案的效能也很高,與普通的防快取擊穿方案一樣
在 Rockscache 策略中,快取中的資料是包含幾個欄位的 hash:
-
value:資料本身
-
lockUtil:資料鎖定到期時間,當某個程式查詢快取無資料,那麼先鎖定快取一小段時間,然後查詢 DB,然後更新快取
-
owner:資料鎖定者 uuid
證明
因為 Rockscache 方案並不更新快取,所以只要確保併發讀寫資料一致性即可。下面來看看 Rockscache 是怎麼解決資料不一致的問題,先回憶一遍 cache aside 模式導致的資料不一致的原因。
結合 cache aside 模式出現資料不一致的場景,來講講 Rockscache 是怎麼解決的。
我們要解決的核心問題是,防止舊值寫入到快取中。Rockscache 的解決方案是這樣的:
-
查詢請求,如果快取中讀不到資料,還要做一個操作:鎖定快取,為key設定一個uuid(程式碼示例:https://github.com/dtm-labs/rockscache/blob/main/client.go#L191)
-
寫請求在刪除快取的時候,需要把鎖刪了(程式碼示例:https://github.com/dtm-labs/rockscache/blob/main/client.go#L96)
-
讀請求在設定快取的時候,透過uuid比對,發現上鎖的不是自己,說明有寫請求把資料更新了,則放棄修改快取(程式碼示例:https://github.com/dtm-labs/rockscache/blob/main/client.go#L160)
至此我們已經完成了 rockscache 策略下的快取更新。不過和其他快取更新策略一樣,我們都預設運算元據庫成功後,操作快取肯定成功。但是這是不對的,在實際操作過程即便運算元據庫成功,也可能出現快取操作失敗的情況,因此可以透過以下 3 種方式來保證快取更新成功:
-
本地訊息表
-
監聽 binlog
-
dtm 的二階段訊息
除了快取更新,Rockscache 還有以下兩種功能:
-
防止快取擊穿
-
防止防止穿透和快取雪崩
這都是非常實用的功能,推薦大家實際使用操作試試看。
參考資料