(討論)緩存同步、如何保證緩存一致性、緩存誤用

緩存誤用

緩存,是互聯網分層架構中,很是重要的一個部分,一般用它來下降數據庫壓力,提高系統總體性能,縮短訪問時間。html

有架構師說「緩存是萬金油,哪裏有問題,加個緩存,就能優化」,緩存的濫用,可能會致使一些錯誤用法。nginx

緩存,你真的用對了麼?redis

誤用一:把緩存做爲服務與服務之間傳遞數據的媒介
clipboard.png算法

如上圖:
服務1和服務2約定好key和value,經過緩存傳遞數據
服務1將數據寫入緩存,服務2從緩存讀取數據,達到兩個服務通訊的目的數據庫

該方案存在的問題是:
一、數據管道,數據通知場景,MQ更加適合
(1)MQ是互聯網常見的邏輯解耦,物理解耦組件,支持1對1,1對多各類模式,很是成熟的數據通道,而cache反而會將service-A/B/C/D耦合在一塊兒,你們要彼此協同約定key的格式,ip地址等
(2)MQ可以支持push,而cache只能拉取,不實時,有時延
(3)MQ自然支持集羣,支持高可用,而cache未必
(4)MQ能支持數據落地,cache具有將數據存在內存裏,具備「易失」性,固然,有些cache支持落地,但互聯網技術選型的原則是,讓專業的軟件幹專業的事情:nginx作反向代理,db作固化,cache作緩存,mq作通道json

二、多個服務關聯同一個緩存實例,會致使服務耦合
(1)你們要彼此協同約定key的格式,ip地址等,耦合
(2)約定好同一個key,可能會產生數據覆蓋,致使數據不一致
(3)不一樣服務業務模式,數據量,併發量不同,會由於一個cache相互影響,例如service-A數據量大,佔用了cache的絕大部份內存,會致使service-B的熱數據所有被擠出cache,致使cache失效;又例如service-A併發量高,佔用了cache的絕大部分鏈接,會致使service-B拿不到cache的鏈接,從而服務異常後端

誤用二:使用緩存未考慮雪崩
clipboard.png緩存

常規的緩存玩法,如上圖:
服務先讀緩存,緩存命中則返回
緩存不命中,再讀數據庫網絡

何時會產生雪崩?
答:若是緩存掛掉,全部的請求會壓到數據庫,若是未提早作容量預估,可能會把數據庫壓垮(在緩存恢復以前,數據庫可能一直都起不來),致使系統總體不可服務。架構

如何應對潛在的雪崩?
答:提早作容量預估,若是緩存掛掉,數據庫仍能扛住,才能執行上述方案。

不然,就要進一步設計。

常見方案一:高可用緩存
clipboard.png

如上圖:使用高可用緩存集羣,一個緩存實例掛掉後,可以自動作故障轉移。

常見方案二:緩存水平切分
clipboard.png

如上圖:使用緩存水平切分(推薦使用一致性哈希算法進行切分),一個緩存實例掛掉後,不至於全部的流量都壓到數據庫上。

誤用三:調用方緩存數據
clipboard.png

如上圖:
服務提供方緩存,向調用方屏蔽數據獲取的複雜性(這個沒問題)
服務調用方,也緩存一份數據,先讀本身的緩存,再決定是否調用服務(這個有問題)

該方案存在的問題是:
一、調用方須要關注數據獲取的複雜性(耦合問題)
二、更嚴重的,服務修改db裏的數據,淘汰了服務cache以後,難以通知調用方淘汰其cache裏的數據,從而致使數據不一致(帶入一致性問題)
三、有人說,服務能夠經過MQ通知調用方淘汰數據,額,難道下游的服務要依賴上游的調用方,分層架構設計不是這麼玩的(反向依賴問題)

誤用四:多服務共用緩存實例
clipboard.png

如上圖:服務A和服務B共用一個緩存實例(不是經過這個緩存實例交互數據)

該方案存在的問題是:

一、可能致使key衝突,彼此沖掉對方的數據
畫外音:可能須要服務A和服務B提早約定好了key,以確保不衝突,常見的約定方式是使用namespace:key的方式來作key。

二、不一樣服務對應的數據量,吞吐量不同,共用一個實例容易致使一個服務把另外一個服務的熱數據擠出去

三、共用一個實例,會致使服務之間的耦合,與微服務架構的「數據庫,緩存私有」的設計原則是相悖的

建議的玩法是
clipboard.png

如上圖:各個服務私有化本身的數據存儲,對上游屏蔽底層的複雜性。

總結
一、服務與服務之間不要經過緩存傳遞數據

二、若是緩存掛掉,可能致使雪崩,此時要作高可用緩存,或者水平切分

三、調用方不宜再單獨使用緩存存儲服務底層的數據,容易出現數據不一致,以及反向依賴

四、不一樣服務,緩存實例要作垂直拆分

緩存,到底是淘汰,仍是修改?

KV緩存都緩存了一些什麼數據?
答:
(1)樸素類型的數據,例如:int
(2)序列化後的對象,例如:User實體,本質是binary
(3)文本數據,例如:json或者html
(4)...

淘汰緩存中的這些數據,修改緩存中的這些數據,有什麼差異?
答:
(1)淘汰某個key,操做簡單,直接將key置爲無效,但下一次該key的訪問會cache miss
(2)修改某個key的內容,邏輯相對複雜,但下一次該key的訪問仍會cache hit

能夠看到,差別僅僅在於一次cache miss。

緩存中的value數據通常是怎麼修改的?
答:
(1)樸素類型的數據,直接set修改後的值便可
(2)序列化後的對象:通常須要先get數據,反序列化成對象,修改其中的成員,再序列化爲binary,再set數據
(3)json或者html數據:通常也須要先get文本,parse成dom樹對象,修改相關元素,序列化爲文本,再set數據

結論:對於對象類型,或者文本類型,修改緩存value的成本較高,通常選擇直接淘汰緩存。

問:對於樸素類型的數據,究竟應該修改緩存,仍是淘汰緩存?
答:仍然視狀況而定。

案例1:
假設,緩存裏存了某一個用戶uid=123的餘額是money=100元,業務場景是,購買了一個商品pid=456。

分析:若是修改緩存,可能須要:
(1)去db查詢pid的價格是50元
(2)去db查詢活動的折扣是8折(商品實際價格是40元)
(3)去db查詢用戶的優惠券是10元(用戶實際要支付30元)
(4)從cache查詢get用戶的餘額是100元
(5)計算出剩餘餘額是100 - 30 = 70
(6)到cache設置set用戶的餘額是70
爲了不一次cache miss,須要額外增長若干次db與cache的交互,得不償失。

結論:此時,應該淘汰緩存,而不是修改緩存。

案例2:
假設,緩存裏存了某一個用戶uid=123的餘額是money=100元,業務場景是,須要扣減30元。

分析:若是修改緩存,須要:
(1)從cache查詢get用戶的餘額是100元
(2)計算出剩餘餘額是100 - 30 = 70
(3)到cache設置set用戶的餘額是70
爲了不一次cache miss,須要額外增長若干次cache的交互,以及業務的計算,得不償失。

結論:此時,應該淘汰緩存,而不是修改緩存。

案例3:
假設,緩存裏存了某一個用戶uid=123的餘額是money=100元,業務場景是,餘額要變爲70元。

分析:若是修改緩存,須要:
(1)到cache設置set用戶的餘額是70
修改緩存成本很低。

結論:此時,能夠選擇修改緩存。固然,若是選擇淘汰緩存,只會額外增長一次cache miss,成本也不高。

總結:
容許cache miss的KV緩存寫場景:

大部分狀況,修改value成本會高於「增長一次cache miss」,所以應該淘汰緩存
若是還在糾結,老是淘汰緩存,問題也不大

先操做數據庫,仍是先操做緩存?

這裏分了兩種觀點,Cache Aside Pattern的觀點、沈老師的觀點。下面兩種觀點分析一下。

Cache Aside Pattern

什麼是「Cache Aside Pattern」?
答:旁路緩存方案的經驗實踐,這個實踐又分讀實踐,寫實踐。

對於讀請求
先讀cache,再讀db
若是,cache hit,則直接返回數據
若是,cache miss,則訪問db,並將數據set回緩存

clipboard.png

(1)先從cache中嘗試get數據,結果miss了
(2)再從db中讀取數據,從庫,讀寫分離
(3)最後把數據set回cache,方便下次讀命中

對於寫請求
先操做數據庫,再淘汰緩存(淘汰緩存,而不是更新緩存)
clipboard.png

如上圖:
(1)第一步要操做數據庫,第二步操做緩存
(2)緩存,採用delete淘汰,而不是set更新

Cache Aside Pattern爲何建議淘汰緩存,而不是更新緩存?
答:若是更新緩存,在併發寫時,可能出現數據不一致。
clipboard.png

如上圖所示,若是採用set緩存。

在1和2兩個併發寫發生時,因爲沒法保證時序,此時無論先操做緩存仍是先操做數據庫,均可能出現:
(1)請求1先操做數據庫,請求2後操做數據庫
(2)請求2先set了緩存,請求1後set了緩存
致使,數據庫與緩存之間的數據不一致。
因此,Cache Aside Pattern建議,delete緩存,而不是set緩存。

Cache Aside Pattern爲何建議先操做數據庫,再操做緩存?
答:若是先操做緩存,在讀寫併發時,可能出現數據不一致。
clipboard.png

如上圖所示,若是先操做緩存。

在1和2併發讀寫發生時,因爲沒法保證時序,可能出現:
(1)寫請求淘汰了緩存
(2)寫請求操做了數據庫(主從同步沒有完成)
(3)讀請求讀了緩存(cache miss)
(4)讀請求讀了從庫(讀了一箇舊數據)
(5)讀請求set回緩存(set了一箇舊數據)
(6)數據庫主從同步完成
致使,數據庫與緩存的數據不一致。

因此,Cache Aside Pattern建議,先操做數據庫,再操做緩存。

Cache Aside Pattern方案存在什麼問題?
答:若是先操做數據庫,再淘汰緩存,在原子性被破壞時:
(1)修改數據庫成功了
(2)淘汰緩存失敗了
致使,數據庫與緩存的數據不一致。

我的看法:這裏我的以爲可使用重試的方法,在淘汰緩存的時候,若是失敗,則重試必定的次數。若是失敗必定次數還不行,那就是其餘緣由了。好比說redis故障、內網出了問題。

關於這個問題,沈老師的解決方案是,使用先操做緩存(delete),再操做數據庫。假如刪除緩存成功,更新數據庫失敗了。緩存裏沒有數據,數據庫裏是以前的數據,數據沒有不一致,對業務無影響。只是下一次讀取,會多一次cache miss。這裏我以爲沈老師可能忽略了併發的問題,好比說如下狀況:
一個寫請求過來,刪除了緩存,準備更新數據庫(還沒更新完成)。
而後一個讀請求過來,緩存未命中,從數據庫讀取舊數據,再次放到緩存中,這時候,數據庫更新完成了。此時的狀況是,緩存中是舊數據,數據庫裏面是新數據,一樣存在數據不一致的問題。
如圖:
圖片描述

不一致解決場景及解決方案

答:發生寫請求後(無論是先操做DB,仍是先淘汰Cache),在主從數據庫同步完成以前,若是有讀請求,均可能發生讀Cache Miss,讀從庫把舊數據存入緩存的狀況。此時怎麼辦呢?

數據庫主從不一致
先回顧下,無緩存時,數據庫主從不一致問題。
clipboard.png

如上圖,發生的場景是,寫後馬上讀:
(1)主庫一個寫請求(主從沒同步完成)
(2)從庫接着一個讀請求,讀到了舊數據
(3)最後,主從同步完成
致使的結果是:主動同步完成以前,會讀取到舊數據。

能夠看到,主從不一致的影響時間很短,在主從同步完成後,就會讀到新數據。

2、緩存與數據庫不一致
再看,引入緩存後,緩存和數據庫不一致問題。
clipboard.png

如上圖,發生的場景也是,寫後馬上讀:
(1+2)先一個寫請求,淘汰緩存,寫數據庫

(3+4+5)接着馬上一個讀請求,讀緩存,cache miss,讀從庫,寫緩存放入數據,以便後續的讀可以cache hit(主從同步沒有完成,緩存中放入了舊數據)

(6)最後,主從同步完成

致使的結果是:舊數據放入緩存,即便主從同步完成,後續仍然會從緩存一直讀取到舊數據。

能夠看到,加入緩存後,致使的不一致影響時間會很長,而且最終也不會達到一致。

3、問題分析
能夠看到,這裏提到的緩存與數據庫數據不一致,根本上是由數據庫主從不一致引發的。當主庫上發生寫操做以後,從庫binlog同步的時間間隔內,讀請求,可能致使有舊數據入緩存。

思路:那能不能寫操做記錄下來,在主從時延的時間段內,讀取修改過的數據的話,強制讀主,而且更新緩存,這樣子緩存內的數據就是最新。在主從時延事後,這部分數據繼續讀從庫,從而繼續利用從庫提升讀取能力。

3、不一致解決方案
選擇性讀主
能夠利用一個緩存記錄必須讀主的數據。
clipboard.png

如上圖,當寫請求發生時:
(1)寫主庫
(2)將哪一個庫,哪一個表,哪一個主鍵三個信息拼裝一個key設置到cache裏,這條記錄的超時時間,設置爲「主從同步時延」
PS:key的格式爲「db:table:PK」,假設主從延時爲1s,這個key的cache超時時間也爲1s。

clipboard.png

如上圖,當讀請求發生時:
這是要讀哪一個庫,哪一個表,哪一個主鍵的數據呢,也將這三個信息拼裝一個key,到cache裏去查詢,若是,
(1)cache裏有這個key,說明1s內剛發生過寫請求,數據庫主從同步可能尚未完成,此時就應該去主庫查詢。而且把主庫的數據set到緩存中,防止下一次cahce miss。
(2)cache裏沒有這個key,說明最近沒有發生過寫請求,此時就能夠去從庫查詢

以此,保證讀到的必定不是不一致的髒數據。

PS:若是系統能夠接收短期的不一致,建議建議定時更新緩存就能夠了。避免系統過於複雜。

進程內緩存

除了常見的redis/memcache等進程外緩存服務,緩存還有一種常見的玩法,進程內緩存。

什麼是進程內緩存?

答:將一些數據緩存在站點,或者服務的進程內,這就是進程內緩存。

進程內緩存的實現載體,最簡單的,能夠是一個帶鎖的Map。又或者,可使用第三方庫,例如leveldb、guave本地緩存

進程內緩存能存儲啥?

答:redis/memcache等進程外緩存服務能存什麼,進程內緩存就能存什麼。

clipboard.png

如上圖,能夠存儲json數據,能夠存儲html頁面,能夠存儲對象。

進程內緩存有什麼好處?

答:與沒有緩存相比,進程內緩存的好處是,數據讀取再也不須要訪問後端,例如數據庫。
clipboard.png
如上圖,整個訪問流程要通過1,2,3,4四個步驟。

若是引入進程內緩存,
clipboard.png
如上圖,整個訪問流程只要通過1,2兩個步驟。

與進程外緩存相比(例如redis/memcache),進程內緩存省去了網絡開銷,因此一來節省了內網帶寬,二來響應時延會更低。

進程內緩存有什麼缺點?

答:統一緩存服務雖然多一次網絡交互,但還是統一存儲。
clipboard.png
如上圖,站點和服務中的多個節點訪問統一的緩存服務,數據統一存儲,容易保證數據的一致性。

clipboard.png
而進程內緩存,如上圖,若是數據緩存在站點和服務的多個節點內,數據存了多份,一致性比較難保障。

如何保證進程內緩存的數據一致性?
答:保障進程內緩存一致性,有三種方案。

第一種方案
能夠經過單節點通知其餘節點。如上圖:寫請求發生在server1,在修改完本身內存數據與數據庫中的數據以後,能夠主動通知其餘server節點,也修改內存的數據。以下圖:
clipboard.png

這種方案的缺點是:同一功能的一個集羣的多個節點,相互耦合在一塊兒,特別是節點較多時,網狀鏈接關係極其複雜。

第二種方案
能夠經過MQ通知其餘節點。如上圖,寫請求發生在server1,在修改完本身內存數據與數據庫中的數據以後,給MQ發佈數據變化通知,其餘server節點訂閱MQ消息,也修改內存數據。
clipboard.png

這種方案雖然解除了節點之間的耦合,但引入了MQ,使得系統更加複雜。

前兩種方案,節點數量越多,數據冗餘份數越多,數據同時更新的原子性越難保證,一致性也就越難保證。

第三種方案
爲了不耦合,下降複雜性,乾脆放棄了「實時一致性」,每一個節點啓動一個timer,定時從後端拉取最新的數據,更新內存緩存。在有節點更新後端數據,而其餘節點經過timer更新數據之間,會讀到髒數據。
clipboard.png

爲何不能頻繁使用進程內緩存?

答:分層架構設計,有一條準則:站點層、服務層要作到無數據無狀態,這樣才能任意的加節點水平擴展,數據和狀態儘可能存儲到後端的數據存儲服務,例如數據庫服務或者緩存服務。
能夠看到,站點與服務的進程內緩存,實際上違背了分層架構設計的無狀態準則,故通常不推薦使用。

何時可使用進程內緩存?

答:如下狀況,能夠考慮使用進程內緩存。

狀況一
只讀數據,能夠考慮在進程啓動時加載到內存。
畫外音:此時也能夠把數據加載到redis / memcache,進程外緩存服務也能解決這類問題。

狀況二
極其高併發的,若是透傳後端壓力極大的場景,能夠考慮使用進程內緩存。
例如,秒殺業務,併發量極高,須要站點層擋住流量,可使用內存緩存。

狀況三
必定程度上容許數據不一致業務。
例如,有一些計數場景,運營場景,頁面對數據一致性要求較低,能夠考慮使用進程內頁面緩存。

再次強調,進程內緩存的適用場景並不如redis/memcache普遍,不要爲了炫技而使用。更多的時候,仍是老老實實使用redis/mc吧。

相關文章
相關標籤/搜索