(討論)快取同步、如何保證快取一致性、快取誤用
PS:轉載自《架構師之路》,覺得受益匪淺,故收錄之
快取誤用
快取,是網際網路分層架構中,非常重要的一個部分,通常用它來降低資料庫壓力,提升系統整體效能,縮短訪問時間。
有架構師說“快取是萬金油,哪裡有問題,加個快取,就能優化”,快取的濫用,可能會導致一些錯誤用法。
快取,你真的用對了麼?
誤用一:把快取作為服務與服務之間傳遞資料的媒介
image.png
如上圖:
服務1和服務2約定好key和value,通過快取傳遞資料
服務1將資料寫入快取,服務2從快取讀取資料,達到兩個服務通訊的目的
該方案存在的問題是:
1、資料管道,資料通知場景,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做通道
2、多個服務關聯同一個快取例項,會導致服務耦合
(1)大家要彼此協同約定key的格式,ip地址等,耦合
(2)約定好同一個key,可能會產生資料覆蓋,導致資料不一致
(3)不同服務業務模式,資料量,併發量不一樣,會因為一個cache相互影響,例如service-A資料量大,佔用了cache的絕大部分記憶體,會導致service-B的熱資料全部被擠出cache,導致cache失效;又例如service-A併發量高,佔用了cache的絕大部分連線,會導致service-B拿不到cache的連線,從而服務異常
誤用二:使用快取未考慮雪崩
image.png
常規的快取玩法,如上圖:
服務先讀快取,快取命中則返回
快取不命中,再讀資料庫
什麼時候會產生雪崩?
答:如果快取掛掉,所有的請求會壓到資料庫,如果未提前做容量預估,可能會把資料庫壓垮(在快取恢復之前,資料庫可能一直都起不來),導致系統整體不可服務。
如何應對潛在的雪崩?
答:提前做容量預估,如果快取掛掉,資料庫仍能扛住,才能執行上述方案。
否則,就要進一步設計。
常見方案一:高可用快取
image.png
如上圖:使用高可用快取叢集,一個快取例項掛掉後,能夠自動做故障轉移。
常見方案二:快取水平切分
image.png
如上圖:使用快取水平切分(推薦使用一致性雜湊演算法進行切分),一個快取例項掛掉後,不至於所有的流量都壓到資料庫上。
誤用三:呼叫方快取資料
image.png
如上圖:
服務提供方快取,向呼叫方遮蔽資料獲取的複雜性(這個沒問題)
服務呼叫方,也快取一份資料,先讀自己的快取,再決定是否呼叫服務(這個有問題)
該方案存在的問題是:
1、呼叫方需要關注資料獲取的複雜性(耦合問題)
2、更嚴重的,服務修改db裡的資料,淘汰了服務cache之後,難以通知呼叫方淘汰其cache裡的資料,從而導致資料不一致(帶入一致性問題)
3、有人說,服務可以通過MQ通知呼叫方淘汰資料,額,難道下游的服務要依賴上游的呼叫方,分層架構設計不是這麼玩的(反向依賴問題)
誤用四:多服務共用快取例項
image.png
如上圖:服務A和服務B共用一個快取例項(不是通過這個快取例項互動資料)
該方案存在的問題是:
1、可能導致key衝突,彼此沖掉對方的資料
畫外音:可能需要服務A和服務B提前約定好了key,以確保不衝突,常見的約定方式是使用namespace:key的方式來做key。
2、不同服務對應的資料量,吞吐量不一樣,共用一個例項容易導致一個服務把另一個服務的熱資料擠出去
3、共用一個例項,會導致服務之間的耦合,與微服務架構的“資料庫,快取私有”的設計原則是相悖的
建議的玩法是
image.png
如上圖:各個服務私有化自己的資料儲存,對上游遮蔽底層的複雜性。
總結
1、服務與服務之間不要通過快取傳遞資料
2、如果快取掛掉,可能導致雪崩,此時要做高可用快取,或者水平切分
3、呼叫方不宜再單獨使用快取儲存服務底層的資料,容易出現數據不一致,以及反向依賴
4、不同服務,快取例項要做垂直拆分
快取,究竟是淘汰,還是修改?
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回快取
image.png
(1)先從cache中嘗試get資料,結果miss了
(2)再從db中讀取資料,從庫,讀寫分離
(3)最後把資料set回cache,方便下次讀命中
對於寫請求
先操作資料庫,再淘汰快取(淘汰快取,而不是更新快取)
![image.png](https://upload-images.jianshu.io/upload_images/14199675-976af0a97296d6a2.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
如上圖:
(1)第一步要操作資料庫,第二步操作快取
(2)快取,採用delete淘汰,而不是set更新
Cache Aside Pattern為什麼建議淘汰快取,而不是更新快取?
答:如果更新快取,在併發寫時,可能出現數據不一致。
[圖片上傳失敗...(image-b9d92c-1538144246619)]
如上圖所示,如果採用set快取。
在1和2兩個併發寫發生時,由於無法保證時序,此時不管先操作快取還是先操作資料庫,都可能出現:
(1)請求1先操作資料庫,請求2後操作資料庫
(2)請求2先set了快取,請求1後set了快取
導致,資料庫與快取之間的資料不一致。
所以,Cache Aside Pattern建議,delete快取,而不是set快取。
Cache Aside Pattern為什麼建議先操作資料庫,再操作快取?
答:如果先操作快取,在讀寫併發時,可能出現數據不一致。
image.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。這裡我覺得沈老師可能忽略了併發的問題,比如說以下情況:
一個寫請求過來,刪除了快取,準備更新資料庫(還沒更新完成)。
然後一個讀請求過來,快取未命中,從資料庫讀取舊資料,再次放到快取中,這時候,資料庫更新完成了。此時的情況是,快取中是舊資料,資料庫裡面是新資料,同樣存在資料不一致的問題。
如圖:
image.png
不一致解決場景及解決方案
答:發生寫請求後(不管是先操作DB,還是先淘汰Cache),在主從資料庫同步完成之前,如果有讀請求,都可能發生讀Cache Miss,讀從庫把舊資料存入快取的情況。此時怎麼辦呢?
資料庫主從不一致
先回顧下,無快取時,資料庫主從不一致問題。
image.png
如上圖,發生的場景是,寫後立刻讀:
(1)主庫一個寫請求(主從沒同步完成)
(2)從庫接著一個讀請求,讀到了舊資料
(3)最後,主從同步完成
導致的結果是:主動同步完成之前,會讀取到舊資料。
可以看到,主從不一致的影響時間很短,在主從同步完成後,就會讀到新資料。
二、快取與資料庫不一致
再看,引入快取後,快取和資料庫不一致問題。
image.png
如上圖,發生的場景也是,寫後立刻讀:
(1+2)先一個寫請求,淘汰快取,寫資料庫
(3+4+5)接著立刻一個讀請求,讀快取,cache miss,讀從庫,寫快取放入資料,以便後續的讀能夠cache hit(主從同步沒有完成,快取中放入了舊資料)
(6)最後,主從同步完成
導致的結果是:舊資料放入快取,即使主從同步完成,後續仍然會從快取一直讀取到舊資料。
可以看到,加入快取後,導致的不一致影響時間會很長,並且最終也不會達到一致。
三、問題分析
可以看到,這裡提到的快取與資料庫資料不一致,根本上是由資料庫主從不一致引起的。當主庫上發生寫操作之後,從庫binlog同步的時間間隔內,讀請求,可能導致有舊資料入快取。
思路:那能不能寫操作記錄下來,在主從時延的時間段內,讀取修改過的資料的話,強制讀主,並且更新快取,這樣子快取內的資料就是最新。在主從時延過後,這部分資料繼續讀從庫,從而繼續利用從庫提高讀取能力。
三、不一致解決方案
選擇性讀主
可以利用一個快取記錄必須讀主的資料。
image.png
如上圖,當寫請求發生時:
(1)寫主庫
(2)將哪個庫,哪個表,哪個主鍵三個資訊拼裝一個key設定到cache裡,這條記錄的超時時間,設定為“主從同步時延”
PS:key的格式為“db:table:PK”,假設主從延時為1s,這個key的cache超時時間也為1s。
image.png
如上圖,當讀請求發生時:
這是要讀哪個庫,哪個表,哪個主鍵的資料呢,也將這三個資訊拼裝一個key,到cache裡去查詢,如果,
(1)cache裡有這個key,說明1s內剛發生過寫請求,資料庫主從同步可能還沒有完成,此時就應該去主庫查詢。並且把主庫的資料set到快取中,防止下一次cahce miss。
(2)cache裡沒有這個key,說明最近沒有發生過寫請求,此時就可以去從庫查詢
以此,保證讀到的一定不是不一致的髒資料。
PS:如果系統可以接收短時間的不一致,建議建議定時更新快取就可以了。避免系統過於複雜。
程序內快取
除了常見的redis/memcache等程序外快取服務,快取還有一種常見的玩法,程序內快取。
什麼是程序內快取?
答:將一些資料快取在站點,或者服務的程序內,這就是程序內快取。
程序內快取的實現載體,最簡單的,可以是一個帶鎖的Map。又或者,可以使用第三方庫,例如leveldb、guave本地快取
程序內快取能儲存啥?
答:redis/memcache等程序外快取服務能存什麼,程序內快取就能存什麼。
image.png
如上圖,可以儲存json資料,可以儲存html頁面,可以儲存物件。
程序內快取有什麼好處?
答:與沒有快取相比,程序內快取的好處是,資料讀取不再需要訪問後端,例如資料庫。
image.png
如上圖,整個訪問流程要經過1,2,3,4四個步驟。
如果引入程序內快取,
image.png
如上圖,整個訪問流程只要經過1,2兩個步驟。
與程序外快取相比(例如redis/memcache),程序內快取省去了網路開銷,所以一來節省了內網頻寬,二來響應時延會更低。
程序內快取有什麼缺點?
答:統一快取服務雖然多一次網路互動,但仍是統一儲存。
image.png
如上圖,站點和服務中的多個節點訪問統一的快取服務,資料統一儲存,容易保證資料的一致性。
image.png
而程序內快取,如上圖,如果資料快取在站點和服務的多個節點內,資料存了多份,一致性比較難保障。
如何保證程序內快取的資料一致性?
答:保障程序內快取一致性,有三種方案。
第一種方案
可以通過單節點通知其他節點。如上圖:寫請求發生在server1,在修改完自己記憶體資料與資料庫中的資料之後,可以主動通知其他server節點,也修改記憶體的資料。如下圖:
image.png
這種方案的缺點是:同一功能的一個叢集的多個節點,相互耦合在一起,特別是節點較多時,網狀連線關係極其複雜。
第二種方案
可以通過MQ通知其他節點。如上圖,寫請求發生在server1,在修改完自己記憶體資料與資料庫中的資料之後,給MQ釋出資料變化通知,其他server節點訂閱MQ訊息,也修改記憶體資料。
image.png
這種方案雖然解除了節點之間的耦合,但引入了MQ,使得系統更加複雜。
前兩種方案,節點數量越多,資料冗餘份數越多,資料同時更新的原子性越難保證,一致性也就越難保證。
第三種方案
為了避免耦合,降低複雜性,乾脆放棄了“實時一致性”,每個節點啟動一個timer,定時從後端拉取最新的資料,更新記憶體快取。在有節點更新後端資料,而其他節點通過timer更新資料之間,會讀到髒資料。
image.png
為什麼不能頻繁使用程序內快取?
答:分層架構設計,有一條準則:站點層、服務層要做到無資料無狀態,這樣才能任意的加節點水平擴充套件,資料和狀態儘量儲存到後端的資料儲存服務,例如資料庫服務或者快取服務。
可以看到,站點與服務的程序內快取,實際上違背了分層架構設計的無狀態準則,故一般不推薦使用。
什麼時候可以使用程序內快取?
答:以下情況,可以考慮使用程序內快取。
情況一
只讀資料,可以考慮在程序啟動時載入到記憶體。
畫外音:此時也可以把資料載入到redis / memcache,程序外快取服務也能解決這類問題。
情況二
極其高併發的,如果透傳後端壓力極大的場景,可以考慮使用程序內快取。
例如,秒殺業務,併發量極高,需要站點層擋住流量,可以使用記憶體快取。
情況三
一定程度上允許資料不一致業務。
例如,有一些計數場景,運營場景,頁面對資料一致性要求較低,可以考慮使用程序內頁面快取。
再次強調,程序內快取的適用場景並不如redis/memcache廣泛,不要為了炫技而使用。更多的時候,還是老老實實使用redis/mc吧。