一、什么是數(shù)據(jù)的一致性
“數(shù)據(jù)一致”一般指的是:緩存中有數(shù)據(jù),緩存的數(shù)據(jù)值=數(shù)據(jù)庫中的值。但根據(jù)緩存中是有數(shù)據(jù)為依據(jù),則“一致”可以包含兩種情況:
緩存中有數(shù)據(jù),緩存的數(shù)據(jù)值=數(shù)據(jù)庫中的值
緩存中本沒有數(shù)據(jù),數(shù)據(jù)庫中的值=最新值(有請求查詢數(shù)據(jù)庫時,會將數(shù)據(jù)寫入緩存,則變?yōu)樯厦娴摹耙恢隆睜顟B(tài))
“數(shù)據(jù)不一致”:緩存的數(shù)據(jù)值≠數(shù)據(jù)庫中的值;緩存或者數(shù)據(jù)庫中存在舊值,導致其他線程讀到舊數(shù)據(jù)。
二、數(shù)據(jù)不一致性情況及應對策略
根據(jù)是否接收寫請求,可以把緩存分成讀寫緩存和只讀緩存。
只讀緩存:只在緩存進行數(shù)據(jù)查找,即使用“更新數(shù)據(jù)庫+刪除緩存”策略。
讀寫緩存:需要在緩存中對數(shù)據(jù)進行增刪改查,即使用“更新數(shù)據(jù)庫+更新緩存”策略。
(一)針對只讀緩存(更新數(shù)據(jù)庫+刪除緩存)
只讀緩存:新增數(shù)據(jù)時,直接寫入數(shù)據(jù)庫;更新(修改/刪除)數(shù)據(jù)時,先刪除緩存。后續(xù)訪問這些增刪改的數(shù)據(jù)時,會發(fā)生緩存缺失,進而查詢數(shù)據(jù)庫,更新緩存。
·新增數(shù)據(jù)時,寫入數(shù)據(jù)庫;訪問數(shù)據(jù)時,緩存缺失,查數(shù)據(jù)庫,更新緩存(始終是處于“數(shù)據(jù)一致”的狀態(tài),不會發(fā)生數(shù)據(jù)不一致性問題)
·更新(修改/刪除)數(shù)據(jù)時,會有個時序問題:更新數(shù)據(jù)庫與刪除緩存的順序(這個過程會發(fā)生數(shù)據(jù)不一致性問題)
在更新數(shù)據(jù)的過程中,可能會有如下問題:
·無并發(fā)請求下,其中一個操作失敗的情況。
·并發(fā)請求下,其他線程可能會讀到舊值
因此,要想達到數(shù)據(jù)一致性,需要保證兩點:
·無并發(fā)請求下,保證A和B步驟都能成功執(zhí)行。
·并發(fā)請求下,在A和B步驟的間隔中,避免或消除其他線程的影響。
接下來,我們針對有/無并發(fā)場景,進行分析并使用不同的策略。
·無并發(fā)情況
無并發(fā)請求下,在更新數(shù)據(jù)庫和刪除緩存值的過程中,因為操作被拆分成兩步,那么就很有可能存在“步驟1成功,步驟2失敗”的情況發(fā)生(由于單線程中步驟1和步驟2是串行執(zhí)行的,不太可能會發(fā)生“步驟2成功,步驟1失敗”的情況)。
(1)先刪除緩存,再更新數(shù)據(jù)庫
(2)先更新數(shù)據(jù)庫,再刪除緩存
解決策略:
a.消息隊列+異步重試
無論使用哪一種執(zhí)行時序,可以在執(zhí)行步驟1時,將步驟2的請求寫入消息隊列,當步驟2失敗時,就可以使用重試策略,對失敗操作進行“補償”。
具體步驟如下:
·把要刪除的緩存值或者是要更新的數(shù)據(jù)庫值暫存到消息隊列中(例如使用Kafka消息隊列)
·當刪除緩存值或者是更新數(shù)據(jù)庫值成功時,把這些值從消息隊列中去除,以免重復操作。
·當刪除緩存值或者是更新數(shù)據(jù)庫值失敗時,執(zhí)行失敗策略,重試服務從消息隊列中重新讀取這些值,然后再次進行刪除或更新。
·刪除或者更新失敗時,需要再次進行重試,重試超過的一定次數(shù)。向業(yè)務層發(fā)送報錯信息。
b.訂閱Binlog變更日志
·創(chuàng)建更新緩存服務,接收數(shù)據(jù)變更的MQ消息,然后消費消息,更新/刪除Redis中的緩存數(shù)據(jù)。
·使用Binlog實時更新/刪除Redis緩存。利用Canal,即將負責更新緩存的服務偽裝成一個MySQL的從節(jié)點,從MySQL接收Binlog,解析Binlog之后,得到實時的數(shù)據(jù)變更信息,然后根據(jù)變更信息去更新/刪除Redis緩存。
·MQ+Canal策略,將Canal Server接收到的Binlog數(shù)據(jù)直接投遞到MQ進行解耦,使用MQ異步消費Binlog日志,以此進行數(shù)據(jù)同步。
不管用MQ/Canal或者MQ+Canal的策略來異步更新緩存,對整個更新服務的數(shù)據(jù)可靠性和實時性要求都比較高,如果產(chǎn)生數(shù)據(jù)丟失或者更新延時情況,會造成MySQL和Redis中的數(shù)據(jù)不一致。因此,使用這種策略時,需要考慮出現(xiàn)不同步問題時的降級或補償方案。
·高并發(fā)情況
使用以上策略后,可以保證在單線程/無并發(fā)場景下的數(shù)據(jù)一致性。但是,在高并發(fā)場景下,由于數(shù)據(jù)庫層面的讀寫并發(fā),會引發(fā)的數(shù)據(jù)庫與緩存數(shù)據(jù)不一致的問題(本質(zhì)是后發(fā)生的讀請求先返回了)
(1)先刪除緩存,再更新數(shù)據(jù)庫
假設線程A刪除緩存值后,由于網(wǎng)絡延遲等原因?qū)е挛醇案聰?shù)據(jù)庫,而此時,線程B開始讀取數(shù)據(jù)時會發(fā)現(xiàn)緩存缺失,進而去查詢數(shù)據(jù)庫。而當線程B從數(shù)據(jù)庫讀取完數(shù)據(jù)、更新了緩存后,線程A才開始更新數(shù)據(jù)庫,此時,會導致緩存中的數(shù)據(jù)是舊值,而數(shù)據(jù)庫中的是最新值,產(chǎn)生“數(shù)據(jù)不一致”。其本質(zhì)就是,本應后發(fā)生的“B線程-讀請求”先于“A線程-寫請求”執(zhí)行并返回了。
或者
解決策略:
設置緩存過期時間+延時雙刪
通過設置緩存過期時間,若發(fā)生上述淘汰緩存失敗的情況,則在緩存過期后,讀請求仍然可以從DB中讀取最新數(shù)據(jù)并更新緩存,可減小數(shù)據(jù)不一致的影響范圍。雖然在一定時間范圍內(nèi)數(shù)據(jù)有差異,但可以保證數(shù)據(jù)的最終一致性。
此外,還可以通過延時雙刪進行保障:在線程A更新完數(shù)據(jù)庫值以后,讓它先sleep一小段時間,確保線程B能夠先從數(shù)據(jù)庫讀取數(shù)據(jù),再把缺失的數(shù)據(jù)寫入緩存,然后,線程A再進行刪除。后續(xù)其它線程讀取數(shù)據(jù)時,發(fā)現(xiàn)緩存缺失,會從數(shù)據(jù)庫中讀取最新值。
redis.delKey(X)
db.update(X)
Thread.sleep(N)
redis.delKey(X)
sleep時間:在業(yè)務程序運行的時候,統(tǒng)計下線程讀數(shù)據(jù)和寫緩存的操作時間,以此為基礎來進行估算。
注意:如果難以接受sleep這種寫法,可以使用延時隊列進行替代。
先刪除緩存值再更新數(shù)據(jù)庫,有可能導致請求因緩存缺失而訪問數(shù)據(jù)庫,給數(shù)據(jù)庫帶來壓力,也就是緩存穿透的問題。針對緩存穿透問題,可以用緩存空結果、布隆過濾器進行解決。
(2)先更新數(shù)據(jù)庫,再刪除緩存
如果線程A更新了數(shù)據(jù)庫中的值,但還沒來得及刪除緩存值,線程B就開始讀取數(shù)據(jù)了,那么此時,線程B查詢緩存時,發(fā)現(xiàn)緩存命中,就會直接從緩存中讀取舊值。其本質(zhì)也是,本應后發(fā)生的“B線程-讀請求”先于“A線程-刪除緩存”執(zhí)行并返回了。
或者,在“先更新數(shù)據(jù)庫,再刪除緩存”方案下,“讀寫分離+主從庫延遲”也會導致不一致:
解決方案:
a.延遲消息
憑借經(jīng)驗發(fā)送「延遲消息」到隊列中,延遲刪除緩存,同時也要控制主從庫延遲,盡可能降低不一致發(fā)生的概率。
b.訂閱binlog,異步刪除
通過數(shù)據(jù)庫的binlog來異步淘汰key,利用工具(canal)將binlog日志采集發(fā)送到MQ中,然后通過ACK機制確認處理刪除緩存。
c.刪除消息寫入數(shù)據(jù)庫
通過比對數(shù)據(jù)庫中的數(shù)據(jù),進行刪除確認先更新數(shù)據(jù)庫再刪除緩存,有可能導致請求因緩存缺失而訪問數(shù)據(jù)庫,給數(shù)據(jù)庫帶來壓力,也就是緩存穿透的問題。針對緩存穿透問題,可以用緩存空結果、布隆過濾器進行解決。
d.加鎖
更新數(shù)據(jù)時,加寫鎖;查詢數(shù)據(jù)時,加讀鎖。
建議:
優(yōu)先使用“先更新數(shù)據(jù)庫再刪除緩存”的執(zhí)行時序,原因主要有兩個:
·先刪除緩存值再更新數(shù)據(jù)庫,有可能導致請求因緩存缺失而訪問數(shù)據(jù)庫,給數(shù)據(jù)庫帶來壓力。
·業(yè)務應用中讀取數(shù)據(jù)庫和寫緩存的時間有時不好估算,進而導致延遲雙刪中的sleep時間不好設置。
(二)針對讀寫緩存(更新數(shù)據(jù)庫+更新緩存)
讀寫緩存:增刪改在緩存中進行,并采取相應的回寫策略,同步數(shù)據(jù)到數(shù)據(jù)庫中。
同步直寫:使用事務,保證緩存和數(shù)據(jù)更新的原子性,并進行失敗重試(如果Redis本身出現(xiàn)故障,會降低服務的性能和可用性)
異步回寫:寫緩存時不同步寫數(shù)據(jù)庫,等到數(shù)據(jù)從緩存中淘汰時,再寫回數(shù)據(jù)庫(沒寫回數(shù)據(jù)庫前,緩存發(fā)生故障,會造成數(shù)據(jù)丟失)
該策略在秒殺場中有見到過,業(yè)務層直接對緩存中的秒殺商品庫存信息進行操作,一段時間后再回寫數(shù)據(jù)庫。
一致性:同步直寫>異步回寫,因此,對于讀寫緩存,要保持數(shù)據(jù)強一致性的主要思路是:利用同步直寫,同步直寫也存在兩個操作的時序問題:更新數(shù)據(jù)庫和更新緩存。
·無并發(fā)情況
·高并發(fā)情況
有四種場景會造成數(shù)據(jù)不一致:
針對場景1和2的解決方案是:保存請求對緩存的讀取記錄,延時消息比較,發(fā)現(xiàn)不一致后,做業(yè)務補償針對場景3和4的解決方案是:對于寫請求,需要配合分布式鎖使用。寫請求進來時,針對同一個資源的修改操作,先加分布式鎖,保證同一時間只有一個線程去更新數(shù)據(jù)庫和緩存;沒有拿到鎖的線程把操作放入到隊列中,延時處理。用這種方式保證多個線程操作同一資源的順序性,以此保證一致性。
其中,分布式鎖的實現(xiàn)可以使用以下策略:
(三)強一致性策略
上述策略只能保證數(shù)據(jù)的最終一致性。要想做到強一致,最常見的方案是2PC、3PC、Paxos、Raft這類一致性協(xié)議,但它們的性能往往比較差,而且這些方案也比較復雜,還要考慮各種容錯問題。如果業(yè)務層要求必須讀取數(shù)據(jù)的強一致性,可以采取以下策略:
·暫存并發(fā)讀請求
在更新數(shù)據(jù)庫時,先在Redis緩存客戶端暫存并發(fā)讀請求,等數(shù)據(jù)庫更新完、緩存值刪除后,再讀取數(shù)據(jù),從而保證數(shù)據(jù)一致性。
·串行化
讀寫請求入隊列,工作線程從隊列中取任務來依次執(zhí)行
·修改服務Service連接池,id取模選取服務連接,能夠保證同一個數(shù)據(jù)的讀寫都落在同一個后端服務上。
·修改數(shù)據(jù)庫DB連接池,id取模選取DB連接,能夠保證同一個數(shù)據(jù)的讀寫在數(shù)據(jù)庫層面是串行的。
·使用Redis分布式讀寫鎖
將淘汰緩存與更新庫表放入同一把寫鎖中,與其它讀請求互斥,防止其間產(chǎn)生舊數(shù)據(jù)。讀寫互斥、寫寫互斥、讀讀共享,可滿足讀多寫少的場景數(shù)據(jù)一致,也保證了并發(fā)性。并根據(jù)邏輯平均運行時間、響應超時時間來確定過期時間。
public void write() {
Lock writeLock = redis.getWriteLock(lockKey);
writeLock.lock();
try {
redis.delete(key);
db.update(record);
} finally {
writeLock.unlock();
}
}
public void read() {
if (caching) {
return;
}
// no cache
Lock readLock = redis.getReadLock(lockKey);
readLock.lock();
try {
record = db.get();
} finally {
readLock.unlock();
}
redis.set(key, record);
}
(四)小結
針對讀寫緩存時:同步直寫,更新數(shù)據(jù)庫+更新緩存
針對只讀緩存時:更新數(shù)據(jù)庫+刪除緩存
較為通用的一致性策略擬定:
在并發(fā)場景下,使用“更新數(shù)據(jù)庫+更新緩存”需要用分布式鎖保證緩存和數(shù)據(jù)一致性,且可能存在“緩存資源浪費”和“機器性能浪費”的情況;一般推薦使用“更新數(shù)據(jù)庫+刪除緩存”的方案。如果根據(jù)需要,熱點數(shù)據(jù)較多,可以使用“更新數(shù)據(jù)庫+更新緩存”策略。
在“更新數(shù)據(jù)庫+刪除緩存”的方案中,推薦使用推薦用“先更新數(shù)據(jù)庫,再刪除緩存”策略,因為先刪除緩存可能會導致大量請求落到數(shù)據(jù)庫,而且延遲雙刪的時間很難評估。
在“先更新數(shù)據(jù)庫,再刪除緩存”策略中,可以使用“消息隊列+重試機制”的方案保證緩存的刪除。并通過“訂閱binlog”進行緩存比對,加上一層保障。
此外,需要通過初始化緩存預熱、多數(shù)據(jù)源觸發(fā)、延遲消息比對等策略進行輔助和補償?!径喾N數(shù)據(jù)更新觸發(fā)源:定時任務掃描,業(yè)務系統(tǒng)MQ、binlog變更MQ,相互之間作為互補來保證數(shù)據(jù)不會漏更新】
三、數(shù)據(jù)不一致性需注意其他問題
(一)k-v大小的合理設置
Redis key大小設計:由于網(wǎng)絡的一次傳輸MTU最大為1500字節(jié),所以為了保證高效的性能,建議單個k-v大小不超過1KB,一次網(wǎng)絡傳輸就能完成,避免多次網(wǎng)絡交互;k-v是越小性能越好
Redis熱key:當業(yè)務遇到單個讀熱key,通過增加副本來提高讀能力或是用hashtag把key存多份在多個分片中。
當業(yè)務遇到單個寫熱key,需業(yè)務拆分這個key的功能,屬于設計不合理-當業(yè)務遇到熱分片,即多個熱key在同一個分片上導致單分片cpu高,可通過hashtag方式打散。
(二)避免其他問題導致緩存服務器崩潰,進而簡直導致數(shù)據(jù)一致性策略失效緩存穿透、緩存擊穿、緩存雪崩、機器故障等問題
(三)方案選定的思路
`確定緩存類型(讀寫/只讀)
`確定一致性級別
`確定同步/異步方式
`選定緩存流程
`補充細節(jié)
參考資料:
1.Redis與MySQL雙寫一致性如何保證
2.干貨|攜程最終一致和強一致性緩存實踐
3.大廠都是怎么做MySQL to Redis同步的
4.緩存與數(shù)據(jù)庫一致性策略
5.緩存與數(shù)據(jù)庫一致性保證
6.如何解決緩存和數(shù)據(jù)庫的數(shù)據(jù)不一致問題
7.Redis經(jīng)典問題,緩存(穿透,雪崩,擊穿,數(shù)據(jù)不一致,數(shù)據(jù)并發(fā)競爭,HotKey,BigKey),分布式鎖(watch樂觀鎖,setnx,Redisson)
8.Redisson分布式鎖場景和使用