在緩存和數據庫雙寫場景下,一致性是如何保證的


緩存一般是直接將數據放到離計算最近的地方(目前大部分放在內存中),解決 CPU 和 I/O 的速度不匹配的問題,用來加快計算處理速度,通常會對熱點數據進行緩存,保證較高的命中率。在互聯網的架構設計中,數據庫及緩存一般相互配合使用來滿足不同的場景需求,比如在大流量的請求中會使用緩存來加速。

Redis 在互聯網行業中使用最爲廣泛。Redis 在很多時候也被稱爲“內存數據庫”,它集合了緩存和數據庫的優勢,但並非開啓持久化和主備同步機制就可以高枕無憂。從架構設計的角度思考:緩存就是緩存,緩存數據會隨時丟失,緩存存在的目的是攔截到數據庫的請求,相比數據的可靠性、一致性,還是吞吐量、穩定性優先。

緩存有三大矛盾:

  1. 緩存實時性和一致性問題:當有了寫入後咋辦?
  2. 緩存的穿透問題:當沒有讀到咋辦?
  3. 緩存對數據庫高併發訪問:都來訪問數據庫咋辦?

第一個也就是本問題。而解決這三大矛盾的刷新策略包括:

  1. 實時策略——用戶體驗好,是默認應該使用的策略;
  2. 異步策略——適用於併發量大,但是數據沒有那麼關鍵的情況,好處是實時性好;
  3. 定時策略——併發量實在太大,數據量也大的情況,異步都難以滿足的場景;

 

寫入數據庫成功,即讓緩存失效,下一次讀取時再緩存。這是緩存的實時策略。當然,並不適用於所有的場景。

實時策略是最常用的策略,也是保持實時性最好的策略:

  • 讀取的過程,應用程序先從 cache 取數據,沒有得到,則從數據庫中取數據,成功後,放到緩存中。如果命中,應用程序從 cache 中取數據,取到後返回。
  • 寫入的過程,把數據存到數據庫中,成功後,再讓緩存失效,失效後下次讀取的時候,會被寫入緩存。

從用戶體驗的角度,應該數據庫有了寫入,就馬上廢棄緩存,觸發一次數據庫的讀取,從而更新緩存。

然而,這和高併發就矛盾了——如果所有的都實時從數據庫裏面讀取,高併發場景下,數據庫往往受不了。

 

一臺MySQL,一臺Redis,兩臺應用服務器,用戶的數據存儲持久化在MySQL中,緩存在Redis,有請求的時候從Redis中獲取緩存的用戶數據,有修改則同時修改MySQL和Redis中的數據。現在問題是:
1. 先保存到MySQL和先保存到Redis都面臨着一個保存成功而另外一個保存失敗的情況,這樣,如何保證MySQL與Redis中的數據同步?
2. 兩臺應用服務器的併發訪問,如何保證數據的安全性?

 

一些用戶請求在某些情況下是可能重複發送的,如果是查詢類操作並無大礙,但其中有些涉及寫入操作,一旦重複了,可能會導致很嚴重的後果。例如交易接口如果重複請求,可能會重複下單。

 

要想達到數據一致性,需要保證兩點:

    • 無併發請求下,保證A和B步驟都能成功執行。
    • 併發請求下,在A和B步驟的間隔中,避免或消除其他線程的影響。

在緩存和數據庫在雙寫場景下,一致性是如何保證的?

 
 

談談一致性

 
 

一致性就是數據保持一致,在分佈式系統中,可以理解爲多個節點中數據的值是一致的。

  • 強一致性:這種一致性級別是最符合用戶直覺的,它要求系統寫入什麼,讀出來的也會是什麼,用戶體驗好,但實現起來往往對系統的性能影響大
  • 弱一致性:這種一致性級別約束了系統在寫入成功後,不承諾立即可以讀到寫入的值,也不承諾多久之後數據能夠達到一致,但會盡可能地保證到某個時間級別(比如秒級別)後,數據能夠達到一致狀態
  • 最終一致性:最終一致性是弱一致性的一個特例,系統會保證在一定時間內,能夠達到一個數據一致的狀態。這裏之所以將最終一致性單獨提出來,是因爲它是弱一致性中非常推崇的一種一致性模型,也是業界在大型分佈式系統的數據一致性上比較推崇的模型

三個經典的緩存模式

緩存可以提升性能、緩解數據庫壓力,但是使用緩存也會導致數據不一致性的問題。一般我們是如何使用緩存呢?有三種經典的緩存模式:

  • 旁路緩存模式 Cache-Aside Pattern
  • 讀寫穿透 Read-Through/Write through
  • 異步緩存寫入 Write behind

1 旁路緩存模式 Cache-Aside Pattern

Cache-Aside Pattern,即旁路緩存模式,它的提出是爲了儘可能地解決緩存與數據庫的數據不一致問題。

1.1 Cache-Aside讀流程

Cache-Aside Pattern的讀請求流程如下:

 
 
  1. 讀的時候,先讀緩存,緩存命中的話,直接返回數據
  2. 緩存沒有命中的話,就去讀數據庫,從數據庫取出數據,放入緩存後,同時返回響應。

1.2 Cache-Aside 寫流程

Cache-Aside Pattern的寫請求流程如下:

 
 

更新的時候,先更新數據庫,然後再刪除緩存

2 讀寫穿透 Read-Through/Write-Through

Read/Write Through模式中,服務端把緩存作爲主要數據存儲。應用程序跟數據庫緩存交互,都是通過抽象緩存層完成的。

2.1 Read-Through

Read-Through的簡要流程如下

 
 
  1. 從緩存讀取數據,讀到直接返回
  2. 如果讀取不到的話,從數據庫加載,寫入緩存後,再返回響應。

這個簡要流程是不是跟Cache-Aside很像呢?其實Read-Through就是多了一層Cache-Provider,流程如下:

 
 

Read-Through實際只是在Cache-Aside之上進行了一層封裝,它會讓程序代碼變得更簡潔,同時也減少數據源上的負載。

2.2 Write-Through

Write-Through模式下,當發生寫請求時,也是由緩存抽象層完成數據源和緩存數據的更新,流程如下:

 
 

3 異步緩存寫入 Write behind 

Write behindRead-Through/Write-Through有相似的地方,都是由Cache Provider來負責緩存和數據庫的讀寫。它兩又有個很大的不同:Read/Write Through是同步更新緩存和數據的,Write Behind則是隻更新緩存,不直接更新數據庫,通過批量異步的方式來更新數據庫。

 
 

這種方式下,緩存和數據庫的一致性不強,對一致性要求高的系統要謹慎使用。但是它適合頻繁寫的場景,MySQL的InnoDB Buffer Pool機制就使用到這種模式。

操作緩存的時候,刪除緩存呢,還是更新緩存?

一般業務場景,我們使用的就是Cache-Aside模式。 有些小夥伴可能會問, Cache-Aside在寫入請求的時候,爲什麼是刪除緩存而不是更新緩存呢?

 
 

我們在操作緩存的時候,到底應該刪除緩存還是更新緩存呢?我們先來看個例子:

  1. 線程A先發起一個寫操作,第一步先更新數據庫
  2. 線程B再發起一個寫操作,第二步更新了數據庫
  3. 由於網絡等原因,線程B先更新了緩存
  4. 線程A更新緩存。

這時候,緩存保存的是A的數據(老數據),數據庫保存的是B的數據(新數據),數據不一致了,髒數據出現啦。如果是刪除緩存取代更新緩存則不會出現這個髒數據問題。

更新緩存相對於刪除緩存,還有兩點劣勢:

  • 如果你寫入的緩存值,是經過複雜計算纔得到的話。更新緩存頻率高的話,就浪費性能啦。
  • 在寫數據庫場景多,讀數據場景少的情況下,數據很多時候還沒被讀取到,又被更新了,這也浪費了性能呢(實際上,寫多的場景,用緩存也不是很划算了)

雙寫的情況下,先操作數據庫還是先操作緩存?

Cache-Aside緩存模式中,有些小夥伴還是有疑問,在寫入請求的時候,爲什麼是先操作數據庫呢?爲什麼不先操作緩存呢?

假設有A、B兩個請求,請求A做更新操作,請求B做查詢讀取操作。


 
 
  1. 線程A發起一個寫操作,第一步del cache
  2. 此時線程B發起一個讀操作,cache miss
  3. 線程B繼續讀DB,讀出來一個老數據
  4. 然後線程B把老數據設置入cache
  5. 線程A寫入DB最新的數據

醬紫就有問題啦,緩存和數據庫的數據不一致了。緩存保存的是老數據,數據庫保存的是新數據。因此,Cache-Aside緩存模式,選擇了先操作數據庫而不是先操作緩存。

緩存延時雙刪

有些小夥伴可能會說,不一定要先操作數據庫呀,採用緩存延時雙刪策略就好啦?什麼是延時雙刪呢?

 
 
  1. 先刪除緩存
  2. 再更新數據庫
  3. 休眠一會(比如1秒),再次刪除緩存。

這個休眠一會,一般多久呢?都是1秒?

這個休眠時間 = 讀業務邏輯數據的耗時 + 幾百毫秒。 爲了確保讀請求結束,寫請求可以刪除讀請求可能帶來的緩存髒數據。

刪除緩存重試機制

不管是延時雙刪還是Cache-Aside的先操作數據庫再刪除緩存,如果第二步的刪除緩存失敗呢,刪除失敗會導致髒數據哦~

刪除失敗就多刪除幾次呀,保證刪除緩存成功呀~ 所以可以引入刪除緩存重試機制

 
 
  1. 寫請求更新數據庫
  2. 緩存因爲某些原因,刪除失敗
  3. 把刪除失敗的key放到消息隊列
  4. 消費消息隊列的消息,獲取要刪除的key
  5. 重試刪除緩存操作

讀取biglog異步刪除緩存

解析MySQL的binlog實現緩存同步,將數據庫中的數據同步到Redis

  • MySQL複製的原理
    • 主服務器操作數據,並將數據寫入Bin log
    • 從服務器調用I/O線程讀取主服務器的Bin log,並且寫入到自己的Relay log中,再調用SQL線程從Relay log中解析數據,從而同步到自己的數據庫中

總結起來就是,從服務器讀取主服務器Bin log中的數據,從而同步到自己的數據庫中。

canal是阿里巴巴旗下的一款開源項目,純Java開發。基於數據庫增量日誌解析,提供增量數據訂閱&消費,目前主要支持了MySQL(也支持mariaDB)

  • 架構:
    • server代表一個canal運行實例,對應於一個jvm
    • instance對應於一個數據隊列 (1個server對應1..n個instance)
    • instance模塊:
      • eventParser (數據源接入,模擬slave協議和master進行交互,協議解析)
      • eventSink (Parser和Store鏈接器,進行數據過濾,加工,分發的工作)
      • eventStore (數據存儲)
      • metaManager (增量訂閱&消費信息管理器)
  • 工作原理(模仿MySQL複製):
    • canal模擬mysql slave的交互協議,僞裝自己爲mysql slave,向mysql master發送dump協議
    • mysql master收到dump請求,開始推送binary log給slave(也就是canal)
    • canal解析binary log對象(原始爲byte流)
  • 大致的解析過程如下:
    • parse解析MySQL的Bin log,然後將數據放入到sink中
    • sink對數據進行過濾,加工,分發
    • store從sink中讀取解析好的數據存儲起來
    • 然後自己用設計代碼將store中的數據同步寫入Redis中就可以了
    • 其中parse/sink是框架封裝好的,我們做的是store的數據讀取那一步

 



重試刪除緩存機制還可以,就是會造成好多業務代碼入侵。其實,還可以通過數據庫的binlog來異步淘汰key

 
 

以mysql爲例 可以使用阿里的canal將binlog日誌採集發送到MQ隊列裏面,然後通過ACK機制確認處理這條更新消息,刪除緩存,保證數據緩存一致性

但如果只是進行刪除緩存,只刪除了一次,也可能會失敗。

就需要加上重試機制了。如果刪除緩存失敗,寫入重試表,使用定時任務重試。或者寫入mq,讓mq自動重試。

推薦使用mq自動重試機制。

在binlog訂閱者中如果刪除緩存失敗,則發送一條mq消息到mq服務器,在mq消費者中自動重試3次。如果有任意一次成功,則直接返回成功。如果重試3次後還是失敗,則該消息自動被放入死信隊列,後面可能需要人工介入。

 

binlog同步的方式相對比較優雅。

1、mysql發生變更產生一條binlog

2、binlog寫進消息隊列(MQ)

3、程序監聽消息隊列,得到binlog消息

4、解析binlog,得到變更的內容

5、將變更的內容更新至redis

由於藉助了MQ消息隊列,那無須擔心有漏變更的情況(MQ一般都能確保至少一次性)。兩個數據源的更新只能保證最終一致性,無法保證強一致性。


如果業務層要求必須讀取數據的強一致性,可以採取以下策略:

  • 暫存併發 讀請求

在更新數據庫時,先在Redis緩存客戶端暫存併發讀請求,等數據庫更新完、緩存值刪除後,再讀取數據,從而保證數據一致性。

讀寫請求入隊列,工作線程從隊列中取任務來依次執行

  • 修改服務Service連接池,id取模選取服務連接,能夠保證同一個數據的讀寫都落在同一個後端服務上。
  • 修改數據庫DB連接池,id取模選取DB連接,能夠保證同一個數據的讀寫在數據庫層面是串行的。
  • 使用Redis分佈式讀寫鎖

將淘汰緩存與更新庫表放入同一把寫鎖中,與其它讀請求互斥,防止其間產生舊數據。讀寫互斥、寫寫互斥、讀讀共享,可滿足讀多寫少的場景數據一致,也保證了併發性。並根據邏輯平均運行時間、響應超時時間來確定過期時間。



小結:

 

讀讀併發解決方案:

a. 延遲消息憑藉經驗發送「延遲消息」到隊列中,延遲刪除緩存,同時也要控制主從庫延遲,儘可能降低不一致發生的概率。

b. 訂閱binlog,異步刪除。通過數據庫的binlog來異步淘汰key,利用工具(canal)將binlog日誌採集發送到MQ中,然後通過ACK機制確認處理刪除緩存。

c. 刪除消息寫入數據庫通過比對數據庫中的數據,進行刪除確認 先更新數據庫再刪除緩存,有可能導致請求因緩存缺失而訪問數據庫,給數據庫帶來壓力,也就是緩存穿透的問題。針對緩存穿透問題,可以用緩存空結果、布隆過濾器進行解決。

d. 加鎖更新數據時,加寫鎖;查詢數據時,加讀鎖。


讀寫併發解決方案:
保存請求對緩存的讀取記錄,延時消息比較,發現不一致後,做業務補償
 
寫寫併發解決方案:
對於寫請求,需要配合分佈式鎖使用。
寫請求進來時,針對同一個資源的修改操作,先加分佈式鎖,保證同一時間只有一個線程去更新數據庫和緩存;沒有拿到鎖的線程把操作放入到隊列中,延時處理。用這種方式保證多個線程操作同一資源的順序性,以此保證一致性。

其中,分佈式鎖的實現可以使用以下策略:

- 樂觀鎖:使用版本號, updatetime;緩存中,只允許高版本覆蓋低版本

- watch 實現 redis 樂觀鎖:watch 監控redisKey 狀態值,創建redis 事務,key+1, 執行事務,key 被修改過則回滾

- setnx : 獲取鎖,set/setnx;釋放鎖:del 命令/ lua 腳本 

- redisson 分佈式鎖:利用redis 的hash 結構作爲儲存單元,將業務指定的名稱作爲key, 將隨機uuid 和 線程id 作爲 field, 最後將加樂的次數作爲 value 來儲存。線程安全。

 

 



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章