緩存一般是直接將數據放到離計算最近的地方(目前大部分放在內存中),解決 CPU 和 I/O 的速度不匹配的問題,用來加快計算處理速度,通常會對熱點數據進行緩存,保證較高的命中率。在互聯網的架構設計中,數據庫及緩存一般相互配合使用來滿足不同的場景需求,比如在大流量的請求中會使用緩存來加速。
Redis 在互聯網行業中使用最爲廣泛。Redis 在很多時候也被稱爲“內存數據庫”,它集合了緩存和數據庫的優勢,但並非開啓持久化和主備同步機制就可以高枕無憂。從架構設計的角度思考:緩存就是緩存,緩存數據會隨時丟失,緩存存在的目的是攔截到數據庫的請求,相比數據的可靠性、一致性,還是吞吐量、穩定性優先。
緩存有三大矛盾:
- 緩存實時性和一致性問題:當有了寫入後咋辦?
- 緩存的穿透問題:當沒有讀到咋辦?
- 緩存對數據庫高併發訪問:都來訪問數據庫咋辦?
第一個也就是本問題。而解決這三大矛盾的刷新策略包括:
- 實時策略——用戶體驗好,是默認應該使用的策略;
- 異步策略——適用於併發量大,但是數據沒有那麼關鍵的情況,好處是實時性好;
- 定時策略——併發量實在太大,數據量也大的情況,異步都難以滿足的場景;
寫入數據庫成功,即讓緩存失效,下一次讀取時再緩存。這是緩存的實時策略。當然,並不適用於所有的場景。
實時策略是最常用的策略,也是保持實時性最好的策略:
- 讀取的過程,應用程序先從 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 Cache-Aside 寫流程
Cache-Aside Pattern的寫請求流程如下:
更新的時候,先更新數據庫,然後再刪除緩存。
2 讀寫穿透 Read-Through/Write-Through
Read/Write Through模式中,服務端把緩存作爲主要數據存儲。應用程序跟數據庫緩存交互,都是通過抽象緩存層完成的。
2.1 Read-Through
Read-Through的簡要流程如下
- 從緩存讀取數據,讀到直接返回
- 如果讀取不到的話,從數據庫加載,寫入緩存後,再返回響應。
這個簡要流程是不是跟Cache-Aside很像呢?其實Read-Through就是多了一層Cache-Provider,流程如下:
Read-Through實際只是在Cache-Aside之上進行了一層封裝,它會讓程序代碼變得更簡潔,同時也減少數據源上的負載。
2.2 Write-Through
Write-Through模式下,當發生寫請求時,也是由緩存抽象層完成數據源和緩存數據的更新,流程如下:
3 異步緩存寫入 Write behind
Write behind跟Read-Through/Write-Through有相似的地方,都是由Cache Provider來負責緩存和數據庫的讀寫。它兩又有個很大的不同:Read/Write Through是同步更新緩存和數據的,Write Behind則是隻更新緩存,不直接更新數據庫,通過批量異步的方式來更新數據庫。
這種方式下,緩存和數據庫的一致性不強,對一致性要求高的系統要謹慎使用。但是它適合頻繁寫的場景,MySQL的InnoDB Buffer Pool機制就使用到這種模式。
操作緩存的時候,刪除緩存呢,還是更新緩存?
一般業務場景,我們使用的就是Cache-Aside模式。 有些小夥伴可能會問, Cache-Aside在寫入請求的時候,爲什麼是刪除緩存而不是更新緩存呢?
我們在操作緩存的時候,到底應該刪除緩存還是更新緩存呢?我們先來看個例子:
- 線程A先發起一個寫操作,第一步先更新數據庫
- 線程B再發起一個寫操作,第二步更新了數據庫
- 由於網絡等原因,線程B先更新了緩存
- 線程A更新緩存。
這時候,緩存保存的是A的數據(老數據),數據庫保存的是B的數據(新數據),數據不一致了,髒數據出現啦。如果是刪除緩存取代更新緩存則不會出現這個髒數據問題。
更新緩存相對於刪除緩存,還有兩點劣勢:
- 如果你寫入的緩存值,是經過複雜計算纔得到的話。更新緩存頻率高的話,就浪費性能啦。
- 在寫數據庫場景多,讀數據場景少的情況下,數據很多時候還沒被讀取到,又被更新了,這也浪費了性能呢(實際上,寫多的場景,用緩存也不是很划算了)
雙寫的情況下,先操作數據庫還是先操作緩存?
Cache-Aside緩存模式中,有些小夥伴還是有疑問,在寫入請求的時候,爲什麼是先操作數據庫呢?爲什麼不先操作緩存呢?
假設有A、B兩個請求,請求A做更新操作,請求B做查詢讀取操作。
- 線程A發起一個寫操作,第一步del cache
- 此時線程B發起一個讀操作,cache miss
- 線程B繼續讀DB,讀出來一個老數據
- 然後線程B把老數據設置入cache
- 線程A寫入DB最新的數據
醬紫就有問題啦,緩存和數據庫的數據不一致了。緩存保存的是老數據,數據庫保存的是新數據。因此,Cache-Aside緩存模式,選擇了先操作數據庫而不是先操作緩存。
緩存延時雙刪
有些小夥伴可能會說,不一定要先操作數據庫呀,採用緩存延時雙刪策略就好啦?什麼是延時雙刪呢?
- 先刪除緩存
- 再更新數據庫
- 休眠一會(比如1秒),再次刪除緩存。
這個休眠一會,一般多久呢?都是1秒?
這個休眠時間 = 讀業務邏輯數據的耗時 + 幾百毫秒。 爲了確保讀請求結束,寫請求可以刪除讀請求可能帶來的緩存髒數據。
刪除緩存重試機制
不管是延時雙刪還是Cache-Aside的先操作數據庫再刪除緩存,如果第二步的刪除緩存失敗呢,刪除失敗會導致髒數據哦~
刪除失敗就多刪除幾次呀,保證刪除緩存成功呀~ 所以可以引入刪除緩存重試機制
- 寫請求更新數據庫
- 緩存因爲某些原因,刪除失敗
- 把刪除失敗的key放到消息隊列
- 消費消息隊列的消息,獲取要刪除的key
- 重試刪除緩存操作
讀取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 來儲存。線程安全。