Redis緩存擊穿、雪崩、穿透!(超詳細)

緩存的擊穿、穿透和雪崩應該是再熟悉不過的詞了,也是面試常問的高頻試題。
不過,對於這三大緩存的問題,有很多人背過了解決方案,卻少有人能把思路給理清的。
而且,網絡上仍然充斥着,大量不太靠譜的解決方案,難免誤人子弟。
我的這篇文章,則會對這三大緩存問題,做一個深入的探討和分析。

最有價值的,不是答案本身,而是誕生答案的過程。

緩存擊穿
緩存擊穿是什麼,大家應該心裏都清楚,我只做一個簡單通俗的解釋:
就是某一個熱點數據,緩存中某一時刻失效了,因而大量併發請求打到數據庫上,就像被擊穿了一樣。

說白了,就是某個數據,數據庫有,但是緩存中沒有。

那麼,緩存擊穿,就會使得因爲這一個熱點數據,將大量併發請求打擊到數據庫上,從而導致數據庫被打垮。

要解決這個問題之前,我們就先得對整個系統的架構有一定的瞭解:

首先,在客戶端很多的情況下,他們必然會去訪問我們的 redis;
這時候 redis 是做緩存用的,所以在後面,必然有一個 DB,比如我們的 MySQL;
如果站在外圍的層面來說,這個客戶端其實就是一個 service;
再往前延伸的話還有很多其它的服務,這個服務只是微服務羣體中的一個;
微服務如果再往前,就是網關,可以是業務網關,比如 Springcloud 的 Zuul;
再往前,必定要有流量分發層,負載均衡器等等,比如 Nginx、LVS;
如果項目夠大,也可能會有 CDN 這樣的把各地的流量分離;
真正在最前面的,纔是我們的用戶,去訪問我們的服務。
真正的流量,也就來自與用戶,他們纔是行爲的主體。
用戶的量足夠大,纔會有所謂的高併發,
我們的系統,也就是從前往後,一層一層的過濾掉各種各樣的請求,
真正能抵達我們的數據庫的,只有很少的一部分請求,這纔是一個架構師在橫看一個項目時,應該做的事情。

在這裏插入圖片描述
而我們的 redis,在整個系統體系中,作爲緩存,就是將很多的請求抗住,過濾掉,
所以最後的數據庫的壓力就會很小。

那麼既然 redis 是作爲緩存,

那要麼就會給 key 設置過期時間,在一段實際後清除;
或者,就是 LRU、LFU,清除冷數據。
所以說,只要是作爲緩存,那麼就一定存在這種情況:
一個 key,某一時間,要麼過期了,要麼 LRU 清除,然後,就突然有人來訪問它。
就好像是在 redis 上打了一個窟窿,擊穿了,穿過去了。

本來一個系統一個架構,請那麼多人,花那麼多力氣,就是爲了能讓系統抗住更高的併發,讓更少的請求打進我們的數據庫裏,
結果,就因爲一個 key 過期了,這麼一個小小的事情,導致所有的請求瘋狂打進我們的數據庫。

那麼這件事情該怎麼規避,首先不要去看網上什麼到處都是的博客裏邊的描述,你需要先承認一點,就是肯定發生了高併發。
如果一個系統本來就沒啥併發量,那就壓根沒什麼事,請求打到數據庫上來唄,完全不是什麼問題。

在這裏插入圖片描述
那麼在高併發的情況下,一個 key 過期了,然後,就是幾千幾萬的併發蜂擁而至,這該怎麼解決?

有些學藝不精的會給出這些布隆啊、過期時間散開啊、改進緩存算法、延長過期時間這些個答案,
那說明對緩存這個概念還沒有深刻的認識。

首先,大部分人會想到這麼一個答案:熱點數據永不過期。

在網絡上盛行的解決方案有很多:

設置 key 永不過期,在修改數據庫時,同時更新緩存;
後臺起一個定時任務,每隔一段時間,再 key 快要失效的時候,提前將 key 刷新爲最新數據;
每次獲取 key 都檢查,key 還有多久過期,如果快過期,則更新這個 key;
分級緩存,一級緩存失效,還有二級緩存墊背。
這一類的回答還有很多,統稱歸納起來,就是讓 redis 的緩存不過期,
普遍的做法就是:

設置 key 永遠不會過期;
在緩存還沒有失效前,就更新緩存。
如果你給出過這樣的答案,那說明你應該沒有在實際的生產環境中,沒有真正碰到過,且需要處理這樣的情況,
因爲確實,存在緩存擊穿問題的,光併發量的要求,就可以排除掉 99% 的企業,
所以,真正有實際場景的,去考慮解決緩存擊穿問題的人,少之又少。

所以,大部分人,都只是停留於紙上談兵的階段,正確與否,沒有實際場景的驗證,只能靠直覺去判斷;
或者,瀏覽了很多的文章博客,發現人人都這麼說,便也自然而然認爲,事實就是這麼一回事。

下面我來詳細分析,爲什麼,這些解決方案,在實際的生產環境中,是無法勝任的。

首先,我來分析,key 永不失效的解決方案,爲什麼不可行。

因爲,對於一個需要解決緩存擊穿問題的企業,他們的業務量一定是普通人無法想象和企及的;
所以,他們的數據量是巨大的,因而需要緩存,去保留熱點數據,減輕數據庫的壓力;
所以根據這一點就可以明確,不存在緩存不會失效的情況。

爲什麼呢?
因爲數據的量巨大,我們的 redis 緩存,是基於內存的,一個單點,一般也不會分配過大的內存,來保證它足夠靈活。
但是,即便是集羣,所能存儲的數據量也是有限的。redis 不可能把所有的數據全部緩存入內存,沒有什麼企業可以說用內存就可以存儲所有的數據。

但是,你可以說,只存熱點數據啊!

但是,什麼叫熱點數據?你覺得是就是嗎?

真正的環境中,熱點數據是在時時變化着的,我們可以對一些熱點做一些預估,但是,我們永遠無法保證我們能預估到多少。
在這樣千變萬化的環境中,一個明星幹了點什麼事,就能掀起你無法想象的流量;
比如 xxx 怎麼怎麼的了,然後新浪就癱瘓了,也不是沒有的事。
所以,數據是流動的。
所以在真實場景中的熱點數據,是絕對不可能是由人去評估的,所有的熱點數據,都是根據時時的流量,系統緩存自動過期掉那一部分已經冷門的數據,然後又緩存起新的熱點數據。
所以說,熱點數據,一定是時時出現,時時消失的,我們靠人的大腦,是無法直接判斷出所有在某個時間點會出現的熱點數據。
所以這必須由我們的系統,能夠直接去應對數據的變化,在巨量數據的流動中找到平衡。
所以,我們的 redis 緩存,也不可能讓 key 永遠不會過期。
所以,redis 也不是你想讓它存一些不過期的數據就行的,由於熱點數據的不斷變動,redis 必須在時刻淘汰舊的數據,緩存入新的數據。

第二個,網絡上很流行的答案就是:加鎖

synchronized 加鎖,而且還衍生出雙重檢查鎖;
ReentrantLock.tryLock(),緩存沒有,嘗試加鎖,搶不到就睡一會,搶到的那一個查數據庫;
redis 的命令 setnx(),只有一個線程能設置成功,也就是能加到鎖,只有加到鎖了,纔讀數據庫,然後存會 redis 裏,其它則等待一會,然後再去 redis 取。
其實加鎖確實是可以解決的,
但是,如果你要是寫了 synchronized,那你一定會被直接炒魷魚。
這種都是嚴重的問題,會使你的系統可能就癱了。

首先,對於緩存穿透的情況,肯定是高併發場景,所以數據庫纔可能扛不住。
所以,查詢 redis,或者 mysql 的,一定不可能是單臺 tomcat 進程。

所以,在如此多的 Tomcat 集羣的情況下,一把 Java 鎖,是不可能鎖住一個集羣的。
而且,synchronized 一但加鎖,是不可撤銷的,它不像 ReentrantLock 那樣,可以 tryLock,加不到鎖也可以返回。
所以,一但使用了 synchronized 加鎖,會使得所有的讀 redis 緩存也加鎖。
讀請求加互斥鎖絕對是致命的,這個系統絕對是一啓動就被流量擊垮。

雖然說,用 ReentrantLock,tryLock 加鎖,成功的去數據庫讀取數據;
而那些失敗的,則睡眠一段時間,再重新去緩存讀取,
這個流程已經開始像實際的解決方案了。

但是,一個 Java 鎖最多隻能夠鎖一個 JVM 進程,對於一個集羣來說,這絕對是遠遠不夠的。
而且,去 redis 裏讀取數據的,可能不僅僅只是 Java 進程,像 Nginx 也能直接訪問 redis、mysql,是不是有點超出你的認知?
如果你的解決方案僅僅是一把 Java 鎖,那麼絕對達不到生產環境的要求。

所以實際上,緩存穿透,加鎖解決,必須還要涉及到分佈式鎖的概念。

這裏不談 zookeeper 之類的東西,既然談 redis,那麼就用 redis 來解決這個問題。

首先,當一個 key 失效,不管是時間過期,還是被 LRU、LFU 剔除,
假設會有 1w 個併發來訪問這個 key,那麼它們就會先查詢 redis,然後都發現,這個 key 不存在;
然後,它們就會對應的,往 redis 用 setnx 設置一個 key,來表示這是一把鎖;
然後,只有一個線程,會設置成功,然後去讀取數據庫,寫回 redis;
其他的 9999 個線程,則 sleep 一小會,然後再去訪問我們的 redis。
有人看到這,首先會問,這個 sleep 要多久?
這個是要根據壓測,以及線上環境進行調整的,一般會給出一個合適的值,也就是大約從數據庫取出數據的時間。
所以,正常情況是不會出現大面積長時間等待的情況的。

在這裏插入圖片描述
看起來似乎可行,但是,還有問題嗎?

我要這麼說肯定是有問題,但是,你可以想一想,存在什麼問題?

如果你不知道,說明對分佈式鎖還不夠了解,那麼,就繼續跟着我分析。

現在,我開始假設:

首先,一堆請求訪問 redis,發現爲空;
然後,這一堆併發開始嘗試加鎖,最終只有一個人,獲取到了鎖,其它人都失敗;
然後,持有鎖的機器,斷電了;
其他人,一直等着,但是始終沒有人等到鎖被釋放,或者 redis 被重新存入該數據。
這是一個分佈式鎖最常見的問題,就是加鎖進程死亡,導致鎖無法被釋放。
於是就產生了死鎖問題。

在這裏插入圖片描述
現在,既然出現了問題,那麼,我們一定得想辦法去解決。

首先,對於我們的分佈式集羣系統,任意一臺機器都有掛掉的可能。
所以,我們首先要明確的思路就是,如何在加鎖進程死亡的情況下,去釋放這個鎖。

可以想到兩種方案:
第一:
就是另起一個集羣,負責專門監管鎖的獲取和釋放;
一但持有鎖的進程宕機,監管集羣就負責將死鎖給釋放。

明顯,這麼做成本比較高昂,還不如用完善的 zookeeper 去實現分佈式鎖。

第二種:
就是平時比較常見的,用 redis 的設置過期時間,來保證,即使宕機,鎖也能在超時過後自動釋放。

於是,之前的方案,就可以稍作修改:

首先,一堆併發開始嘗試加鎖,最終只有一個人,獲取到了鎖,其它人都失敗;
然後,鎖被設置上過期時間,保證無論如何一定會被釋放;
然後,持有鎖的機器,斷電掛了。。。;
其他人,等了一會,發現鎖又沒了,於是重新開始之前的操作。

在這裏插入圖片描述
看起來很完美。

但是,還有問題嗎?

我要這麼問了,那麼一定說明有。

那麼,現在請你先不要拖到後文,先自己思考,會存在什麼問題,然後再來看我的分析。

現在我繼續列出場景,
假設:

首先,一堆併發開始嘗試加鎖,最終只有一個人,獲取到了鎖,其它人都失敗;
然後,持有鎖的人還沒來得及設置鎖的過期時間,就掛了。。。
其他人,一直等着,但是始終沒有人等到鎖被釋放,或者 redis 被重新存入該數據,又是死鎖。
既然問題來了,我們就需要想辦法,去加以解決。

首先,可以確定的是,可能鎖沒有來得及增加過期時間,從而導致,可能出現死鎖的情況。
因爲,之前的設置鎖、和設置過期時間,是兩步操作,不是原子的。

有些人可能就會說,那就放一個原子操作啊!

但是,redis 並沒有一個 API,既可以 setnx,又同時給予它一個過期時間。

那該怎麼辦?

所以,這就需要考驗,我們對 redis 的各種機制的掌握程度了。

首先,redis 有事務這麼一個概念,
不過,redis 的事務不像 mysql 那樣,可以支持回滾。

那麼不能回滾的事務也可以用來完成鎖操作嗎?

雖然不支持回滾,但是主要是因爲 redis 的事務是保證原子性的:
事務中的命令要麼全部被執行,要麼全部都不執行。
如果客戶端在使用 MULTI 開啓了一個事務之後,卻因爲斷線而沒有成功執行 EXEC ,那麼事務中的所有命令都不會被執行。
另一方面,如果客戶端成功在開啓事務之後執行 EXEC ,那麼事務中的所有命令都會被執行。

在一個事務中,只有全部的命令發送結束了,並且提交事務,那麼整個事務中的所有指令,纔會被 redis 執行;
也就是說,在嘗試去給 redis 的一個 key,加鎖,只要不最終 EXEC 觸發事務,那麼這些方法就永遠不會被執行;
那也就是,要是 EXEC 觸發事務執行,就一定會執行加鎖和設置過期時間的命令。
否則,沒有 EXEC,就兩條指令都不會執行。

這樣,就可以保證,redis 不會出現死鎖的問題。

這樣,解決了死鎖問題,就看起來很完美了。

但是,
這樣就可以了嗎?

確實,如果只是解決了死鎖的問題的情況下,是沒有什麼問題的。

但是,因爲我們在解決死鎖問題的時候,引入了超時時間,所以,就會導致新的問題的產生。
我們在解決一個問題的時候,往往會引入新的問題。

現在,假設:

首先,一堆併發開始嘗試加鎖,最終只有一個人,獲取到了鎖,且設置了超時時間;
然後,持有鎖的線程,開始進行讀數據庫的操作;
但是,由於各種不確定因素,它這次讀數據庫讀得很慢,所以還沒結束,鎖就超時釋放了;
然後,第二個線程也拿了一個鎖,開始它的操作;
然後,第一個線程結束了,這時,本該所有其他線程可以訪問數據庫了,但是,由於第二個線程去加了鎖,導致現在它們得額外繼續等到第二個線程去釋放;
這樣,就會增加了等待時間,響應延遲就會增大,如果,再多幾次加鎖過程,響應延遲就會越發嚴重,或者直接超時斷開。
在這裏插入圖片描述
所以,在設置鎖的超時時間的時候,該怎麼設置?
設置短一點?
那就會很容易發生鎖被別人又搶過去的情況;
那設置長一點?
那麼就又可能使得阻塞時間變長。

所以,鎖的超時時間又成了問題。

既然新問題出現了,我們就得想辦法去解決它。

而現在普遍的解決方案,就是多線程:

在加鎖了之後,由於鎖會有過期時間,然而又不能保證,鎖一定不會在執行結束過後過期,
那麼,我們就可以採用多線程的方案,讓鎖每隔一定時間,就重新設置它的超時時間。

於是就出現下面這樣的場景:

首先,一個手速快的傢伙搶到了鎖,並且也設置了超時時間,比如 30 秒。
然後,一個線程執行業務操作;又另起一個線程,去監管鎖的時間;
假設,這個業務做起來比較漫長,過了 10 秒還沒結束;
於是,監控線程感覺不妙,於是將過期時間又重新設置成了 30 秒;
業務繼續執行着,然後又過了 10 秒,鎖的過期時間還剩 20 秒;
於是,監控線程又感覺不妙,於是將過期時間再一次重新設置成了 30 秒;
周而復始,只要業務沒做完,鎖就不會過期;
假設 1,進程掛了,然後,30 秒一到,鎖被釋放;
假設 2,業務執行完了,於是線程主動釋放鎖。
於是,多線程的技術,就把這個緩存穿透的方案給解決了。

在這裏插入圖片描述
是不是覺得巨麻煩,竟然要從頭到尾經歷這麼多的過程,才能最終,實現一個不起眼的緩存穿透!

不過,實際上你再細想,其實,我上面提及的各種問題和解決方案,都刻意迴避了一個問題:
就是,redis 是單節點單實例的。

也就是說,我們對這一個 key 的操作,都是在一個 redis 上,而沒有同時牽扯到其他 redis;
所以,只要這個 redis 不掛,那麼就不存在問題。

不過要是 redis 掛了,那麼面臨的問題,也就不是 redis 的緩存擊穿問題了。
而是系統的高可用的解決方案,比如我上一篇文章提到的:
redis 的主從、主備,哨兵監控,來保證 redis 掛了之後,能立刻有 redis 前來替補。

文章指引>>
不爲技術而技術:Redis 從單點到集羣

因爲後面的這些個知識點,對集羣有相關的知識,所以,我也很建議,你可以看一下我的這一篇文章。
你也可以先看後面的,然後看完後,去看我之前的這一篇文章,做一次知識的整合和理解,然後再回過頭來看到這裏,也許你又會有不一樣的感覺。

不過,即使是主從模型,允許 redis 的從節點也提供讀服務,
這樣就會存在數據在一定時間內不一致的情況,那麼其實也沒有太大的問題。

假設:

第一個線程,搶到了鎖,然後訪問完數據庫,將數據寫回主結點;
然後,其它線程去從結點讀取,由於可能數據同步的不及時,導致一部分結點讀出的數據還是空;
於是,那些讀同步不及時的那部分從節點的線程,再重複一遍之前的操作;
這樣,就可能出現部分線程的往復多次操作,一直讀,一直是空。
如果從節點多的話,那麼所有的從節點之中,至少大部分從結點,都是通信正常的,
一般不會出現大面積壞死的情況;
所以,如果有少量從節點沒有數據,那麼會導致的二次重新操作,也只是少量的一部分線程,這樣,也只是再次加鎖一次,多讀取了一次數據庫。

然而實際上,也根本並不用那麼麻煩,假設從節點沒有讀取到,可以直接去主節點讀取,那麼就不會出現數據遲遲讀取不到的情況了。
也就是,對於這樣的加鎖操作,沒有必要要去涉及到從節點,所有的鎖操作,直接對主節點即可。

所以說,通過雙線程的加鎖操作,是可以解決緩存擊穿的問題的。

不過,由於我在上文,提到了這是一個分佈式鎖的概念,
要是,我在這裏,僅僅就這麼結束的話,難免會有同志誤以爲這就已經是一個完美的分佈式鎖,
所以,我再稍微提一下,redis 集羣的分佈式鎖的知識點。

對於單個 redis 來說,上面的知識點已經可以實現分佈式鎖了。

但是,既然要討論高併發高可用的系統,就會涉及到集羣。

對於單個 redis 來說,假設,加鎖的 redis 掛了,那該怎麼辦?

redis 的主從模型,默認使用異步同步數據的方式,所以,存在數據不一致的情況,
主節點掛了,從節點頂替的時候,是可能丟失數據的,

所以,這把鎖很可能就丟了。

爲了能夠解決這樣的問題,Redis 的作者 antirez 給出了一個更好的實現,稱爲 Redlock,算是 Redis 官方對於實現分佈式鎖的指導規範。

如果你們不善於閱讀英文,那麼就直接看我中文的描述:

在算法的分佈式版本中,我們假設有 N 個 redis,且這些節點是完全獨立的,也就是不存在任何主從關係,一個 redis 的死活和其他 redis 沒有任何關係。

那麼,接下來,就請思考一下,加鎖的操作:
首先,既然是分佈式鎖,那麼就不能只對單臺結點加鎖,因爲上面已經描述過了,一但該結點宕機,就可能會使得鎖丟失,因此,存在單點故障的問題。
所以,就必須對集羣中的多個結點加鎖。

那麼,應該給幾臺結點加鎖呢?

如果採用全部結點加鎖成功,才表示加鎖成功,那麼就成了強一致性,
如果你閱讀過我的《Redis從單點到集羣》這篇文章,應該能明白,強一致性,會對可用性產生衝擊,因而不適合採取這樣的方式。

那麼應該給幾個結點加鎖成功,才表示加鎖成功呢?
在 Redlock 的實現中,加鎖的 redis 結點,只要滿足,N/2+1,也就是過半,即代表加鎖成功。
爲什麼要過半呢?
因爲過半才能保證,真正加鎖成功的,只有一個。
過半又不要求全部,這樣,保證了持有鎖的唯一性,並且也保證了集羣的可用性足夠好。

那麼既然最基礎的問題解決了,下面,假設出現這麼一個場景:
假設,N=5,有 5 臺 redis:

一個線程,向 redis 集羣發起加鎖操作,然後第一個結點加鎖成功了;
然後,它又緊接着立刻向下一個結點發起加鎖,也加鎖成功了;
然後,它又向第三臺 redis 加鎖,也加鎖成功了;
那麼,這時候,已經代表,它獲得了 redis 集羣的分佈式鎖;
但是,它不知道,它前兩個節點加的鎖,已經過期了,這時候,它只加了一把鎖。
然後,另一個人揭竿而起,也立刻加上了 3 個節點,也代表獲取了鎖。
這下,就出現了一個集羣,有兩人持鎖,鎖就不可靠了。
不過,在之前提到過,我們可以給 redis 的鎖,延續超時時間。
於是,假設:

一個線程,向 redis 集羣發起加鎖操作,向 1、2 redis 加鎖,都加鎖成功了;
但是,加鎖到 redis 3,由於網絡通信延遲,一直卡在那加鎖;
這時,另一個哥麼看不下去了,於是他也發起加鎖操作;
於是,它向 redis 1、2 開始加鎖;
1、2 因爲已經被加過鎖了,所以加鎖失敗,然後加鎖 3、4、5;
但是,由於它和 4、5 連接有故障,導致無法加鎖成功;
然後,這時第一個加鎖的哥們,由於網絡故障,也沒有加鎖成功;
從而,倆人都沒加鎖成功。
其實,如果沒有第一個傢伙,第二個哥們是能加到鎖的,
但是,由於第一個加鎖者,佔據了鎖的位置,佔用了大量的時間,導致之後加鎖的線程,就會因爲被佔用,很容易加不到鎖,就會使得加鎖資源被白白浪費,系統的加鎖過程就會變長,效率變低。

所以,爲了解決這個問題,就可以設置一個超時時間的概念,讓加鎖的每一步,都快速,輕盈,
加的到就加的到,加不到就加不到,過程迅速,不拖泥帶水,
這樣,就能使得加鎖的過程更迅速,加鎖衝撞而導致加不到鎖的概率也會變低,從而使得加鎖更加高效。

所以,加鎖的時候,設置超時時間,但是,如果加鎖最終沒有成功,就不給單獨結點上的鎖續命,就讓它快速過期,這樣,就能夠使得集羣之間的加鎖更加高效迅速,而不容易出現爭搶激烈的情況。
所以,在這裏,就不應該像之前那樣,給鎖延長超時時間。

所以,在整個加鎖過程中,整個加鎖的過程,不能超過鎖的有效時間,否則,就應算作加鎖失敗,要立刻清除所有單獨結點上的鎖。

在這裏插入圖片描述
現在,想來你應該能大致理解,Redlock 加鎖的大致過程了,下面我就用簡略的語言,翻譯一下官方對於 Redlock 的加鎖操作:

首先獲取當前時間(毫秒數);
按順序依次向 N 個 redis 節點獲取鎖,其中要保證 key 相同,且 value 隨機;
爲了保證在某個 redis 節點不可用的時候算法能夠繼續運行,獲取鎖的操作還有一個超時時間,它要遠小於鎖的有效時間(幾十毫秒量級)。
客戶端在向某個 redis 節點獲取鎖失敗以後,應該立即嘗試下一個 redis 節點。這裏的失敗,應該包含任何類型的失敗,比如該 redis 節點不可用,或者該 redis 節點上的鎖已經被其它客戶端持有(注:Redlock 原文中這裏只提到了 redis 節點不可用的情況,但也應該包含其它的失敗情況)。
計算整個獲取鎖的過程總共消耗了多長時間,就是用當前時間減去第1步記錄的時間。
如果客戶端從大多數 redis 節點(>= N/2+1,也就是過半)成功獲取到了鎖,並且獲取鎖總共消耗的時間沒有超過鎖的有效時間,那麼這時客戶端才認爲最終獲取鎖成功,否則,認爲最終獲取鎖失敗。
如果最終獲取鎖成功了,那麼這個鎖的有效時間應該重新計算,它等於最初的鎖的有效時間減去第 3 步計算出來的獲取鎖消耗的時間。
如果最終獲取鎖失敗了(可能由於獲取到鎖的 redis 節點個數少於 N/2+1,或者整個獲取鎖的過程消耗的時間超過了鎖的最初有效時間),那麼客戶端應該立即向所有 redis 節點釋放鎖。
加鎖的過程比較複雜,不過釋放鎖的過程就簡單多了:
向所有 redis 節點發起釋放鎖即可,不管這些節點當時在加鎖的時候成功與否。

看起來似乎很完美了,但是,我繼續拋出一個問題。

假設一共有 5 個 redis ,分別是 ABCDE:

客戶端1成功鎖住了A, B, C, 加鎖成功(但 D 和 E 沒有鎖住)。
節點 C 崩潰重啓了,但客戶端 1 在 C 上加的鎖沒有持久化下來,丟失了。
節點 C 重啓後,客戶端 2 鎖住了 C,D,E,加鎖成功。
這樣,客戶端 1 和客戶端 2 就同時獲得了鎖。
在這裏插入圖片描述
這時候該怎麼辦?
我們是不能保證,在分佈式及羣中,沒有結點會宕機的。

在默認情況下,redis 的 AOF 持久化方式是每秒寫一次磁盤(即執行 fsync),因此最壞情況下可能丟失 1 秒的數據。
爲了儘可能不丟數據,redis 也允許設置成每次修改數據都進行 fsync,但這會降低性能。
當然,即使執行了 fsync 也仍然有可能丟失數據(這取決於系統而不是 redis 的實現)。
所以,上面分析的由於節點重啓引發的鎖失效問題,總是有可能出現的。

那麼,既然鎖可能因宕機而丟失,已經無法再恢復。於是,antirez 又提出了延遲重啓 (delayed restarts)的概念。
也就是說,一個節點崩潰後,先不立即重啓它,而是等待一段時間再重啓,這段時間應該大於鎖的有效時間。
這樣的話,只要這個結點不重啓,如果此時,持有鎖的線程所佔據的 redis 只剩下了 2 臺,那麼,這把鎖就無法被繼續維持,那麼,只要失效時間一到,鎖就會被保證被迫過期釋放。
所以,延遲重啓,就使得這個節點在重啓前所參與的鎖都會過期,它在重啓後就不會對現有的鎖造成影響。

不過關於 Redlock 還有一點細節值得拿出來分析一下:
在最後釋放鎖 的時候,antirez 在算法描述中特別強調,客戶端應該向所有 redis 節點釋放鎖。
也就是說,即使當時向某個節點獲取鎖沒有成功,在釋放鎖的時候也不應該漏掉這個節點。

這是爲什麼呢?

設想這樣一種情況,客戶端發給某個 redis 節點的獲取鎖的請求成功到達了該 redis 節點,這個節點也成功執行了 SET操作,但是它返回給客戶端的響應包卻丟失了。
這在客戶端看來,獲取鎖的請求由於超時而失敗了,但在 redis 這邊看來,加鎖已經成功了。
因此,釋放鎖的時候,客戶端也應該對當時獲取鎖失敗的那些 redis 節點同樣發起請求。

因爲這種情況在異步通信模型中是有可能發生的:客戶端向服務器通信是正常的,但反方向卻是有問題的。

談到這裏,對於緩存的擊穿,以及涉及到的一點 redis 分佈式鎖的知識,你應該已經瞭解得差不多了。
而且,其實實際上,你會發現,我談論到的知識,絕對是不限於僅僅在這個 redis 身上的,而是這整個架構的設計和思維。
如果你僅僅把眼光放在這個小小的 redis 上,那你是永遠不會達到架構師的水平的。

緩存雪崩
其實把緩存擊穿搞清楚了,那麼你去理解緩存雪崩也會容易許多。

緩存雪崩,指的是大面積的 key 同時過期,導致大量併發打到我們的數據庫。
不像擊穿,只是因爲 1 個 key 的過期。

所以,對於雪崩來說,一般,少量的 key 失效,所帶來的數據庫的併發壓力是不會太大的。
而是大量 key 的同時失效,導致所有 key 的併發加起來,會影響到我們的數據庫。

那就算一個 key 失效,也會對數據庫造成很大的影響,那麼你把雪崩的所有 key 拆成一個一個 key 來看,也就是雪崩可以拆分成一個一個緩存擊穿的集合。

其實在真實場景中,雪崩纔是一個更容易發生的一個問題,它不像擊穿那麼極端,一個 key 就成千上萬的併發,直接把數據庫打垮了;
而是,可能就一個 key 幾十幾百的併發,然後大量的 key 一過期,然後就使得好多併發,同時疊加起來,累積到上千上萬個,把數據庫打崩了。

那麼既然緩存擊穿已經給過解決方案了,那麼我們現在要關注的,則是如何緩解雪崩所帶來的壓力。

因爲,key 是同時失效,所以導致很多 key 的併發,一起壓上來,纔會使得數據庫的併發壓力過大,
所以,我們很正常的思路就是,就是讓併發分散開來。

首先一個很常見的做法就是,分散 key 的過期時間。

確實,這麼做是可行的,因爲這個問題的本質,就是要讓瞬間到來的併發,把它分散開。
而給了一定的隨機過期時間之後,就能夠使得 key 會分散開,一個一個過期,
所以,併發量就會分成一部分,一部分,少量的打到數據庫上。

看起來就很像一個削峯的操作。

這個方法,是最簡單有效的。所以一般情況,我們都採用這種方式。

在這裏插入圖片描述
不過,要考慮一種情況,就是,如果你的業務對時點性要求高,必須每天的指定時間,去更新我們的數據。
就比如遊戲每日零點更新,或者財報記錄……等等等等。

就是,在某一個固定的時間,由於業務要求,必須使得數據刷新,並且不允許出現舊數據。
所以,必須讓緩存全部失效。

像這樣的業務應該怎麼辦?

因爲這個場景,非常類似削峯操作,所以有人會覺得,可以用 MQ,先把讀請求打入 MQ,再一個一個依次消費。
在這裏插入圖片描述

這樣可行嗎?

首先,從系統實現來說,是可以保證,數據庫的請求壓力先被扛下來,然後異步消費。
但是,對於讀請求,是不應該用到消息隊列的。
如果是異步寫,沒有什麼問題,用戶只要求能把數據存入即可。
但是對於讀,如果用隊列依次讀取,那麼,大量用戶的響應延遲,就會變高,這對用戶體驗的影響是不容忽視的。

所以,對於讀請求,不適合用隊列的方式,因爲這已經把請求串行化了,不再是併發執行。

於是,還有人提出觀點,讓緩存提前開始更新。

但是,提前更新了之後,比如 58 分開始更新,59 分的時候,有大量數據又被修改了呢?
所以,數據是不準確的,那些 59 分修改了之後的客戶,在 12 點查看數據的時候,發現數據仍然沒有變化,他們就會認爲,系統的 12 點更新的說法不靠譜,公司不值得信任。

所以,緩存是必須要在 12 點準時失效,準時更新的。你無法讓更新時間進行變化。

那麼,你還能想到什麼辦法?

因爲此刻,redis 中的數據,是必須立即失效的,你不能夠改變。
那麼,對於 redis,就不能夠把時間分散開來。

既然 redis 不可以,那麼其它地方可以嗎?

所以,這就是考驗你思維和功力的時候。

既然 redis 無法分散過期時間,那麼,我們去查數據的時候,是不是可以把時間稍微地分散一下?

所以到了下面這種情景:

時間一到,redis 數據全部失效;
大量併發前來查詢;
在 service 服務層,查詢時,設置一個短暫的隨機延遲時間;
這樣,於是查詢的操作,就被分散了開來;
少量的請求,先查詢,就會讀數據庫,然後存入 redis;
其他請求,由於隨機時間,稍稍慢了點,就可以去 redis 讀出數據。

在這裏插入圖片描述
這就是,從業務層,再把時間分散。

帶來的影響,也就是客戶等待時,會多那麼幾十毫秒的延遲,不過對於人來說,是微乎其微,可以接受的。

所以,對於時點性要求高的業務要求,雪崩的問題,想要解決,還必須稍微多思考,變通一下。

緩存穿透
很多人會把緩存穿透和擊穿搞混,主要是名詞方面的混淆。
更多的,我覺得關注意思即可。

緩存穿透,與擊穿的區別就是,
擊穿:數據庫裏“有”數據;
穿透:數據庫裏“沒”數據。

所以,緩存擊穿可以規避,因爲只是 redis 緩存數據失效了,而數據庫裏有數據,只要把數據庫裏的數據更新到 redis 上,那麼就可以解決掉緩存擊穿的問題。

但是,緩存穿透,意味着,這個數據,數據庫裏也沒有。
所以,就不可能會把數據存到 redis 緩存裏,因此只要有人來查詢,就一定緩存中查不到,所以就一定要走數據庫。
那麼,假設很多人,故意去查那些數據庫裏也沒有的記錄,我們的 redis 就起不到屏障的作用,因爲 redis 裏不可能有數據,所以併發查詢就一定會打到數據庫的身上。

那麼,想要解決緩存穿透,就必須想辦法,能夠識別出,哪些請求的數據,是數據庫沒有的,然後,對這些請求的查詢,進行過濾。

如果你以前沒有了解過這些知識,那你可以先想一想,可以用什麼辦法?

比較簡單的,就是選擇,當用戶查詢不存在的數據時,將這個 key,存入 redis,然後用一個特殊的 value 來表示,這是一個不存在的數據。
但是,如果有大量的請求,都請求各不相同的不存在的數據,那麼,redis 的緩存,就會用來存儲大量沒用的數據,就會造成空間的浪費。
而且,一般這部分的請求,都是人刻意攻擊服務器。

而且,很明確的一點,就是數據是無限的,我們不可能找出所有的數據庫中不存在的數據;
但是,對於數據庫裏已有的數據,那就是有限的,所以我們可以找出所有已經存在的數據;
這樣,當請求打過來的時候,我們就能以此來判斷,這個數據是否存在。

但是,由於數據量的巨大,我們必須得想一個方法,怎樣用儘可能少的空間和時間,去對數據是已有在做一個判斷。
正是因爲 redis 的小,內存空間的可貴,才使得,我們不能夠去緩存所有的數據,因而纔會有查詢 DB 的操作。
所以,我們不可能直接將完整的數據信息全部搬入內存。

那麼,既然數據無法完整存儲,那麼是否可以,只保留 key,省略 value,從而使得單位範圍內內存能存儲的信息量大了很多。
因爲數據的大部分空間,都是 value 佔用的,一般 key 和 value 相比,都是非常非常小的。

所以,是不是就可以額外開闢一片 Set,用來專門存儲 key,這樣,每次要訪問數據庫前,先去 key set 中查詢時候存在,如果存在,那麼再去訪問數據庫。

這樣,確實可以使得緩存不會穿透了。而且相比緩存全量 key、value,只存儲 key 會使得內存的佔用變小了很多。

但是,理論上聽起來似乎不錯,假設,我們一臺 redis,用來緩存後端 4T 的熱點數據;
現在爲了實現緩存穿透的解決,假設一個 key : value = 1 : 99,那麼需要緩存的總量 key 大約要 40G。

所以,到了數據面前,就能發現仍然是一個超高成本的方法。

那麼,既然如果存儲 key,空間仍然很大,那麼我們能否想出一個更節省空間的存儲方式?

一般有點經驗的都會想到用 bit,也就是用一個位來存儲,這已經是計算機中的一個最小的存儲單位了。

現在,先不管如何實現,我們先來看一下,假設用 bitmap,那麼空間的花費代價有多少。
因爲一個 key 只佔用一個 bit,所以,假設我們花費 1 個字節的空間,就能存儲 8 個 key;
假設,我們用了 1 M字節的空間,就可以存儲 800w 個 key;
那麼我們在增多一點,用 100MB,那麼就能存儲 8億 個 key;

由此可見,用 bitmap,確確實實可以達到對空間利用的極致。

那麼,既然空間的問題解決了,下面就要解決如何使用這些空間:
也就是如何把一個 key,和一個 bit 去對應起來。

有點經驗的,立刻就能夠想到,用哈希映射。
就比如我們的 HashMap,我們的每一個 key,都能通過哈希函數,轉換成一個數組的下標,然後將鍵值對,存儲在 HashMap 中。
HashSet,就是隻存儲 key,而不存儲 value。

所以,採用哈希映射,就可以將那些所有存在的 key,全部對應到這個 bitmap 的每一個槽位上,
這樣,我們可以將所有存在的 key,把它映射到一個 bit 槽位上,然後用 1 表示,其他剩餘的部分,就用 0 表示,
這樣,當一個查詢的 key 被映射到 0 這個槽位,那麼就代表這個數據不存在,所以就可以直接返回。
因此,就可以實現對請求數據的過濾。

看起來似乎很完美,既解決了空間的問題,又可以保證每一個 key,能夠映射到一個槽位上。

但是,仔細一思考,就會發現,其實還有問題。

首先,我們先談我們熟悉的:HashMap。
我們知道,如果 HashMap 出現衝突的話,會用一個鏈表去連接起那些衝突的結點,從而保證,所有的 key,都能在裏面完好無缺的存着,不存在不同的 key 將其他 key 擠佔的情況。

但是,由於此時的槽位已經縮減爲 bit,已經不能夠再往上去追加其它的數據結構了,所以,就無法用鏈表解決衝突碰撞的產生。
而且,不單單是不能用鏈表,還有一個原因,就是由於 bit 槽位只存 0、1,即使碰撞,也無法判斷原有的信息是什麼,到底是不是同一條信息。

所以,bitmap 由於它的精簡,因此,不能夠將碰撞給消除解決。
在這裏插入圖片描述

那麼,該怎麼辦?

實際上,這個問題是無法被完全解決的,由於節省空間,每一個槽位被精簡到了一個比特,所以,能表示的信息已經只有 0、1 兩種,從而無法表示出其他信息。
除非增加空間來保存更多的信息,否則就無法被解決。

那麼,假設增加空間,加一個 bit,體積就會增大一倍;再加兩個,體積又會翻一倍;
看起來,這種方式,爲了絕對的解決衝突,花費的代價,是有點高的。
我們費盡心思節下來的空間,又會被重新花費。

那麼,既然作爲軟件工程的學習者,我們必須有這麼一個思維,就是不較真,不去追求極限。
比如強一致性,因爲會破壞可用性,所以一般都會採取弱一致性,或者最終一致性。

這裏也是如此,既然不能解決衝突問題,那麼,可以想辦法,讓衝突發生的概率更小,而不是去完全地讓衝突消失。

所以,就可以延伸到布隆算法。

首先,因爲在單次哈希的情況下,會產生一定的碰撞;
因此,爲了降低碰撞而導致的概率,於是,採用多次哈希的方式,每一次哈希都往一個槽位寫上 1;
當來查詢一個不存在的 key 的時候,就可以進行同樣的多次哈希,第一次可能碰巧撞對,得到 1,但是後面還有兩次,這樣,就不一定有那麼好的運氣,還能夠撞對。
因此,這樣,可以降低緩存穿透的概率。

這樣,只要對應出我們的需求,去調整 bitmap 的大小,以及哈希函數的個數,就可以得到不同的過濾的百分比,雖然可能出現漏網之魚,不過那也已經是少之又少了。

在這裏插入圖片描述
不過,對於布隆過濾器,我們的使用還是需要去思考一下的。

首先,對於布隆過濾器,我們可以把它放在客戶端。
也就是每一個客戶端都包含這麼一個算法,以及存儲了一個 bitmap,來過濾無效的查詢請求。
這樣,redis 的壓力就比較輕。

不過,由於是基於JVM內存的一種布隆過濾器,
所以重啓即失效;
而且由於存儲在本地內存中,導致無法應用在分佈式場景;
因此也不支持大數據量存儲。

第二,我們也可以選擇,客戶端包含算法,然後,把 bitmap,存到 redis 上去,
這樣,客戶端就是無狀態的,因而可以輕鬆的複製。

第三,或者,還可以,直接把算法,和 bitmap,一併放到 redis 上去,也就是在 redis 當中集成這麼一個模塊。
這樣,客戶端就又省去了代碼,就能夠更加靈活;
而且也省去了重啓失效和定時任務維護的成本;
但是,由於布隆過濾器外遷到了 redis 上,從而會導致網絡 I/O 的開銷增大,並且性能會比在 JVM 上的 Google 布隆過濾器性能略低。

不管怎樣,至少,穿透的問題,似乎已經迎來了大結局。

不過,你有沒有想到布隆過濾器有一個缺點,
就是,我們談到這裏,好像隻字未提,布隆過濾器的刪除。
也就是,我們的布隆過濾器,只能往裏邊添加數據,而不能夠刪除數據。

你現在想一想,是不是這麼一個情況!

也就是說,如果數據頻繁增刪改,是不太適合用布隆過濾器的。
因爲,一個數據變更之後,布隆過濾器無法刪除 key,因此,只能重新創建一個布隆過濾器,再加載一遍所有的數據,創建出 bitmap。

那麼,解決的話,可以用布穀鳥這樣的,帶刪除功能的,來滿足動態變化的需求。

作者的話
其實,緩存的擊穿、雪崩、穿透,看似很平常簡單的問題,其實背後能夠涉及到的知識點,可以有很多。

很多時候,對於一個問題,不是去拘泥於這個問題,而是你能夠,聯想到這個問題所置身的場景,能夠理解清楚,整個系統的環境,能夠從一個高的維度,去看這一系列的過程。

所以,對於我們來說,重要的,不是去背過這些答案,而是能夠從一個系統、一個架構的角度,去理清設計的原由和思路。

這樣,你才能夠去面對,這個不斷變化着的時代。
————————————————
版權聲明:本文爲CSDN博主「小龍JWY」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/weixin_44051223/article/details/105590857

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