緩存一致性最佳實踐

背景?
概述
最近團(tuán)隊里我們在密集的討論Redis緩存一致性相關(guān)的問題,電商核心的域如商品、營銷、庫存、訂單等實際上在緩存的選擇上各有特色,那么在這些差異的業(yè)務(wù)背后,我們有沒有一些最佳實踐可供參考呢?
本文嘗試著來討論這個問題,并給出一些建議。
在討論之前,有兩個重點我們需要達(dá)成一致:
分布式場景下無法做到強(qiáng)一致:
不同于CPU硬件緩存體系采用的MESI協(xié)議以及硬件的強(qiáng)時鐘控制,分布式場景下我們無法做到緩存與底層數(shù)據(jù)庫的強(qiáng)一致,即把緩存和數(shù)據(jù)庫的數(shù)據(jù)變更做成一個原子操作。
硬件工程師設(shè)計了內(nèi)存屏障(Memory Barrier)的概念,提供給軟件開發(fā)者不同的一致性選項在性能與一致性上進(jìn)行權(quán)衡。
就算是達(dá)到最終一致性也很難:
分布式場景下,要做到最終一致性,就要求緩存中存儲的是最新版本的數(shù)據(jù)(或者緩存為空),而且是在數(shù)據(jù)庫更新后很迅速的就要達(dá)到這個一致性的狀態(tài),要做到是極其困難的。
我們會面臨硬件、軟件、通信等等組件非常多的異常情況。

CPU的緩存結(jié)構(gòu)
緩存的一致性問題
一般化來說,我們面臨的是這樣的一個問題,如下圖所示,數(shù)據(jù)庫的數(shù)據(jù)會有5次更新,產(chǎn)生6個版本,V1~V6,圖中每個方框的長度代表這個版本持續(xù)的時間。
我們期望,在數(shù)據(jù)庫中的數(shù)據(jù)變化后,緩存層需要盡快的感知到并作出反應(yīng),如下圖所示,緩存層方框中的間隔代表這個時間段緩存數(shù)據(jù)不存在,V2、V3以及V5版本在緩存中不存在并不會破壞我們的最終一致性要求,只要數(shù)據(jù)庫的最終版本和緩存的最終版本是相同的就可以了。

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

考慮如下的情形,假設(shè)有3個緩存寫入的并發(fā)執(zhí)行,由于短時間數(shù)據(jù)庫大量的更新,它們分別組裝的是V1、V2、V3版本的數(shù)據(jù)。
使用WriteIfNotExists語義,其中必然有2個執(zhí)行會失敗,哪一個會成功根本無法保證。
我們無法簡單的做決策,需要再次將緩存讀取出來,然后判斷是否我們即將寫入的一樣,如果一樣那就很簡單;如果不一樣的話,我們有兩種選擇:
將緩存刪除,讓后續(xù)別的請求來處理寫入。
使用緩存提供的原子操作,僅在我們的數(shù)據(jù)是較新版本時寫入。

如何感知數(shù)據(jù)庫的變化
數(shù)據(jù)庫的數(shù)據(jù)發(fā)生變化后,我們?nèi)绾胃兄讲⑦M(jìn)行有效的緩存管理呢?
通常情況下有如下的3種做法:
使用代碼執(zhí)行流
通常我們會在數(shù)據(jù)庫操作完成后,執(zhí)行一些緩存操作的代碼。
這種方式最大的問題是可靠性不高,應(yīng)用重啟、機(jī)器意外當(dāng)機(jī)等情況都會導(dǎo)致后續(xù)的代碼無法執(zhí)行。
使用事務(wù)消息
作為使用代碼執(zhí)行流的改進(jìn),在數(shù)據(jù)庫操作完成后發(fā)出事務(wù)消息,然后在消息的消費(fèi)邏輯里執(zhí)行緩存的管理操作。
可靠性的問題就解決了,只是業(yè)務(wù)側(cè)要為此增加事務(wù)消息的邏輯,以及運(yùn)行成本。
使用數(shù)據(jù)變更日志
數(shù)據(jù)庫產(chǎn)品通常都支持在數(shù)據(jù)變更后產(chǎn)生變更日志,比如MySQL的binlog。
可以讓中間件團(tuán)隊寫一款產(chǎn)品,在接收到變更后執(zhí)行緩存的管理操作,比如阿里的精衛(wèi)。
可靠性有保證,同時還可以進(jìn)行某個時間段變更日志的回放,功能就比較強(qiáng)大了。
最佳實踐一:數(shù)據(jù)庫變更后失效緩存
這是最常用和簡單的方式,應(yīng)該被作為首選的方案,整體的執(zhí)行邏輯如下圖所示:

W4步使用最基本的put語義,這里的假設(shè)是寫入較晚的請求往往也是攜帶的最新的數(shù)據(jù),這在大多的情形下都是成立的。
D1步使用監(jiān)聽DB binlog的方式來刪除緩存,即前述使用數(shù)據(jù)變更日志中介紹的方法。
這個方案的缺點是:在數(shù)據(jù)庫數(shù)據(jù)存在高并發(fā)更新且緩存讀取流量較大的情況下,會有小概率存在緩存中存儲的是舊版本數(shù)據(jù)的情況。
通常的解法有四種:
限制緩存有效時間:
設(shè)定緩存的過期時間,比如15分鐘。即表示我們最多接受緩存在15分鐘的時間范圍內(nèi)是舊的。
小概率緩存重加載:
根據(jù)流量比設(shè)定一定比例的緩存重加載,以保證大流量情況下的緩存數(shù)據(jù)的一致性。
比如1%的比例,這同時還可以幫助數(shù)據(jù)庫得到充分的預(yù)熱。
結(jié)合業(yè)務(wù)特點:
根據(jù)業(yè)務(wù)的特點做一些設(shè)計,比如:
針對營銷的場景:
在商品詳情頁/確認(rèn)訂單頁的優(yōu)惠計算時使用緩存,而在下單時不使用緩存。
這可以讓極端情況發(fā)生時,不產(chǎn)生過大的業(yè)務(wù)損失。
針對庫存的場景:
讀取到舊版本的數(shù)據(jù)只是會在商品已售罄的情況下讓多余的流量進(jìn)入到下單而已,下單時的庫存扣減是操作數(shù)據(jù)庫的,所以不會有業(yè)務(wù)上的損失。
兩次刪除:
D1步刪除緩存的操作執(zhí)行兩次,且中間有一定的間隔,比如30秒。
這兩次動作的觸發(fā)都是由“緩存管理組件”發(fā)起的,所以可以由它支持。
最佳實踐二:帶版本寫入
針對象商品信息緩存這種更新頻率低、數(shù)據(jù)一致性要求較高且緩存讀取流量很高的場景,通常會采用帶版本更新的方式,整體的執(zhí)行邏輯如下圖如示:

和“數(shù)據(jù)庫變更后失效緩存”方案最大的差異在W4步和D1步,需要緩存層提供帶版本寫入的API,即僅當(dāng)寫入數(shù)據(jù)版本較新時可以寫入成功,否則寫入失敗。
這同時也要求我們在數(shù)據(jù)庫增加數(shù)據(jù)版本的信息。
這個方案的最終一致性效果比較好,僅在極端情況下(新版本寫入后數(shù)據(jù)丟失了,后續(xù)舊版本的寫入就會成功)存在緩存中存儲的是舊版本數(shù)據(jù)的可能。
在D1步使用寫入而不是使用刪除可以極大程度的避免這個極端情況的出現(xiàn),同時由于該方案適用于緩存讀取流量很高的場景,還可以避免緩存被刪除后W3步短時間大量請求穿透到DB。
總結(jié)與展望
對于緩存與數(shù)據(jù)庫分離的場景,在結(jié)合了業(yè)界多家公司的實踐經(jīng)驗以及ROI權(quán)衡之后,前述的兩個最佳實踐是被應(yīng)用的最為廣泛的,尤其是最佳實踐一,應(yīng)該作為我們?nèi)粘?yīng)用的首選。
同時,為了最大限度的避免每個最佳實踐背后可能發(fā)生的不一致性問題,我們還需要切合業(yè)務(wù)的特點,在關(guān)鍵的場景上做一些保障一致性的設(shè)計(比如前述的營銷在下單時使用數(shù)據(jù)庫讀而不是緩存讀),這也顯得尤為重要(畢竟如“背景”中所述,并不存在完美的技術(shù)方案)。
除了緩存與數(shù)據(jù)庫分離的方案,還有兩個業(yè)界已經(jīng)應(yīng)用的方案也值得我們借鑒:
阿里XKV
簡單來講就是在數(shù)據(jù)庫上部署一個Memcache的Server,它直接繞過數(shù)據(jù)庫層直接訪問存儲引擎層(如:InnoDB),同時使用KV client來進(jìn)行數(shù)據(jù)的訪問。
它的特點是數(shù)據(jù)實際上與數(shù)據(jù)庫是強(qiáng)一致的,性能可以比使用SQL訪問數(shù)據(jù)庫提升5~10倍。
缺點也很明顯,只能通過主鍵或者唯一鍵來訪問數(shù)據(jù)(這只是相對SQL來說的,大多數(shù)緩存本來也就是KV訪問協(xié)議)。
騰訊DCache
不用自行維護(hù)緩存與數(shù)據(jù)庫兩套存儲,給開發(fā)人員統(tǒng)一的一套數(shù)據(jù)視圖,由DCache在緩存更新后自行持久化數(shù)據(jù)。
缺點是支持的數(shù)據(jù)結(jié)構(gòu)有限( key-value,k-k-row,list,set,zset ),未來也很難支持形如數(shù)據(jù)庫表一樣復(fù)雜的數(shù)據(jù)結(jié)構(gòu)。

*文/蘇木
