Redis作為緩存應用的情形詳細分析
為什么使用緩存
Redis是一個內存型數(shù)據(jù)庫,也就是說,所有的數(shù)據(jù)都會存在與內存中,基于Redis的高性能特性,我們將Redis用在緩存場景非常廣泛。使用起來方便,響應也是遠超關系型數(shù)據(jù)庫。
應用場景
Redis的應用場景非常廣泛。雖然Redis是一個key-value的內存數(shù)據(jù)庫,但在實際場景中,Redis經(jīng)常被作為緩存來使用,如面對數(shù)據(jù)高并發(fā)的讀寫、海量數(shù)據(jù)的讀寫等。
舉個例子,A網(wǎng)站首頁一天有100萬人訪問,其中有一個“積分商城”的板塊,要直接從數(shù)據(jù)庫查詢,那么一天就要多消耗100萬次數(shù)據(jù)庫請求。如果將這些數(shù)據(jù)儲存到Redis(內存)中,要用的時候,直接從內存調取,不僅可以大大節(jié)省系統(tǒng)直接讀取磁盤來獲得數(shù)據(jù)的IO開銷,提高服務器的資源利用率,還能極大地提升速度。
比如很多大型電商網(wǎng)站、視頻網(wǎng)站和游戲應用等,存在大規(guī)模數(shù)據(jù)訪問,對數(shù)據(jù)查詢效率要求高。Redis服務可實現(xiàn)頁面緩存、應用緩存、狀態(tài)緩存、事件并行處理,能夠有效減少數(shù)據(jù)庫磁盤IO,提高數(shù)據(jù)查詢效率,減輕管理維護工作量,降低數(shù)據(jù)庫存儲成本。對傳統(tǒng)磁盤數(shù)據(jù)庫是一個重要的補充,成為了互聯(lián)網(wǎng)應用,尤其是支持高并發(fā)訪問的互聯(lián)網(wǎng)應用必不可少的基礎服務之一。

具體而言,分布式緩存Redis可用于以下場景:
1、頁面緩存
Redis可將Web頁面的內容片段,包括HTML,CSS和圖片等靜態(tài)數(shù)據(jù),緩存到Redis實例,提高網(wǎng)站的訪問性能。
比如在電商類應用中,熱銷商品展示、秒殺推薦等數(shù)據(jù)面臨高并發(fā)讀的壓力,分布式緩存Redis的高并發(fā)及靈活擴展,可輕松支持此類應用。
2、狀態(tài)緩存
Redis可將Session會話狀態(tài)及應用橫向擴展時的狀態(tài)數(shù)據(jù)等緩存到DCS實例,實現(xiàn)狀態(tài)數(shù)據(jù)共享。在應對游戲應用中爆發(fā)式增長的玩家數(shù)據(jù)存儲和讀寫請求時,使用分布式緩存Redis可通過將熱點數(shù)據(jù)放入緩存,加快用戶端訪問速度,提升用戶體驗。
3、應用對象緩存
Redis可作為服務層的二級緩存對外提供服務,減輕數(shù)據(jù)庫的負載壓力,加速應用訪問。
4、事件緩存
Redis可提供針對事件流的連續(xù)查詢(continuous query)處理技術,滿足實時性需求。
使用緩存的收益和成本
如圖左側為客戶端直接調用存儲層的架構,右側為比較典型的緩存層+存儲層架構,下面分析一下緩存加入后帶來的收益和成本。

收益:
l 加速讀寫:因為緩存通常都是全內存的,而存儲層通常讀寫性能不夠強悍(例如MySQL),通過緩存的使用可以有效地加速讀寫,優(yōu)化用戶體驗。
l 降低后端負載:幫助后端減少訪問量和復雜計算(例如很復雜的SQL語句),在很大程度降低了后端的負載。
成本:
l 數(shù)據(jù)不一致性:緩存層和存儲層的數(shù)據(jù)存在著一定時間窗口的不一致性,時間窗口跟更新策略有關。
l 代碼維護成本:加入緩存后,需要同時處理緩存層和存儲層的邏輯,增大了開發(fā)者維護代碼的成本。
l 運維成本:以Redis Cluster為例,加入后無形中增加了運維成本。
緩存不一致
一致性
1、強一致性
如果你的項目對緩存的要求是強一致性的,那么請不要使用緩存。這種一致性級別是最符合用戶直覺的,它要求系統(tǒng)寫入什么,讀出來的也會是什么,用戶體驗好,但實現(xiàn)起來往往對系統(tǒng)的性能影響大。
2、弱一致性
這種一致性級別約束了系統(tǒng)在寫入成功后,不承諾立即可以讀到寫入的值,也不承諾多久之后數(shù)據(jù)能夠達到一致,但會盡可能地保證到某個時間級別(比如秒級別)后,數(shù)據(jù)能夠達到一致狀態(tài)
3**、最終一致性**
最終一致性是弱一致性的一個特例,系統(tǒng)會保證在一定時間內,能夠達到一個數(shù)據(jù)一致的狀態(tài)。這里之所以將最終一致性單獨提出來,是因為它是弱一致性中非常推崇的一種一致性模型,也是業(yè)界在大型分布式系統(tǒng)的數(shù)據(jù)一致性上比較推崇的模型。一般情況下,高可用只確保最終一致性,不確保強一致性。
強一致性,讀請求和寫請求會串行化,串到一個內存隊列里去,這樣會大大增加系統(tǒng)的處理效率,吞吐量也會大大降低。
業(yè)務場景
在絕大多數(shù)的系統(tǒng)中數(shù)據(jù)庫往往是用戶并發(fā)訪問最薄弱的地方,并且在高并發(fā)下的讀多寫少的情況下,我們往往會借助一些中間鍵,來解決數(shù)據(jù)訪問過大時造成的數(shù)據(jù)庫宕機情況,例如我們可以使用Redis來作為緩存,讓請求先訪問到Redis,而不是直接訪問數(shù)據(jù)庫。而在這種業(yè)務場景下,可能會出現(xiàn)緩存和數(shù)據(jù)庫數(shù)據(jù)不一致性的問題。

問題產(chǎn)生的原因
一般來說讀取緩存步驟是不會有什么問題的,但是一旦涉及到數(shù)據(jù)更新,也就是數(shù)據(jù)庫和緩存都操作,就容易出現(xiàn)緩存(Redis)和數(shù)據(jù)庫(MySQL)間的數(shù)據(jù)一致性問題。
在數(shù)據(jù)更新時,我們需要做以下兩步:
- 操作MySQL
- 操作緩存
但是無論是先執(zhí)行步驟1還是先執(zhí)行步驟2,都有可能出現(xiàn)數(shù)據(jù)不一致的情況,主要是因為讀寫是并發(fā)的,我們無法保證他們的先后順序。
相關策略
先做一個說明,從理論上來說,給緩存設置過期時間,是保證最終一致性的解決方案(如果要求強一致性的話,我認為沒有必要添加緩存了,直接走數(shù)據(jù)庫)。這種前提下,我們可以對存入緩存的數(shù)據(jù)設置過期時間,所有的寫操作以數(shù)據(jù)庫為準,對緩存操作只是盡最大努力即可。也就是說如果數(shù)據(jù)庫寫成功,緩存更新失敗,那么只要到達過期時間,則后面的讀請求自然會從數(shù)據(jù)庫中讀取新值然后回填緩存。因此,接下來討論的思路不依賴于給緩存設置過期時間這個方案。
給出了三種更新策略:
- 先更新數(shù)據(jù)庫,再更新緩存
- 先刪除緩存,再更新數(shù)據(jù)庫
- 先更新數(shù)據(jù)庫,在刪除緩存
先更新數(shù)據(jù)庫值再更新緩存值
最不可能選擇的策略,原因是此種策略可能會在線程安全的角度和業(yè)務場景角度生成臟數(shù)據(jù)和性能問題。
原因一:線程安全的角度
同時有請求A和請求B進行更新操作,那么就會出現(xiàn)
- 請求A更新數(shù)據(jù)庫
- 請求B更新數(shù)據(jù)庫
- 請求B更新緩存
- 請求A更新緩存
這就出現(xiàn)請求A更新緩存應該比請求B更新緩存早才對,但是因為網(wǎng)絡等原因,B比A更早更新了緩存。這就導致了臟數(shù)據(jù),因此不考慮。

業(yè)務場景角度
(1)如果是寫數(shù)據(jù)庫場景比較多,而讀數(shù)據(jù)場景比較少的業(yè)務需求,那么采用這種方案就會導致,數(shù)據(jù)壓根還沒讀到,緩存就被頻繁的更新,浪費性能,緩存此類數(shù)據(jù),沒有很大的意義。
(2)如果是寫入數(shù)據(jù)庫的值,并不是直接寫入緩存的,而是要經(jīng)過一系列復雜的計算再寫入緩存。那么,每次寫入數(shù)據(jù)庫后,都再次計算寫入緩存的值,無疑是浪費性能的。顯然,刪除緩存更為適合。
后面兩種策略都是對緩存進行刪除,這里先做一個解釋。
例子:數(shù)據(jù)庫在1小時內更新1000次那么緩存也更新1000次,但是這個緩存可能在1小時內只被讀了1次,那么就沒有必要更新1000次了。反過來,如果是刪除的話,那么也只是做了1次刪除操作,當緩存真正被讀取的時候才去更新。
刪除緩存值再更新數(shù)據(jù)庫值
- 請求A進行更新操作,首先刪除緩存
- 請求B查詢發(fā)現(xiàn)緩存不存在
- 請求B去數(shù)據(jù)庫查詢得到舊值
- 請求B將舊值寫入緩存
- 請求A將新值寫入數(shù)據(jù)庫

上述情況就會導致不一致的情形出現(xiàn)。而且,如果不采用給緩存設置過期時間策略,該數(shù)據(jù)永遠都是臟數(shù)據(jù)。我們可以采用延遲雙刪策略,來解決這個問題。
相對應的步驟:
- 先淘汰緩存
- 再寫數(shù)據(jù)庫
- 休眠t秒,再次淘汰緩存
這么做的目的,就是確保讀請求結束,寫請求可以刪除讀請求造成的緩存臟數(shù)據(jù)。
// 偽代碼
public void updateDb(String key,Object data) {
redis.delKey(key);
db.updateData(data);
Thread.sleep(t);
redis.delKey(key);
}如果系統(tǒng)中MySQL使用了讀寫分離模式,那么有可能會出現(xiàn)在主從同步?jīng)]有完成時,讀請求就去讀取數(shù)據(jù)了,這時候就會讀取到舊值,這里我們可以延長睡眠時間,讓主從同步完成后在進行一次刪除(如果不考慮主從的情況下,采用雙刪不用加延時時間也是可以保證一直性的)。
先更新數(shù)據(jù)庫值在刪除緩存值
假設有兩個請求,請求A進行更新操作,請求B進行查詢操作。
那么會出現(xiàn)如下情形:
- 請求A進行更新操作,首先更新數(shù)據(jù)庫
- 請求B進行查詢操作,擊中緩存,得到舊值
- 請求A進行刪除緩存操作

在這種情況下如果其他線程并發(fā)讀緩存的請求不多,那么,就不會有很多請求讀取到舊值。而且,請求 A 一般也會很快刪除緩存值,這樣一來,其他線程再次讀取時,就會發(fā)生緩存缺失,進而從數(shù)據(jù)庫中讀取最新值。所以,這種情況對業(yè)務的影響較小。
無論是策略2還是策略3都有可能會出現(xiàn)這種情況:刪除緩存失敗,這時我們可以采用重試機制來保證數(shù)據(jù)的一致性。
方案的詳細設計
在相關策略的調用中,雖然提出了一些簡單解決方案,但是沒有考慮到列如 緩存刪除失敗,數(shù)據(jù)庫更新失敗等情況,因此需要增加重試策略,但是還是可能會出現(xiàn)比較不一致的問題,此處詳細介紹幾種方案。

流程如下:
- 更新數(shù)據(jù)庫數(shù)據(jù);
- 緩存因為種種問題刪除失敗
- 將需要刪除的key發(fā)送至消息隊列
- 自己消費消息,獲得需要刪除的key
- 繼續(xù)重試刪除操作,直到成功
如果能夠成功地刪除或更新,我們就要把這些值從消息隊列中去除,以免重復操作,此時,我們也可以保證數(shù)據(jù)庫和緩存的數(shù)據(jù)一致了。否則的話,我們還需要再次進行重試。如果重試超過的一定次數(shù),還是沒有成功,我們就需要向業(yè)務層發(fā)送報錯信息了。
// 偽代碼
public void updateDb(String key,Object data){
db.updateData(data);
if (!redis.delKey(key)) {
mq.send(key);
new Thread(() -> asyncDel()).start();
}
}
// 異步重試
private void asyncDel() {
int count = 0;
String key = mq.get();
while(!redis.delKey(key)) {
count++;
if (count > 5) {
throw new DelFailException();
}
}
mq.remove(key);
}這種雖然可以解決,但是會對業(yè)務代碼造成侵入,而且還需要去維護消息隊列,如果可以容忍的話,我覺得是可選的方案之一。
注意 需要使用有序的消息隊列,保證消息的有序性。重試刪除

訂閱binlog

業(yè)務代碼只會操作數(shù)據(jù)庫,不操作緩存。同時啟動一個訂閱binlog的程序去監(jiān)聽刪除操作,然后投遞到消息隊列中。再啟動一個消費者,根據(jù)消息去刪除緩存。
canal是用來模擬MySQL slave,來訂閱MySQL master 的binlog。
異步重試
總結
對于讀多寫少的數(shù)據(jù),請使用緩存。
為了保持數(shù)據(jù)庫和緩存的一致性,會導致系統(tǒng)吞吐量的下降。
為了保持數(shù)據(jù)庫和緩存的一致性,會導致業(yè)務代碼邏輯復雜。
緩存做不到絕對一致性,但可以做到最終一致性。
對于需要保證緩存數(shù)據(jù)庫數(shù)據(jù)一致的情況,請盡量考慮對一致性到底有多高要求,選定合適的方案,避免過度設計。
緩存問題
緩存穿透
問題描述
緩存穿透是指查詢一個根本不存在的數(shù)據(jù),緩存層和存儲層都不會命中,通常出于容錯的考慮,如果從存儲層查不到數(shù)據(jù)則不寫入緩存層,如下圖所示

整個過程分為如下3步:
緩存層不命中。存儲層不命中,不將空結果寫回緩存。返回空結果。
緩存穿透將導致不存在的數(shù)據(jù)每次請求都要到存儲層去查詢,失去了緩存保護后端存儲的意義。
緩存穿透問題可能會使后端存儲負載加大,由于很多后端存儲不具備高并發(fā)性,甚至可能造成后端存儲宕掉。通??梢栽诔绦蛑蟹謩e統(tǒng)計總調用數(shù)、緩存層命中數(shù)、存儲層命中數(shù),如果發(fā)現(xiàn)大量存儲層空命中,可能就是出現(xiàn)了緩存穿透問題。
解決方案
造成緩存穿透的基本原因有兩個。第一,自身業(yè)務代碼或者數(shù)據(jù)出現(xiàn)問題,第二,一些惡意攻擊、爬蟲等造成大量空命中。下面我們來看一下如何解決緩存穿透問題。
緩存空對象
如圖所示,當?shù)?步存儲層不命中后,仍然將空對象保留到緩存層中,之后再訪問這個數(shù)據(jù)將會從緩存中獲取,這樣就保護了后端數(shù)據(jù)源。

緩存空對象會有兩個問題:第一,空值做了緩存,意味著緩存層中存了更多的鍵,需要更多的內存空間,比較有效的方法是針對這類數(shù)據(jù)設置一個較短的過期時間,讓其自動剔除。第二,緩存層和存儲層的數(shù)據(jù)會有一段時間窗口的不一致,可能會對業(yè)務有一定影響。例如過期時間設置為5分鐘,如果此時存儲層添加了這個數(shù)據(jù),那此段時間就會出現(xiàn)緩存層和存儲層數(shù)據(jù)的不一致,此時可以利用消息系統(tǒng)或者其他方式清除掉緩存層中的空對象。
布隆過濾器攔截
布隆過濾器:實際上是一個很長的二進制向量和一系列隨機映射函數(shù)。布隆過濾器可以用于檢索一個元素是否在一個集合中。它的優(yōu)點是空間效率和查詢時間都比一般的算法要好的多,缺點是有一定的誤識別率和刪除困難??梢愿嬖V你某樣東西一定不存在或者可能存在。

如圖所示,在訪問緩存層和存儲層之前,將存在的key用布隆過濾器提前保存起來,做第一層攔截。例如:一個推薦系統(tǒng)有4億個用戶id,每個小時算法工程師會根據(jù)每個用戶之前歷史行為計算出推薦數(shù)據(jù)放到存儲層中,但是最新的用戶由于沒有歷史行為,就會發(fā)生緩存穿透的行為,為此可以將所有推薦數(shù)據(jù)的用戶做成布隆過濾器。如果布隆過濾器認為該用戶id不存在,那么就不會訪問存儲層,在一定程度保護了存儲層。
兩種方案比對

緩存雪崩
如圖描述了什么是緩存雪崩:由于緩存層承載著大量請求,有效地保護了存儲層,但是如果緩存層由于某些原因不能提供服務,于是所有的請求都會達到存儲層,存儲層的調用量會暴增,造成存儲層也會級聯(lián)宕機的情況。

預防和解決緩存雪崩問題,可以從以下三個方面進行著手。
(1) 保證緩存層服務高可用性。如果緩存層設計成高可用的,即使個別節(jié)點、個別機器、甚至是機房宕掉,依然可以提供服務,例如前面介紹過的Redis Sentinel和Redis Cluster都實現(xiàn)了高可用。
(2) 依賴隔離組件為后端限流并降級。無論是緩存層還是存儲層都會有出錯的概率,可以將它們視同為資源。作為并發(fā)量較大的系統(tǒng),假如有一個資源不可用,可能會造成線程全部阻塞在這個資源上,造成整個系統(tǒng)不可用。降級機制在高并發(fā)系統(tǒng)中是非常普遍的。實際項目中,我們需要對重要的資源(例如Redis、MySQL、HBase、外部接口)都進行隔離,讓每種資源都單獨運行在自己的線程池中,即使個別資源出現(xiàn)了問題,對其他服務沒有影響。但是線程池如何管理,比如如何關閉資源池、開啟資源池、資源池閥值管理,這些做起來還是相當復雜的。這里推薦使用Java依賴隔離工具Hystrix,他是解決依賴隔離的利器。
(3) 提前演練。在項目上線前,演練緩存層宕掉后,應用以及后端的負載情況以及可能出現(xiàn)的問題,在此基礎上做一些預案設定。
緩存擊穿(熱點數(shù)據(jù)集中失效)
問題描述
當一個key是熱點key,并發(fā)量很大,而且重建緩存不能在短時間完成,在緩存失效的一瞬間,就會有大量的線程來重建緩存,造成后端負載加大,甚至讓應用崩潰,這就叫緩存擊穿。如下圖:

解決方案
互斥鎖
此方法只允許一個線程重建緩存,其他線程等待重建緩存的線程執(zhí)行完,重新從緩存獲取數(shù)據(jù)即可,整個過程如圖所示。

永遠不過期
“永遠不過期”包含兩層意思:
l 從緩存層面來看,確實沒有設置過期時間,所以不會出現(xiàn)熱點key過期后產(chǎn)生的問題,也就是“物理”不過期。
l 從功能層面來看,為每個value設置一個邏輯過期時間,當發(fā)現(xiàn)超過邏輯過期時間后,會使用單獨的線程去構建緩存。
整個過程如圖所示:

此方法有效杜絕了熱點key產(chǎn)生的問題,但唯一不足的就是重構緩存期間,會出現(xiàn)數(shù)據(jù)不一致的情況,這取決于應用方是否容忍這種不一致。
兩種方案對比

到此這篇關于Redis作為緩存應用的情形詳細分析的文章就介紹到這了,更多相關Redis作為緩存內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
基于springboot bean的實例化過程和屬性注入過程
這篇文章主要介紹了基于springboot bean的實例化過程和屬性注入過程,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-11-11
Java DefaultListableBeanFactory接口超詳細介紹
這篇文章主要介紹了Java DefaultListableBeanFactory接口,DefaultListableBeanFactory是整個bean加載的核心部分,是Spring注冊機加載bean的默認實現(xiàn)2022-11-11
Java Spring使用hutool的HttpRequest發(fā)送請求的幾種方式
文章介紹了Hutool庫中用于發(fā)送HTTP請求的工具,包括添加依賴、發(fā)送GET和POST請求的方法,以及GET請求的不同參數(shù)傳遞方式,感興趣的朋友跟隨小編一起看看吧2024-11-11
Spring Boot整合Spring Data Jpa代碼實例
這篇文章主要介紹了Spring Boot整合Spring Data Jpa代碼實例,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2019-11-11
SpringBoot2.0集成WebSocket實現(xiàn)后臺向前端推送信息
這篇文章主要介紹了SpringBoot2.0集成WebSocket實現(xiàn)后臺向前端推送信息,本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2021-01-01
Java編程中使用JDBC API連接數(shù)據(jù)庫和創(chuàng)建程序的方法
這篇文章主要介紹了Java編程中使用JDBC API連接數(shù)據(jù)庫和創(chuàng)建程序的基本教程,JDBC是一種用于執(zhí)行SQL語句的Java API,可以為多種關系數(shù)據(jù)庫提供統(tǒng)一訪問需要的朋友可以參考下2015-12-12

