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。

PXexpireTime 參數則是用於解決沒有解鎖導致的死鎖問題。因為如果沒有過期時間,萬一程序員寫的代碼有 bug 導致沒有解鎖操作,則就出現瞭死鎖,因此該參數起到瞭一個“兜底”的作用。

NX 參數用於保證在多個線程並發 set 下,隻會有1個線程成功,起到瞭鎖的“唯一”性。

2)解鎖

解鎖需要兩步操作:

1)查詢當前“鎖”是否還是我們持有,因為存在過期時間,所以可能等你想解鎖的時候,“鎖”已經到期,然後被其他線程獲取瞭,所以我們在解鎖前需要先判斷自己是否還持有“鎖”

2)如果“鎖”還是我們持有,則執行解鎖操作,也就是刪除該鍵值對,並返回成功;否則,直接返回失敗。

由於當前 Redis 還沒有原子命令直接支持這兩步操作,所以當前通常是使用 Lua 腳本來執行解鎖操作,Redis 會保證腳本裡的內容執行是一個原子操作。

腳本代碼如下,邏輯比較簡單:

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 就會發生變化。

上述方法是 Redis 當前實現分佈式鎖的主流方法,可能會有一些小優區別,但是核心都是這個思路。看著好像沒啥毛病,但是真的是這個樣子嗎?讓我們繼續往下看。

2、Redis 分佈式鎖過期瞭,還沒處理完怎麼辦

為瞭防止死鎖,我們會給分佈式鎖加一個過期時間,但是萬一這個時間到瞭,我們業務邏輯還沒處理完,怎麼辦?

首先,我們在設置過期時間時要結合業務場景去考慮,盡量設置一個比較合理的值,就是理論上正常處理的話,在這個過期時間內是一定能處理完畢的。

之後,我們再來考慮對這個問題進行兜底設計。

關於這個問題,目前常見的解決方法有兩種:

  • 守護線程“續命”:額外起一個線程,定期檢查線程是否還持有鎖,如果有則延長過期時間。Redisson 裡面就實現瞭這個方案,使用“看門狗”定期檢查(每1/3的鎖時間檢查1次),如果線程還持有鎖,則刷新過期時間。
  • 超時回滾:當我們解鎖時發現鎖已經被其他線程獲取瞭,說明此時我們執行的操作已經是“不安全”的瞭,此時需要進行回滾,並返回失敗。

同時,需要進行告警,人為介入驗證數據的正確性,然後找出超時原因,是否需要對超時時間進行優化等等。

3、守護線程續命的方案有什麼問題嗎

Redisson 使用看門狗(守護線程)“續命”的方案在大多數場景下是挺不錯的,也被廣泛應用於生產環境,但是在極端情況下還是會存在問題。

問題例子如下:

  1. 線程1首先獲取鎖成功,將鍵值對寫入 redis 的 master 節點
  2. 在 redis 將該鍵值對同步到 slave 節點之前,master 發生瞭故障
  3. redis 觸發故障轉移,其中一個 slave 升級為新的 master此時新的 master
  4. 並不包含線程1寫入的鍵值對,因此線程2嘗試獲取鎖也可以成功拿到鎖
  5. 此時相當於有兩個線程獲取到瞭鎖,可能會導致各種預期之外的情況發生,例如最常見的臟數據

解決方法:上述問題的根本原因主要是由於 redis 異步復制帶來的數據不一致問題導致的,因此解決的方向就是保證數據的一致。

當前比較主流的解法和思路有兩種:

1)Redis 作者提出的 RedLock;

2)Zookeeper 實現的分佈式鎖。

4、RedLock

首先,該方案也是基於文章開頭的那個方案(set加鎖、lua腳本解鎖)進行改良的,所以 antirez 隻描述瞭差異的地方,大致方案如下。

假設我們有 N 個 Redis 主節點,例如 N = 5,這些節點是完全獨立的,我們不使用復制或任何其他隱式協調系統,為瞭取到鎖,客戶端應該執行以下操作:

  • 獲取當前時間,以毫秒為單位。
  • 依次嘗試從5個實例,使用相同的 key 和隨機值(例如UUID)獲取鎖。當向Redis 請求獲取鎖時,客戶端應該設置一個超時時間,這個超時時間應該小於鎖的失效時間。例如你的鎖自動失效時間為10秒,則超時時間應該在 5-50 毫秒之間。這樣可以防止客戶端在試圖與一個宕機的 Redis 節點對話時長時間處於阻塞狀態。如果一個實例不可用,客戶端應該盡快嘗試去另外一個Redis實例請求獲取鎖。
  • 客戶端通過當前時間減去步驟1記錄的時間來計算獲取鎖使用的時間。當且僅當從大多數(N/2+1,這裡是3個節點)的Redis節點都取到鎖,並且獲取鎖使用的時間小於鎖失效時間時,鎖才算獲取成功。
  • 如果取到瞭鎖,其真正有效時間等於初始有效時間減去獲取鎖所使用的時間(步驟3計算的結果)。
  • 如果由於某些原因未能獲得鎖(無法在至少N/2+1個Redis實例獲取鎖、或獲取鎖的時間超過瞭有效時間),客戶端應該在所有的Redis實例上進行解鎖(即便某些Redis實例根本就沒有加鎖成功,防止某些節點獲取到鎖但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖)。

可以看出,該方案為瞭解決數據不一致的問題,直接舍棄瞭異步復制,隻使用 master 節點,同時由於舍棄瞭 slave,為瞭保證可用性,引入瞭 N 個節點,官方建議是 5。

該方案看著挺美好的,但是實際上我所瞭解到的在實際生產上應用的不多,主要有兩個原因:1)該方案的成本似乎有點高,需要使用5個實例;2)該方案一樣存在問題。

該方案主要存以下問題:

  • 嚴重依賴系統時鐘。如果線程1從3個實例獲取到瞭鎖,但是這3個實例中的某個實例的系統時間走的稍微快一點,則它持有的鎖會提前過期被釋放,當他釋放後,此時又有3個實例是空閑的,則線程2也可以獲取到鎖,則可能出現兩個線程同時持有鎖瞭。
  • 如果線程1從3個實例獲取到瞭鎖,但是萬一其中有1臺重啟瞭,則此時又有3個實例是空閑的,則線程2也可以獲取到鎖,此時又出現兩個線程同時持有鎖瞭。

針對以上問題其實後續也有人給出一些相應的解法,但是整體上來看還是不夠完美,所以目前實際應用得不是那麼多。

5、使用緩存時,先操作數據庫 or 先操作緩存

1)先操作數據庫

案例如下,有兩個並發的請求,一個寫請求,一個讀請求,流程如下:

​可能存在的臟數據時間范圍:更新數據庫後,失效緩存前。這個時間范圍很小,通常不會超過幾毫秒。

2)先操作緩存

案例如下,有兩個並發的請求,一個寫請求,一個讀請求,流程如下:

​可能存在的臟數據時間范圍:更新數據庫後,下一次對該數據的更新前。這個時間范圍不確定性很大,情況如下:

  • 如果下一次對該數據的更新馬上就到來,那麼會失效緩存,臟數據的時間就很短。
  • 如果下一次對該數據的更新要很久才到來,那這期間緩存保存的一直是臟數據,時間范圍很長。

結論:通過上述案例可以看出,先操作數據庫和先操作緩存都會存在臟數據的情況。但是相比之下,先操作數據庫,再操作緩存是更優的方式,即使在並發極端情況下,也隻會出現很小量的臟數據。

6、為什麼是讓緩存失效,而不是更新緩存

1)更新緩存

案例如下,有兩個並發的寫請求,流程如下:

​分析:數據庫中的數據是請求B的,緩存中的數據是請求A的,數據庫和緩存存在數據不一致。

2)失效(刪除)緩存

案例如下,有兩個並發的寫請求,流程如下:

​分析:由於是刪除緩存,所以不存在數據不一致的情況。

結論:通過上述案例,可以很明顯的看出,失效緩存是更優的方式。

7、如何保證數據庫和緩存的數據一致性

在上文的案例中,無論是先操作數據庫,還是先操作緩存,都會存在臟數據的情況,有辦法避免嗎?

答案是有的,由於數據庫和緩存是兩個不同的數據源,要保證其數據一致性,其實就是典型的分佈式事務場景,可以引入分佈式事務來解決,常見的有:2PC、TCC、MQ事務消息等。

但是引入分佈式事務必然會帶來性能上的影響,這與我們當初引入緩存來提升性能的目的是相違背的。

所以在實際使用中,通常不會去保證緩存和數據庫的強一致性,而是做出一定的犧牲,保證兩者數據的最終一致性。

如果是實在無法接受臟數據的場景,則比較合理的方式是放棄使用緩存,直接走數據庫。

保證數據庫和緩存數據最終一致性的常用方案如下:

1)更新數據庫,數據庫產生 binlog。

2)監聽和消費 binlog,執行失效緩存操作。

3)如果步驟2失效緩存失敗,則引入重試機制,將失敗的數據通過MQ方式進行重試,同時考慮是否需要引入冪等機制。

​兜底:當出現未知的問題時,及時告警通知,人為介入處理。

人為介入是終極大法,那些外表看著光鮮艷麗的應用,其背後大多有一群苦逼的程序員,在不斷的修復各種臟數據和bug。

​8、緩存穿透

描述:訪問一個緩存和數據庫都不存在的 key,此時會直接打到數據庫上,並且查不到數據,沒法寫緩存,所以下一次同樣會打到數據庫上。

此時,緩存起不到作用,請求每次都會走到數據庫,流量大時數據庫可能會被打掛。此時緩存就好像被“穿透”瞭一樣,起不到任何作用。

解決方案:

1)接口校驗。在正常業務流程中可能會存在少量訪問不存在 key 的情況,但是一般不會出現大量的情況,所以這種場景最大的可能性是遭受瞭非法攻擊。可以在最外層先做一層校驗:用戶鑒權、數據合法性校驗等,例如商品查詢中,商品的ID是正整數,則可以直接對非正整數直接過濾等等。

2)緩存空值。當訪問緩存和DB都沒有查詢到值時,可以將空值寫進緩存,但是設置較短的過期時間,該時間需要根據產品業務特性來設置。

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)加互斥鎖。在並發的多個請求中,隻有第一個請求線程能拿到鎖並執行數據庫查詢操作,其他的線程拿不到鎖就阻塞等著,等到第一個線程將數據寫入緩存後,直接走緩存。

關於互斥鎖的選擇,網上看到的大部分文章都是選擇 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)熱點數據不過期。直接將緩存設置為不過期,然後由定時任務去異步加載數據,更新緩存。

這種方式適用於比較極端的場景,例如流量特別特別大的場景,使用時需要考慮業務能接受數據不一致的時間,還有就是異常情況的處理,不要到時候緩存刷新不上,一直是臟數據,那就涼瞭。

11、緩存雪崩

描述:大量的熱點 key 設置瞭相同的過期時間,導在緩存在同一時刻全部失效,造成瞬時數據庫請求量大、壓力驟增,引起雪崩,甚至導致數據庫被打掛。

緩存雪崩其實有點像“升級版的緩存擊穿”,緩存擊穿是一個熱點 key,緩存雪崩是一組熱點 key。

解決方案:

1)過期時間打散。既然是大量緩存集中失效,那最容易想到就是讓他們不集中生效。可以給緩存的過期時間時加上一個隨機值時間,使得每個 key 的過期時間分佈開來,不會集中在同一時刻失效。

2)熱點數據不過期。該方式和緩存擊穿一樣,也是要著重考慮刷新的時間間隔和數據異常如何處理的情況。

3)加互斥鎖。該方式和緩存擊穿一樣,按 key 維度加鎖,對於同一個 key,隻允許一個線程去計算,其他線程原地阻塞等待第一個線程的計算結果,然後直接走緩存即可。

總結

本篇文章就到這裡瞭,希望能給你帶來幫助,也希望您能夠多多關註WalkonNet的更多內容!

2021年最新Redis面試題匯總(1)

2021年最新Redis面試題匯總(2)

2021年最新Redis面試題匯總(3)

推薦閱讀: