2021年最新Redis面試題匯總(4)
1、Redis 實現(xiàn)分布式鎖
1)加鎖
加鎖通常使用 set 命令來實現(xiàn),偽代碼如下:
set key value PX milliseconds NX
幾個參數(shù)的意義如下:
key、value
:鍵值對
PX milliseconds
:設置鍵的過期時間為 milliseconds 毫秒。
NX
:只在鍵不存在時,才對鍵進行設置操作。SET key value NX 效果等同于 SETNX key value。
PX
、expireTime
參數(shù)則是用于解決沒有解鎖導致的死鎖問題。因為如果沒有過期時間,萬一程序員寫的代碼有 bug 導致沒有解鎖操作,則就出現(xiàn)了死鎖,因此該參數(shù)起到了一個“兜底”的作用。
NX
參數(shù)用于保證在多個線程并發(fā) set 下,只會有1個線程成功,起到了鎖的“唯一”性。
2)解鎖
解鎖需要兩步操作:
1)查詢當前“鎖”是否還是我們持有,因為存在過期時間,所以可能等你想解鎖的時候,“鎖”已經(jīng)到期,然后被其他線程獲取了,所以我們在解鎖前需要先判斷自己是否還持有“鎖”
2)如果“鎖”還是我們持有,則執(zhí)行解鎖操作,也就是刪除該鍵值對,并返回成功;否則,直接返回失敗。
由于當前 Redis 還沒有原子命令直接支持這兩步操作,所以當前通常是使用 Lua 腳本來執(zhí)行解鎖操作,Redis 會保證腳本里的內(nèi)容執(zhí)行是一個原子操作。
腳本代碼如下,邏輯比較簡單:
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
兩個參數(shù)的意義如下:
KEYS[1]:我們要解鎖的 key
ARGV[1]:我們加鎖時的 value,用于判斷當“鎖”是否還是我們持有,如果被其他線程持有了,value 就會發(fā)生變化。
上述方法是 Redis 當前實現(xiàn)分布式鎖的主流方法,可能會有一些小優(yōu)區(qū)別,但是核心都是這個思路??粗孟駴]啥毛病,但是真的是這個樣子嗎?讓我們繼續(xù)往下看。
2、Redis 分布式鎖過期了,還沒處理完怎么辦
為了防止死鎖,我們會給分布式鎖加一個過期時間,但是萬一這個時間到了,我們業(yè)務邏輯還沒處理完,怎么辦?
首先,我們在設置過期時間時要結(jié)合業(yè)務場景去考慮,盡量設置一個比較合理的值,就是理論上正常處理的話,在這個過期時間內(nèi)是一定能處理完畢的。
之后,我們再來考慮對這個問題進行兜底設計。
關(guān)于這個問題,目前常見的解決方法有兩種:
- 守護線程“續(xù)命”:額外起一個線程,定期檢查線程是否還持有鎖,如果有則延長過期時間。Redisson 里面就實現(xiàn)了這個方案,使用“看門狗”定期檢查(每1/3的鎖時間檢查1次),如果線程還持有鎖,則刷新過期時間。
- 超時回滾:當我們解鎖時發(fā)現(xiàn)鎖已經(jīng)被其他線程獲取了,說明此時我們執(zhí)行的操作已經(jīng)是“不安全”的了,此時需要進行回滾,并返回失敗。
同時,需要進行告警,人為介入驗證數(shù)據(jù)的正確性,然后找出超時原因,是否需要對超時時間進行優(yōu)化等等。
3、守護線程續(xù)命的方案有什么問題嗎
Redisson 使用看門狗(守護線程)“續(xù)命”的方案在大多數(shù)場景下是挺不錯的,也被廣泛應用于生產(chǎn)環(huán)境,但是在極端情況下還是會存在問題。
問題例子如下:
- 線程1首先獲取鎖成功,將鍵值對寫入 redis 的 master 節(jié)點
- 在 redis 將該鍵值對同步到 slave 節(jié)點之前,master 發(fā)生了故障
- redis 觸發(fā)故障轉(zhuǎn)移,其中一個 slave 升級為新的 master此時新的 master
- 并不包含線程1寫入的鍵值對,因此線程2嘗試獲取鎖也可以成功拿到鎖
- 此時相當于有兩個線程獲取到了鎖,可能會導致各種預期之外的情況發(fā)生,例如最常見的臟數(shù)據(jù)
解決方法:上述問題的根本原因主要是由于 redis 異步復制帶來的數(shù)據(jù)不一致問題導致的,因此解決的方向就是保證數(shù)據(jù)的一致。
當前比較主流的解法和思路有兩種:
1)Redis 作者提出的 RedLock;
2)Zookeeper 實現(xiàn)的分布式鎖。
4、RedLock
首先,該方案也是基于文章開頭的那個方案(set加鎖、lua腳本解鎖)進行改良的,所以 antirez 只描述了差異的地方,大致方案如下。
假設我們有 N 個 Redis 主節(jié)點,例如 N = 5,這些節(jié)點是完全獨立的,我們不使用復制或任何其他隱式協(xié)調(diào)系統(tǒng),為了取到鎖,客戶端應該執(zhí)行以下操作:
- 獲取當前時間,以毫秒為單位。
- 依次嘗試從5個實例,使用相同的 key 和隨機值(例如UUID)獲取鎖。當向Redis 請求獲取鎖時,客戶端應該設置一個超時時間,這個超時時間應該小于鎖的失效時間。例如你的鎖自動失效時間為10秒,則超時時間應該在 5-50 毫秒之間。這樣可以防止客戶端在試圖與一個宕機的 Redis 節(jié)點對話時長時間處于阻塞狀態(tài)。如果一個實例不可用,客戶端應該盡快嘗試去另外一個Redis實例請求獲取鎖。
- 客戶端通過當前時間減去步驟1記錄的時間來計算獲取鎖使用的時間。當且僅當從大多數(shù)(N/2+1,這里是3個節(jié)點)的Redis節(jié)點都取到鎖,并且獲取鎖使用的時間小于鎖失效時間時,鎖才算獲取成功。
- 如果取到了鎖,其真正有效時間等于初始有效時間減去獲取鎖所使用的時間(步驟3計算的結(jié)果)。
- 如果由于某些原因未能獲得鎖(無法在至少N/2+1個Redis實例獲取鎖、或獲取鎖的時間超過了有效時間),客戶端應該在所有的Redis實例上進行解鎖(即便某些Redis實例根本就沒有加鎖成功,防止某些節(jié)點獲取到鎖但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖)。
可以看出,該方案為了解決數(shù)據(jù)不一致的問題,直接舍棄了異步復制,只使用 master 節(jié)點,同時由于舍棄了 slave,為了保證可用性,引入了 N 個節(jié)點,官方建議是 5。
該方案看著挺美好的,但是實際上我所了解到的在實際生產(chǎn)上應用的不多,主要有兩個原因:1)該方案的成本似乎有點高,需要使用5個實例;2)該方案一樣存在問題。
該方案主要存以下問題:
- 嚴重依賴系統(tǒng)時鐘。如果線程1從3個實例獲取到了鎖,但是這3個實例中的某個實例的系統(tǒng)時間走的稍微快一點,則它持有的鎖會提前過期被釋放,當他釋放后,此時又有3個實例是空閑的,則線程2也可以獲取到鎖,則可能出現(xiàn)兩個線程同時持有鎖了。
- 如果線程1從3個實例獲取到了鎖,但是萬一其中有1臺重啟了,則此時又有3個實例是空閑的,則線程2也可以獲取到鎖,此時又出現(xiàn)兩個線程同時持有鎖了。
針對以上問題其實后續(xù)也有人給出一些相應的解法,但是整體上來看還是不夠完美,所以目前實際應用得不是那么多。
5、使用緩存時,先操作數(shù)據(jù)庫 or 先操作緩存
1)先操作數(shù)據(jù)庫
案例如下,有兩個并發(fā)的請求,一個寫請求,一個讀請求,流程如下:
可能存在的臟數(shù)據(jù)時間范圍:更新數(shù)據(jù)庫后,失效緩存前。這個時間范圍很小,通常不會超過幾毫秒。
2)先操作緩存
案例如下,有兩個并發(fā)的請求,一個寫請求,一個讀請求,流程如下:
可能存在的臟數(shù)據(jù)時間范圍:更新數(shù)據(jù)庫后,下一次對該數(shù)據(jù)的更新前。這個時間范圍不確定性很大,情況如下:
- 如果下一次對該數(shù)據(jù)的更新馬上就到來,那么會失效緩存,臟數(shù)據(jù)的時間就很短。
- 如果下一次對該數(shù)據(jù)的更新要很久才到來,那這期間緩存保存的一直是臟數(shù)據(jù),時間范圍很長。
結(jié)論:通過上述案例可以看出,先操作數(shù)據(jù)庫和先操作緩存都會存在臟數(shù)據(jù)的情況。但是相比之下,先操作數(shù)據(jù)庫,再操作緩存是更優(yōu)的方式,即使在并發(fā)極端情況下,也只會出現(xiàn)很小量的臟數(shù)據(jù)。
6、為什么是讓緩存失效,而不是更新緩存
1)更新緩存
案例如下,有兩個并發(fā)的寫請求,流程如下:
分析:數(shù)據(jù)庫中的數(shù)據(jù)是請求B的,緩存中的數(shù)據(jù)是請求A的,數(shù)據(jù)庫和緩存存在數(shù)據(jù)不一致。
2)失效(刪除)緩存
案例如下,有兩個并發(fā)的寫請求,流程如下:
分析:由于是刪除緩存,所以不存在數(shù)據(jù)不一致的情況。
結(jié)論:通過上述案例,可以很明顯的看出,失效緩存是更優(yōu)的方式。
7、如何保證數(shù)據(jù)庫和緩存的數(shù)據(jù)一致性
在上文的案例中,無論是先操作數(shù)據(jù)庫,還是先操作緩存,都會存在臟數(shù)據(jù)的情況,有辦法避免嗎?
答案是有的,由于數(shù)據(jù)庫和緩存是兩個不同的數(shù)據(jù)源,要保證其數(shù)據(jù)一致性,其實就是典型的分布式事務場景,可以引入分布式事務來解決,常見的有:2PC、TCC、MQ事務消息等。
但是引入分布式事務必然會帶來性能上的影響,這與我們當初引入緩存來提升性能的目的是相違背的。
所以在實際使用中,通常不會去保證緩存和數(shù)據(jù)庫的強一致性,而是做出一定的犧牲,保證兩者數(shù)據(jù)的最終一致性。
如果是實在無法接受臟數(shù)據(jù)的場景,則比較合理的方式是放棄使用緩存,直接走數(shù)據(jù)庫。
保證數(shù)據(jù)庫和緩存數(shù)據(jù)最終一致性的常用方案如下:
1)更新數(shù)據(jù)庫,數(shù)據(jù)庫產(chǎn)生 binlog。
2)監(jiān)聽和消費 binlog,執(zhí)行失效緩存操作。
3)如果步驟2失效緩存失敗,則引入重試機制,將失敗的數(shù)據(jù)通過MQ方式進行重試,同時考慮是否需要引入冪等機制。
兜底:當出現(xiàn)未知的問題時,及時告警通知,人為介入處理。
人為介入是終極大法,那些外表看著光鮮艷麗的應用,其背后大多有一群苦逼的程序員,在不斷的修復各種臟數(shù)據(jù)和bug。
8、緩存穿透
描述:訪問一個緩存和數(shù)據(jù)庫都不存在的 key,此時會直接打到數(shù)據(jù)庫上,并且查不到數(shù)據(jù),沒法寫緩存,所以下一次同樣會打到數(shù)據(jù)庫上。
此時,緩存起不到作用,請求每次都會走到數(shù)據(jù)庫,流量大時數(shù)據(jù)庫可能會被打掛。此時緩存就好像被“穿透”了一樣,起不到任何作用。
解決方案:
1)接口校驗。在正常業(yè)務流程中可能會存在少量訪問不存在 key 的情況,但是一般不會出現(xiàn)大量的情況,所以這種場景最大的可能性是遭受了非法攻擊??梢栽谧钔鈱酉茸鲆粚有r灒河脩翳b權(quán)、數(shù)據(jù)合法性校驗等,例如商品查詢中,商品的ID是正整數(shù),則可以直接對非正整數(shù)直接過濾等等。
2)緩存空值。當訪問緩存和DB都沒有查詢到值時,可以將空值寫進緩存,但是設置較短的過期時間,該時間需要根據(jù)產(chǎn)品業(yè)務特性來設置。
3)布隆過濾器。使用布隆過濾器存儲所有可能訪問的 key,不存在的 key 直接被過濾,存在的 key 則再進一步查詢緩存和數(shù)據(jù)庫。
9、布隆過濾器
布隆過濾器的特點是判斷不存在的,則一定不存在;判斷存在的,大概率存在,但也有小概率不存在。并且這個概率是可控的,我們可以讓這個概率變小或者變高,取決于用戶本身的需求。
布隆過濾器由一個 bitSet 和 一組 Hash 函數(shù)(算法)組成,是一種空間效率極高的概率型算法和數(shù)據(jù)結(jié)構(gòu),主要用來判斷一個元素是否在集合中存在。
在初始化時,bitSet 的每一位被初始化為0,同時會定義 Hash 函數(shù),例如有3組 Hash 函數(shù):hash1、hash2、hash3。
寫入流程
當我們要寫入一個值時,過程如下,以“jionghui”為例:
1)首先將“jionghui”跟3組 Hash 函數(shù)分別計算,得到 bitSet 的下標為:1、7、10。
2)將 bitSet 的這3個下標標記為1。
假設我們還有另外兩個值:java 和 diaosi,按上面的流程跟 3組 Hash 函數(shù)分別計算,結(jié)果如下:
java:Hash 函數(shù)計算 bitSet 下標為:1、7、11
diaosi:Hash 函數(shù)計算 bitSet 下標為:4、10、11
查詢流程
當我們要查詢一個值時,過程如下,同樣以“jionghui”為例::
1)首先將“jionghui”跟3組 Hash 函數(shù)分別計算,得到 bitSet 的下標為:1、7、10。
2)查看 bitSet 的這3個下標是否都為1,如果這3個下標不都為1,則說明該值必然不存在,如果這3個下標都為1,則只能說明可能存在,并不能說明一定存在。
其實上圖的例子已經(jīng)說明了這個問題了,當我們只有值“jionghui”和“diaosi”時,bitSet 下標為1的有:1、4、7、10、11。
當我們又加入值“java”時,bitSet 下標為1的還是這5個,所以當 bitSet 下標為1的為:1、4、7、10、11 時,我們無法判斷值“java”存不存在。
其根本原因是,不同的值在跟 Hash 函數(shù)計算后,可能會得到相同的下標,所以某個值的標記位,可能會被其他值給標上了。
這也是為啥布隆過濾器只能判斷某個值可能存在,無法判斷必然存在的原因。但是反過來,如果該值根據(jù) Hash 函數(shù)計算的標記位沒有全部都為1,那么則說明必然不存在,這個是肯定的。
降低這種誤判率的思路也比較簡單:
- 一個是加大 bitSet 的長度,這樣不同的值出現(xiàn)“沖突”的概率就降低了,從而誤判率也降低。
- 提升 Hash 函數(shù)的個數(shù),Hash 函數(shù)越多,每個值對應的 bit 越多,從而誤判率也降低。
布隆過濾器的誤判率還有專門的推導公式,有興趣的可以去搜相關(guān)的文章和論文查看。
10、緩存擊穿
描述:某一個熱點 key,在緩存過期的一瞬間,同時有大量的請求打進來,由于此時緩存過期了,所以請求最終都會走到數(shù)據(jù)庫,造成瞬時數(shù)據(jù)庫請求量大、壓力驟增,甚至可能打垮數(shù)據(jù)庫。
解決方案:
1)加互斥鎖。在并發(fā)的多個請求中,只有第一個請求線程能拿到鎖并執(zhí)行數(shù)據(jù)庫查詢操作,其他的線程拿不到鎖就阻塞等著,等到第一個線程將數(shù)據(jù)寫入緩存后,直接走緩存。
關(guān)于互斥鎖的選擇,網(wǎng)上看到的大部分文章都是選擇 Redis 分布式鎖(可以參考我之前的文章:面試必問的分布式鎖,你懂了嗎?),因為這個可以保證只有一個請求會走到數(shù)據(jù)庫,這是一種思路。
但是其實仔細想想的話,這邊其實沒有必要保證只有一個請求走到數(shù)據(jù)庫,只要保證走到數(shù)據(jù)庫的請求能大大降低即可,所以還有另一個思路是 JVM 鎖。
JVM 鎖保證了在單臺服務器上只有一個請求走到數(shù)據(jù)庫,通常來說已經(jīng)足夠保證數(shù)據(jù)庫的壓力大大降低,同時在性能上比分布式鎖更好。
需要注意的是,無論是使用“分布式鎖”,還是“JVM 鎖”,加鎖時要按 key 維度去加鎖。
我看網(wǎng)上很多文章都是使用一個“固定的 key”加鎖,這樣會導致不同的 key 之間也會互相阻塞,造成性能嚴重損耗。
使用 redis 分布式鎖的偽代碼,僅供參考:
public Object getData(String key) throws InterruptedException { Object value = redis.get(key); // 緩存值過期 if (value == null) { // lockRedis:專門用于加鎖的redis; // "empty":加鎖的值隨便設置都可以 if (lockRedis.set(key, "empty", "PX", lockExpire, "NX")) { try { // 查詢數(shù)據(jù)庫,并寫到緩存,讓其他線程可以直接走緩存 value = getDataFromDb(key); redis.set(key, value, "PX", expire); } catch (Exception e) { // 異常處理 } finally { // 釋放鎖 lockRedis.delete(key); } } else { // sleep50ms后,進行重試 Thread.sleep(50); return getData(key); } } return value; }
2)熱點數(shù)據(jù)不過期。直接將緩存設置為不過期,然后由定時任務去異步加載數(shù)據(jù),更新緩存。
這種方式適用于比較極端的場景,例如流量特別特別大的場景,使用時需要考慮業(yè)務能接受數(shù)據(jù)不一致的時間,還有就是異常情況的處理,不要到時候緩存刷新不上,一直是臟數(shù)據(jù),那就涼了。
11、緩存雪崩
描述:大量的熱點 key 設置了相同的過期時間,導在緩存在同一時刻全部失效,造成瞬時數(shù)據(jù)庫請求量大、壓力驟增,引起雪崩,甚至導致數(shù)據(jù)庫被打掛。
緩存雪崩其實有點像“升級版的緩存擊穿”,緩存擊穿是一個熱點 key,緩存雪崩是一組熱點 key。
解決方案:
1)過期時間打散。既然是大量緩存集中失效,那最容易想到就是讓他們不集中生效。可以給緩存的過期時間時加上一個隨機值時間,使得每個 key 的過期時間分布開來,不會集中在同一時刻失效。
2)熱點數(shù)據(jù)不過期。該方式和緩存擊穿一樣,也是要著重考慮刷新的時間間隔和數(shù)據(jù)異常如何處理的情況。
3)加互斥鎖。該方式和緩存擊穿一樣,按 key 維度加鎖,對于同一個 key,只允許一個線程去計算,其他線程原地阻塞等待第一個線程的計算結(jié)果,然后直接走緩存即可。
總結(jié)
本篇文章就到這里了,希望能給你帶來幫助,也希望您能夠多多關(guān)注腳本之家的更多內(nèi)容!
相關(guān)文章
Java?數(shù)據(jù)結(jié)構(gòu)與算法系列精講之數(shù)組
數(shù)組是有序的元素序列,若將有限個類型相同的變量的集合命名,那么這個名稱為數(shù)組名。組成數(shù)組的各個變量稱為數(shù)組的分量,也稱為數(shù)組的元素,有時也稱為下標變量。數(shù)組是在程序設計中,為了處理方便, 把具有相同類型的若干元素按有序的形式組織起來的一種形式2022-02-02JAVA技術(shù)實現(xiàn)上傳下載文件到FTP服務器(完整)
這篇文章主要介紹了JAVA技術(shù)實現(xiàn)上傳下載文件到FTP服務器(完整),本文使用 Apache Jakarta Commons Net(commons-net-3.3.jar) 基于FileZilla Server服務器實現(xiàn)FTP服務器上文件的上傳/下載/刪除等操作,需要的朋友可以參考下2015-07-07Java switch case數(shù)據(jù)類型原理解析
這篇文章主要介紹了Java switch case數(shù)據(jù)類型原理解析,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-01-01