2021年最新Redis面試題匯總(4)
1、Redis 實現分布式鎖
1)加鎖
加鎖通常使用 set 命令來實現,偽代碼如下:
set key value PX milliseconds NX
幾個參數的意義如下:
key、value:鍵值對
PX milliseconds:設置鍵的過期時間為 milliseconds 毫秒。
NX:只在鍵不存在時,才對鍵進行設置操作。SET key value NX 效果等同于 SETNX key value。
PX、expireTime 參數則是用于解決沒有解鎖導致的死鎖問題。因為如果沒有過期時間,萬一程序員寫的代碼有 bug 導致沒有解鎖操作,則就出現了死鎖,因此該參數起到了一個“兜底”的作用。
NX 參數用于保證在多個線程并發(fā) set 下,只會有1個線程成功,起到了鎖的“唯一”性。
2)解鎖
解鎖需要兩步操作:
1)查詢當前“鎖”是否還是我們持有,因為存在過期時間,所以可能等你想解鎖的時候,“鎖”已經到期,然后被其他線程獲取了,所以我們在解鎖前需要先判斷自己是否還持有“鎖”
2)如果“鎖”還是我們持有,則執(zhí)行解鎖操作,也就是刪除該鍵值對,并返回成功;否則,直接返回失敗。
由于當前 Redis 還沒有原子命令直接支持這兩步操作,所以當前通常是使用 Lua 腳本來執(zhí)行解鎖操作,Redis 會保證腳本里的內容執(zhí)行是一個原子操作。
腳本代碼如下,邏輯比較簡單:
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
兩個參數的意義如下:
KEYS[1]:我們要解鎖的 key
ARGV[1]:我們加鎖時的 value,用于判斷當“鎖”是否還是我們持有,如果被其他線程持有了,value 就會發(fā)生變化。
上述方法是 Redis 當前實現分布式鎖的主流方法,可能會有一些小優(yōu)區(qū)別,但是核心都是這個思路??粗孟駴]啥毛病,但是真的是這個樣子嗎?讓我們繼續(xù)往下看。
2、Redis 分布式鎖過期了,還沒處理完怎么辦
為了防止死鎖,我們會給分布式鎖加一個過期時間,但是萬一這個時間到了,我們業(yè)務邏輯還沒處理完,怎么辦?
首先,我們在設置過期時間時要結合業(yè)務場景去考慮,盡量設置一個比較合理的值,就是理論上正常處理的話,在這個過期時間內是一定能處理完畢的。
之后,我們再來考慮對這個問題進行兜底設計。
關于這個問題,目前常見的解決方法有兩種:
- 守護線程“續(xù)命”:額外起一個線程,定期檢查線程是否還持有鎖,如果有則延長過期時間。Redisson 里面就實現了這個方案,使用“看門狗”定期檢查(每1/3的鎖時間檢查1次),如果線程還持有鎖,則刷新過期時間。
- 超時回滾:當我們解鎖時發(fā)現鎖已經被其他線程獲取了,說明此時我們執(zhí)行的操作已經是“不安全”的了,此時需要進行回滾,并返回失敗。
同時,需要進行告警,人為介入驗證數據的正確性,然后找出超時原因,是否需要對超時時間進行優(yōu)化等等。
3、守護線程續(xù)命的方案有什么問題嗎
Redisson 使用看門狗(守護線程)“續(xù)命”的方案在大多數場景下是挺不錯的,也被廣泛應用于生產環(huán)境,但是在極端情況下還是會存在問題。
問題例子如下:
- 線程1首先獲取鎖成功,將鍵值對寫入 redis 的 master 節(jié)點
- 在 redis 將該鍵值對同步到 slave 節(jié)點之前,master 發(fā)生了故障
- redis 觸發(fā)故障轉移,其中一個 slave 升級為新的 master此時新的 master
- 并不包含線程1寫入的鍵值對,因此線程2嘗試獲取鎖也可以成功拿到鎖
- 此時相當于有兩個線程獲取到了鎖,可能會導致各種預期之外的情況發(fā)生,例如最常見的臟數據
解決方法:上述問題的根本原因主要是由于 redis 異步復制帶來的數據不一致問題導致的,因此解決的方向就是保證數據的一致。
當前比較主流的解法和思路有兩種:
1)Redis 作者提出的 RedLock;
2)Zookeeper 實現的分布式鎖。
4、RedLock
首先,該方案也是基于文章開頭的那個方案(set加鎖、lua腳本解鎖)進行改良的,所以 antirez 只描述了差異的地方,大致方案如下。
假設我們有 N 個 Redis 主節(jié)點,例如 N = 5,這些節(jié)點是完全獨立的,我們不使用復制或任何其他隱式協(xié)調系統(tǒng),為了取到鎖,客戶端應該執(zhí)行以下操作:
- 獲取當前時間,以毫秒為單位。
- 依次嘗試從5個實例,使用相同的 key 和隨機值(例如UUID)獲取鎖。當向Redis 請求獲取鎖時,客戶端應該設置一個超時時間,這個超時時間應該小于鎖的失效時間。例如你的鎖自動失效時間為10秒,則超時時間應該在 5-50 毫秒之間。這樣可以防止客戶端在試圖與一個宕機的 Redis 節(jié)點對話時長時間處于阻塞狀態(tài)。如果一個實例不可用,客戶端應該盡快嘗試去另外一個Redis實例請求獲取鎖。
- 客戶端通過當前時間減去步驟1記錄的時間來計算獲取鎖使用的時間。當且僅當從大多數(N/2+1,這里是3個節(jié)點)的Redis節(jié)點都取到鎖,并且獲取鎖使用的時間小于鎖失效時間時,鎖才算獲取成功。
- 如果取到了鎖,其真正有效時間等于初始有效時間減去獲取鎖所使用的時間(步驟3計算的結果)。
- 如果由于某些原因未能獲得鎖(無法在至少N/2+1個Redis實例獲取鎖、或獲取鎖的時間超過了有效時間),客戶端應該在所有的Redis實例上進行解鎖(即便某些Redis實例根本就沒有加鎖成功,防止某些節(jié)點獲取到鎖但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖)。
可以看出,該方案為了解決數據不一致的問題,直接舍棄了異步復制,只使用 master 節(jié)點,同時由于舍棄了 slave,為了保證可用性,引入了 N 個節(jié)點,官方建議是 5。
該方案看著挺美好的,但是實際上我所了解到的在實際生產上應用的不多,主要有兩個原因:1)該方案的成本似乎有點高,需要使用5個實例;2)該方案一樣存在問題。
該方案主要存以下問題:
- 嚴重依賴系統(tǒng)時鐘。如果線程1從3個實例獲取到了鎖,但是這3個實例中的某個實例的系統(tǒng)時間走的稍微快一點,則它持有的鎖會提前過期被釋放,當他釋放后,此時又有3個實例是空閑的,則線程2也可以獲取到鎖,則可能出現兩個線程同時持有鎖了。
- 如果線程1從3個實例獲取到了鎖,但是萬一其中有1臺重啟了,則此時又有3個實例是空閑的,則線程2也可以獲取到鎖,此時又出現兩個線程同時持有鎖了。
針對以上問題其實后續(xù)也有人給出一些相應的解法,但是整體上來看還是不夠完美,所以目前實際應用得不是那么多。
5、使用緩存時,先操作數據庫 or 先操作緩存
1)先操作數據庫
案例如下,有兩個并發(fā)的請求,一個寫請求,一個讀請求,流程如下:

可能存在的臟數據時間范圍:更新數據庫后,失效緩存前。這個時間范圍很小,通常不會超過幾毫秒。
2)先操作緩存
案例如下,有兩個并發(fā)的請求,一個寫請求,一個讀請求,流程如下:

可能存在的臟數據時間范圍:更新數據庫后,下一次對該數據的更新前。這個時間范圍不確定性很大,情況如下:
- 如果下一次對該數據的更新馬上就到來,那么會失效緩存,臟數據的時間就很短。
- 如果下一次對該數據的更新要很久才到來,那這期間緩存保存的一直是臟數據,時間范圍很長。
結論:通過上述案例可以看出,先操作數據庫和先操作緩存都會存在臟數據的情況。但是相比之下,先操作數據庫,再操作緩存是更優(yōu)的方式,即使在并發(fā)極端情況下,也只會出現很小量的臟數據。
6、為什么是讓緩存失效,而不是更新緩存
1)更新緩存
案例如下,有兩個并發(fā)的寫請求,流程如下:

分析:數據庫中的數據是請求B的,緩存中的數據是請求A的,數據庫和緩存存在數據不一致。
2)失效(刪除)緩存
案例如下,有兩個并發(fā)的寫請求,流程如下:

分析:由于是刪除緩存,所以不存在數據不一致的情況。
結論:通過上述案例,可以很明顯的看出,失效緩存是更優(yōu)的方式。
7、如何保證數據庫和緩存的數據一致性
在上文的案例中,無論是先操作數據庫,還是先操作緩存,都會存在臟數據的情況,有辦法避免嗎?
答案是有的,由于數據庫和緩存是兩個不同的數據源,要保證其數據一致性,其實就是典型的分布式事務場景,可以引入分布式事務來解決,常見的有:2PC、TCC、MQ事務消息等。
但是引入分布式事務必然會帶來性能上的影響,這與我們當初引入緩存來提升性能的目的是相違背的。
所以在實際使用中,通常不會去保證緩存和數據庫的強一致性,而是做出一定的犧牲,保證兩者數據的最終一致性。
如果是實在無法接受臟數據的場景,則比較合理的方式是放棄使用緩存,直接走數據庫。
保證數據庫和緩存數據最終一致性的常用方案如下:
1)更新數據庫,數據庫產生 binlog。
2)監(jiān)聽和消費 binlog,執(zhí)行失效緩存操作。
3)如果步驟2失效緩存失敗,則引入重試機制,將失敗的數據通過MQ方式進行重試,同時考慮是否需要引入冪等機制。

兜底:當出現未知的問題時,及時告警通知,人為介入處理。
人為介入是終極大法,那些外表看著光鮮艷麗的應用,其背后大多有一群苦逼的程序員,在不斷的修復各種臟數據和bug。
8、緩存穿透
描述:訪問一個緩存和數據庫都不存在的 key,此時會直接打到數據庫上,并且查不到數據,沒法寫緩存,所以下一次同樣會打到數據庫上。
此時,緩存起不到作用,請求每次都會走到數據庫,流量大時數據庫可能會被打掛。此時緩存就好像被“穿透”了一樣,起不到任何作用。
解決方案:
1)接口校驗。在正常業(yè)務流程中可能會存在少量訪問不存在 key 的情況,但是一般不會出現大量的情況,所以這種場景最大的可能性是遭受了非法攻擊??梢栽谧钔鈱酉茸鲆粚有r灒河脩翳b權、數據合法性校驗等,例如商品查詢中,商品的ID是正整數,則可以直接對非正整數直接過濾等等。
2)緩存空值。當訪問緩存和DB都沒有查詢到值時,可以將空值寫進緩存,但是設置較短的過期時間,該時間需要根據產品業(yè)務特性來設置。
3)布隆過濾器。使用布隆過濾器存儲所有可能訪問的 key,不存在的 key 直接被過濾,存在的 key 則再進一步查詢緩存和數據庫。
9、布隆過濾器
布隆過濾器的特點是判斷不存在的,則一定不存在;判斷存在的,大概率存在,但也有小概率不存在。并且這個概率是可控的,我們可以讓這個概率變小或者變高,取決于用戶本身的需求。
布隆過濾器由一個 bitSet 和 一組 Hash 函數(算法)組成,是一種空間效率極高的概率型算法和數據結構,主要用來判斷一個元素是否在集合中存在。
在初始化時,bitSet 的每一位被初始化為0,同時會定義 Hash 函數,例如有3組 Hash 函數:hash1、hash2、hash3。
寫入流程
當我們要寫入一個值時,過程如下,以“jionghui”為例:
1)首先將“jionghui”跟3組 Hash 函數分別計算,得到 bitSet 的下標為:1、7、10。
2)將 bitSet 的這3個下標標記為1。
假設我們還有另外兩個值:java 和 diaosi,按上面的流程跟 3組 Hash 函數分別計算,結果如下:
java:Hash 函數計算 bitSet 下標為:1、7、11
diaosi:Hash 函數計算 bitSet 下標為:4、10、11

查詢流程
當我們要查詢一個值時,過程如下,同樣以“jionghui”為例::
1)首先將“jionghui”跟3組 Hash 函數分別計算,得到 bitSet 的下標為:1、7、10。
2)查看 bitSet 的這3個下標是否都為1,如果這3個下標不都為1,則說明該值必然不存在,如果這3個下標都為1,則只能說明可能存在,并不能說明一定存在。
其實上圖的例子已經說明了這個問題了,當我們只有值“jionghui”和“diaosi”時,bitSet 下標為1的有:1、4、7、10、11。
當我們又加入值“java”時,bitSet 下標為1的還是這5個,所以當 bitSet 下標為1的為:1、4、7、10、11 時,我們無法判斷值“java”存不存在。
其根本原因是,不同的值在跟 Hash 函數計算后,可能會得到相同的下標,所以某個值的標記位,可能會被其他值給標上了。
這也是為啥布隆過濾器只能判斷某個值可能存在,無法判斷必然存在的原因。但是反過來,如果該值根據 Hash 函數計算的標記位沒有全部都為1,那么則說明必然不存在,這個是肯定的。
降低這種誤判率的思路也比較簡單:
- 一個是加大 bitSet 的長度,這樣不同的值出現“沖突”的概率就降低了,從而誤判率也降低。
- 提升 Hash 函數的個數,Hash 函數越多,每個值對應的 bit 越多,從而誤判率也降低。
布隆過濾器的誤判率還有專門的推導公式,有興趣的可以去搜相關的文章和論文查看。
10、緩存擊穿
描述:某一個熱點 key,在緩存過期的一瞬間,同時有大量的請求打進來,由于此時緩存過期了,所以請求最終都會走到數據庫,造成瞬時數據庫請求量大、壓力驟增,甚至可能打垮數據庫。
解決方案:
1)加互斥鎖。在并發(fā)的多個請求中,只有第一個請求線程能拿到鎖并執(zhí)行數據庫查詢操作,其他的線程拿不到鎖就阻塞等著,等到第一個線程將數據寫入緩存后,直接走緩存。
關于互斥鎖的選擇,網上看到的大部分文章都是選擇 Redis 分布式鎖(可以參考我之前的文章:面試必問的分布式鎖,你懂了嗎?),因為這個可以保證只有一個請求會走到數據庫,這是一種思路。
但是其實仔細想想的話,這邊其實沒有必要保證只有一個請求走到數據庫,只要保證走到數據庫的請求能大大降低即可,所以還有另一個思路是 JVM 鎖。
JVM 鎖保證了在單臺服務器上只有一個請求走到數據庫,通常來說已經足夠保證數據庫的壓力大大降低,同時在性能上比分布式鎖更好。
需要注意的是,無論是使用“分布式鎖”,還是“JVM 鎖”,加鎖時要按 key 維度去加鎖。
我看網上很多文章都是使用一個“固定的 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 {
// 查詢數據庫,并寫到緩存,讓其他線程可以直接走緩存
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)熱點數據不過期。直接將緩存設置為不過期,然后由定時任務去異步加載數據,更新緩存。
這種方式適用于比較極端的場景,例如流量特別特別大的場景,使用時需要考慮業(yè)務能接受數據不一致的時間,還有就是異常情況的處理,不要到時候緩存刷新不上,一直是臟數據,那就涼了。
11、緩存雪崩
描述:大量的熱點 key 設置了相同的過期時間,導在緩存在同一時刻全部失效,造成瞬時數據庫請求量大、壓力驟增,引起雪崩,甚至導致數據庫被打掛。
緩存雪崩其實有點像“升級版的緩存擊穿”,緩存擊穿是一個熱點 key,緩存雪崩是一組熱點 key。
解決方案:
1)過期時間打散。既然是大量緩存集中失效,那最容易想到就是讓他們不集中生效。可以給緩存的過期時間時加上一個隨機值時間,使得每個 key 的過期時間分布開來,不會集中在同一時刻失效。
2)熱點數據不過期。該方式和緩存擊穿一樣,也是要著重考慮刷新的時間間隔和數據異常如何處理的情況。
3)加互斥鎖。該方式和緩存擊穿一樣,按 key 維度加鎖,對于同一個 key,只允許一個線程去計算,其他線程原地阻塞等待第一個線程的計算結果,然后直接走緩存即可。
總結
本篇文章就到這里了,希望能給你帶來幫助,也希望您能夠多多關注腳本之家的更多內容!
相關文章
SpringBoot構造器注入循環(huán)依賴及解決方案
這篇文章主要介紹了SpringBoot構造器注入循環(huán)依賴及解決方案,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-03-03
spring-gateway網關聚合swagger實現多個服務接口切換的示例代碼
這篇文章主要介紹了spring-gateway網關聚合swagger實現多個服務接口切換的示例代碼,本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-03-03
IDEA Debug啟動tomcat報60659端口占用錯誤的解決
工作中將開發(fā)工具由Eclipse轉為IntelliJ IDEA,在使用過程中遇到許多問題,其中60659端口占用錯誤對于不熟悉IDEA的開發(fā)者來說或許會比較頭痛,本文就來解決一下這個問題2018-11-11

