如何保證快取和資料庫一致性,這是一個老生常談的話題了,
但很多人對這個問題,依舊有很多疑惑:
- 到底是更新快取還是刪快取?
- 到底選擇先更新資料庫,再洗掉快取,還是先洗掉快取,再更新資料庫?
- 為什么要引入訊息佇列保證一致性?
- 延遲雙刪會有什么問題?到底要不要用?
- ...
這篇文章,我們就來把這些問題講清楚,
這篇文章干貨很多,希望你可以耐心讀完,

一、引入快取提高性能
我們從最簡單的場景開始講起,
如果你的業務處于起步階段,流量非常小,那無論是讀請求還是寫請求,直接操作資料庫即可,這時你的架構模型是這樣的:

但隨著業務量的增長,你的專案請求量越來越大,這時如果每次都從資料庫中讀資料,那肯定會有性能問題,
這個階段通常的做法是,引入「快取」來提高讀性能,架構模型就變成了這樣:

當下優秀的快取中間件,當屬 Redis 莫屬,它不僅性能非常高,還提供了很多友好的資料型別,可以很好地滿足我們的業務需求,
但引入快取之后,你就會面臨一個問題:之前資料只存在資料庫中,現在要放到快取中讀取,具體要怎么存呢?
最簡單直接的方案是「全量資料刷到快取中」:
- 資料庫的資料,全量刷入快取(不設定失效時間)
- 寫請求只更新資料庫,不更新快取
- 啟動一個定時任務,定時把資料庫的資料,更新到快取中

這個方案的優點是,所有讀請求都可以直接「命中」快取,不需要再查資料庫,性能非常高,
但缺點也很明顯,有 2 個問題:
- 快取利用率低:不經常訪問的資料,還一直留在快取中
- 資料不一致:因為是「定時」重繪快取,快取和資料庫存在不一致(取決于定時任務的執行頻率)
所以,這種方案一般更適合業務「體量小」,且對資料一致性要求不高的業務場景,
那如果我們的業務體量很大,怎么解決這 2 個問題呢?
二、快取利用率和一致性問題
先來看第一個問題,如何提高快取利用率?
想要快取利用率「最大化」,我們很容易想到的方案是,快取中只保留最近訪問的「熱資料」,但具體要怎么做呢?
我們可以這樣優化:
- 寫請求依舊只寫資料庫
- 讀請求先讀快取,如果快取不存在,則從資料庫讀取,并重建快取
- 同時,寫入快取中的資料,都設定失效時間

這樣一來,快取中不經常訪問的資料,隨著時間的推移,都會逐漸「過期」淘汰掉,最終快取中保留的,都是經常被訪問的「熱資料」,快取利用率得以最大化,
再來看資料一致性問題,
要想保證快取和資料庫「實時」一致,那就不能再用定時任務重繪快取了,
所以,當資料發生更新時,我們不僅要操作資料庫,還要一并操作快取,具體操作就是,修改一條資料時,不僅要更新資料庫,也要連帶快取一起更新,
但資料庫和快取都更新,又存在先后問題,那對應的方案就有 2 個:
- 先更新快取,后更新資料庫
- 先更新資料庫,后更新快取
哪個方案更好呢?
先不考慮并發問題,正常情況下,無論誰先誰后,都可以讓兩者保持一致,但現在我們需要重點考慮「例外」情況,
因為操作分為兩步,那么就很有可能存在「第一步成功、第二步失敗」的情況發生,
這 2 種方案我們一個個來分析,
1、先更新快取,后更新資料庫
如果快取更新成功了,但資料庫更新失敗,那么此時快取中是最新值,但資料庫中是「舊值」,
雖然此時讀請求可以命中快取,拿到正確的值,但是,一旦快取「失效」,就會從資料庫中讀取到「舊值」,重建快取也是這個舊值,
這時用戶會發現自己之前修改的資料又「變回去」了,對業務造成影響,
2、先更新資料庫,后更新快取
如果資料庫更新成功了,但快取更新失敗,那么此時資料庫中是最新值,快取中是「舊值」,
之后的讀請求讀到的都是舊資料,只有當快取「失效」后,才能從資料庫中得到正確的值,
這時用戶會發現,自己剛剛修改了資料,但卻看不到變更,一段時間過后,資料才變更過來,對業務也會有影響,
可見,無論誰先誰后,但凡后者發生例外,就會對業務造成影響,那怎么解決這個問題呢?
別急,后面我會詳細給出對應的解決方案,
我們繼續分析,除了操作失敗問題,還有什么場景會影響資料一致性?
這里我們還需要重點關注:并發問題,
三、并發引發的一致性問題
假設我們采用「先更新資料庫,再更新快取」的方案,并且兩步都可以「成功執行」的前提下,如果存在并發,情況會是怎樣的呢?
有執行緒 A 和執行緒 B 兩個執行緒,需要更新「同一條」資料,會發生這樣的場景:
- 執行緒 A 更新資料庫(X = 1)
- 執行緒 B 更新資料庫(X = 2)
- 執行緒 B 更新快取(X = 2)
- 執行緒 A 更新快取(X = 1)
最終 X 的值在快取中是 1,在資料庫中是 2,發生不一致,
也就是說,A 雖然先于 B 發生,但 B 操作資料庫和快取的時間,卻要比 A 的時間短,執行時序發生「錯亂」,最終這條資料結果是不符合預期的,
同樣地,采用「先更新快取,再更新資料庫」的方案,也會有類似問題,這里不再詳述,
除此之外,我們從「快取利用率」的角度來評估這個方案,也是不太推薦的,
這是因為每次資料發生變更,都「無腦」更新快取,但是快取中的資料不一定會被「馬上讀取」,這就會導致快取中可能存放了很多不常訪問的資料,浪費快取資源,
而且很多情況下,寫到快取中的值,并不是與資料庫中的值一一對應的,很有可能是先查詢資料庫,再經過一系列「計算」得出一個值,才把這個值才寫到快取中,
由此可見,這種「更新資料庫 + 更新快取」的方案,不僅快取利用率不高,還會造成機器性能的浪費,
所以此時我們需要考慮另外一種方案:洗掉快取,
四、洗掉快取可以保證一致性嗎?
洗掉快取對應的方案也有 2 種:
- 先洗掉快取,后更新資料庫
- 先更新資料庫,后洗掉快取
經過前面的分析我們已經得知,但凡「第二步」操作失敗,都會導致資料不一致,
這里我不再詳述具體場景,你可以按照前面的思路推演一下,就可以看到依舊存在資料不一致的情況,
這里我們重點來看「并發」問題,
1、先洗掉快取,后更新資料庫
如果有 2 個執行緒要并發「讀寫」資料,可能會發生以下場景:
- 執行緒 A 要更新 X = 2(原值 X = 1)
- 執行緒 A 先洗掉快取
- 執行緒 B 讀快取,發現不存在,從資料庫中讀取到舊值(X = 1)
- 執行緒 A 將新值寫入資料庫(X = 2)
- 執行緒 B 將舊值寫入快取(X = 1)
最終 X 的值在快取中是 1(舊值),在資料庫中是 2(新值),發生不一致,
可見,先洗掉快取,后更新資料庫,當發生「讀+寫」并發時,還是存在資料不一致的情況,
2、先更新資料庫,后洗掉快取
依舊是 2 個執行緒并發「讀寫」資料:
- 快取中 X 不存在(資料庫 X = 1)
- 執行緒 A 讀取資料庫,得到舊值(X = 1)
- 執行緒 B 更新資料庫(X = 2)
- 執行緒 B 洗掉快取
- 執行緒 A 將舊值寫入快取(X = 1)
最終 X 的值在快取中是 1(舊值),在資料庫中是 2(新值),也發生不一致,
這種情況「理論」來說是可能發生的,但實際真的有可能發生嗎?
其實概率「很低」,這是因為它必須滿足 3 個條件:
- 快取剛好已失效
- 讀請求 + 寫請求并發
- 更新資料庫 + 洗掉快取的時間(步驟 3-4),要比讀資料庫 + 寫快取時間短(步驟 2 和 5)
仔細想一下,條件 3 發生的概率其實是非常低的,
因為寫資料庫一般會先「加鎖」,所以寫資料庫,通常是要比讀資料庫的時間更長的,
這么來看,「先更新資料庫 + 再洗掉快取」的方案,是可以保證資料一致性的,
所以,我們應該采用這種方案,來操作資料庫和快取,
好,解決了并發問題,我們繼續來看前面遺留的,第二步執行「失敗」導致資料不一致的問題,
五、如何保證兩步都執行成功?
前面我們分析到,無論是更新快取還是洗掉快取,只要第二步發生失敗,那么就會導致資料庫和快取不一致,
保證第二步成功執行,就是解決問題的關鍵,
想一下,程式在執行程序中發生例外,最簡單的解決辦法是什么?
答案是:重試,
是的,其實這里我們也可以這樣做,
無論是先操作快取,還是先操作資料庫,但凡后者執行失敗了,我們就可以發起重試,盡可能地去做「補償」,
那這是不是意味著,只要執行失敗,我們「無腦重試」就可以了呢?
答案是否定的,現實情況往往沒有想的這么簡單,失敗后立即重試的問題在于:
- 立即重試很大概率「還會失敗」
- 「重試次數」設定多少才合理?
- 重試會一直「占用」這個執行緒資源,無法服務其它客戶端請求
看到了么,雖然我們想通過重試的方式解決問題,但這種「同步」重試的方案依舊不嚴謹,
那更好的方案應該怎么做?
答案是:異步重試,什么是異步重試?
其實就是把重試請求寫到「訊息佇列」中,然后由專門的消費者來重試,直到成功,
或者更直接的做法,為了避免第二步執行失敗,我們可以把操作快取這一步,直接放到訊息佇列中,由消費者來操作快取,
到這里你可能會問,寫訊息佇列也有可能會失敗啊?而且,引入訊息佇列,這又增加了更多的維護成本,這樣做值得嗎?
這個問題很好,但我們思考這樣一個問題:如果在執行失敗的執行緒中一直重試,還沒等執行成功,此時如果專案「重啟」了,那這次重試請求也就「丟失」了,那這條資料就一直不一致了,
所以,這里我們必須把重試或第二步操作放到另一個「服務」中,這個服務用「訊息佇列」最為合適,這是因為訊息佇列的特性,正好符合我們的需求:
- 訊息佇列保證可靠性:寫到佇列中的訊息,成功消費之前不會丟失(重啟專案也不擔心)
- 訊息佇列保證訊息成功投遞:下游從佇列拉取訊息,成功消費后才會洗掉訊息,否則還會繼續投遞訊息給消費者(符合我們重試的場景)
至于寫佇列失敗和訊息佇列的維護成本問題:
- 寫佇列失敗:操作快取和寫訊息佇列,「同時失敗」的概率其實是很小的
- 維護成本:我們專案中一般都會用到訊息佇列,維護成本并沒有新增很多
所以,引入訊息佇列來解決這個問題,是比較合適的,這時架構模型就變成了這樣:

那如果你確實不想在應用中去寫訊息佇列,是否有更簡單的方案,同時又可以保證一致性呢?
方案還是有的,這就是近幾年比較流行的解決方案:訂閱資料庫變更日志,再操作快取,
具體來講就是,我們的業務應用在修改資料時,「只需」修改資料庫,無需操作快取,
那什么時候操作快取呢?這就和資料庫的「變更日志」有關了,
拿 MySQL 舉例,當一條資料發生修改時,MySQL 就會產生一條變更日志(Binlog),我們可以訂閱這個日志,拿到具體操作的資料,然后再根據這條資料,去洗掉對應的快取,

訂閱變更日志,目前也有了比較成熟的開源中間件,例如阿里的 canal,使用這種方案的優點在于:
- 無需考慮寫訊息佇列失敗情況:只要寫 MySQL 成功,Binlog 肯定會有
- 自動投遞到下游佇列:canal 自動把資料庫變更日志「投遞」給下游的訊息佇列
當然,與此同時,我們需要投入精力去維護 canal 的高可用和穩定性,
如果你有留意觀察很多資料庫的特性,就會發現其實很多資料庫都逐漸開始提供「訂閱變更日志」的功能了,相信不遠的將來,我們就不用通過中間件來拉取日志,自己寫程式就可以訂閱變更日志了,這樣可以進一步簡化流程,
至此,我們可以得出結論,想要保證資料庫和快取一致性,推薦采用「先更新資料庫,再洗掉快取」方案,并配合「訊息佇列」或「訂閱變更日志」的方式來做,
訂閱變更日志,目前也有了比較成熟的開源中間件,例如阿里的 canal,使用這種方案的優點在于:
- 無需考慮寫訊息佇列失敗情況:只要寫 MySQL 成功,Binlog 肯定會有
- 自動投遞到下游佇列:canal 自動把資料庫變更日志「投遞」給下游的訊息佇列
當然,與此同時,我們需要投入精力去維護 canal 的高可用和穩定性,
如果你有留意觀察很多資料庫的特性,就會發現其實很多資料庫都逐漸開始提供「訂閱變更日志」的功能了,相信不遠的將來,我們就不用通過中間件來拉取日志,自己寫程式就可以訂閱變更日志了,這樣可以進一步簡化流程,
至此,我們可以得出結論,想要保證資料庫和快取一致性,推薦采用「先更新資料庫,再洗掉快取」方案,并配合「訊息佇列」或「訂閱變更日志」的方式來做,
六、主從庫延遲和延遲雙刪問題
到這里,還有 2 個問題,是我們沒有重點分析過的,
第一個問題,還記得前面講到的「先洗掉快取,再更新資料庫」方案,導致不一致的場景么?
這里我再把例子拿過來讓你復習一下:
2 個執行緒要并發「讀寫」資料,可能會發生以下場景:
- 執行緒 A 要更新 X = 2(原值 X = 1)
- 執行緒 A 先洗掉快取
- 執行緒 B 讀快取,發現不存在,從資料庫中讀取到舊值(X = 1)
- 執行緒 A 將新值寫入資料庫(X = 2)
- 執行緒 B 將舊值寫入快取(X = 1)
最終 X 的值在快取中是 1(舊值),在資料庫中是 2(新值),發生不一致,
第二個問題:是關于「讀寫分離 + 主從復制延遲」情況下,快取和資料庫一致性的問題,
在「先更新資料庫,再洗掉快取」方案下,「讀寫分離 + 主從庫延遲」其實也會導致不一致:
- 執行緒 A 更新主庫 X = 2(原值 X = 1)
- 執行緒 A 洗掉快取
- 執行緒 B 查詢快取,沒有命中,查詢「從庫」得到舊值(從庫 X = 1)
- 從庫「同步」完成(主從庫 X = 2)
- 執行緒 B 將「舊值」寫入快取(X = 1)
最終 X 的值在快取中是 1(舊值),在主從庫中是 2(新值),也發生不一致,
看到了么?這 2 個問題的核心在于:快取都被回種了「舊值」,
那怎么解決這類問題呢?
最有效的辦法就是,把快取刪掉,
但是,不能立即刪,而是需要「延遲刪」,這就是業界給出的方案:快取延遲雙刪策略,
按照延時雙刪策略,這 2 個問題的解決方案是這樣的:
解決第一個問題:在執行緒 A 洗掉快取、更新完資料庫之后,先「休眠一會」,再「洗掉」一次快取,
解決第二個問題:執行緒 A 可以生成一條「延時訊息」,寫到訊息佇列中,消費者延時「洗掉」快取,
這兩個方案的目的,都是為了把快取清掉,這樣一來,下次就可以從資料庫讀取到最新值,寫入快取,
但問題來了,這個「延遲洗掉」快取,延遲時間到底設定要多久呢?
- 問題1:延遲時間要大于「主從復制」的延遲時間
- 問題2:延遲時間要大于執行緒 B 讀取資料庫 + 寫入快取的時間
但是,這個時間在分布式和高并發場景下,其實是很難評估的,
很多時候,我們都是憑借經驗大致估算這個延遲時間,例如延遲 1-5s,只能盡可能地降低不一致的概率,
所以你看,采用這種方案,也只是盡可能保證一致性而已,極端情況下,還是有可能發生不一致,
所以實際使用中,我還是建議你采用「先更新資料庫,再洗掉快取」的方案,同時,要盡可能地保證「主從復制」不要有太大延遲,降低出問題的概率,
七、可以做到強一致嗎?
看到這里你可能會想,這些方案還是不夠完美,我就想讓快取和資料庫「強一致」,到底能不能做到呢?
其實很難,
要想做到強一致,最常見的方案是 2PC、3PC、Paxos、Raft 這類一致性協議,但它們的性能往往比較差,而且這些方案也比較復雜,還要考慮各種容錯問題,
相反,這時我們換個角度思考一下:我們引入快取的目的是什么?
沒錯,性能,
一旦我們決定使用快取,那必然要面臨一致性問題,性能和一致性就像天平的兩端,無法做到都滿足要求,
而且,就拿我們前面講到的方案來說,當操作資料庫和快取完成之前,只要有其它請求可以進來,都有可能查到「中間狀態」的資料,
所以如果非要追求強一致,那必須要求所有更新操作完成之前期間,不能有「任何請求」進來,
雖然我們可以通過加「分布鎖」的方式來實作,但我們要付出的代價,很可能會超過引入快取帶來的性能提升,
所以,既然決定使用快取,就必須容忍「一致性」問題,我們只能盡可能地去降低問題出現的概率,
同時我們也要知道,快取都是有「失效時間」的,就算在這期間存在短期不一致,我們依舊有失效時間來兜底,這樣也能達到最終一致,
八、總結
好了,總結一下這篇文章的重點,
1、想要提高應用的性能,可以引入「快取」來解決
2、引入快取后,需要考慮快取和資料庫一致性問題,可選的方案有:「更新資料庫 + 更新快取」、「更新資料庫 + 洗掉快取」
3、更新資料庫 + 更新快取方案,在「并發」場景下無法保證快取和資料一致性,且存在「快取資源浪費」和「機器性能浪費」的情況發生
4、在更新資料庫 + 洗掉快取的方案中,「先洗掉快取,再更新資料庫」在「并發」場景下依舊有資料不一致問題,解決方案是「延遲雙刪」,但這個延遲時間很難評估,所以推薦用「先更新資料庫,再洗掉快取」的方案
5、在「先更新資料庫,再洗掉快取」方案下,為了保證兩步都成功執行,需配合「訊息佇列」或「訂閱變更日志」的方案來做,本質是通過「重試」的方式保證資料一致性
6、在「先更新資料庫,再洗掉快取」方案下,「讀寫分離 + 主從庫延遲」也會導致快取和資料庫不一致,緩解此問題的方案是「延遲雙刪」,憑借經驗發送「延遲訊息」到佇列中,延遲洗掉快取,同時也要控制主從庫延遲,盡可能降低不一致發生的概率,
后記
本以為這個老生常談的話題,寫起來很好寫,沒想到在寫的程序中,還是挖到了很多之前沒有深度思考過的細節,
在這里我也分享 4 點心得給你:
1、性能和一致性不能同時滿足,為了性能考慮,通常會采用「最終一致性」的方案
2、掌握快取和資料庫一致性問題,核心問題有 3 點:快取利用率、并發、快取 + 資料庫一起成功問題
3、失敗場景下要保證一致性,常見手段就是「重試」,同步重試會影響吞吐量,所以通常會采用異步重試的方案
4、訂閱變更日志的思想,本質是把權威資料源(例如 MySQL)當做 leader 副本,讓其它異質系統(例如 Redis / Elasticsearch)成為它的 follower 副本,通過同步變更日志的方式,保證 leader 和 follower 之間保持一致
很多一致性問題,都會采用這些方案來解決,希望我的這些心得對你有所啟發,
作者丨Magic Kaito
來源丨公眾號:水滴與銀彈(ID:waterdrop_bullet)
本文來自博客園,作者:古道輕風,轉載請注明原文鏈接:https://www.cnblogs.com/88223100/p/Cache_and_database_consistency_issues.html
轉載請註明出處,本文鏈接:https://www.uj5u.com/shujuku/425001.html
標籤:NoSQL
