詳解 Redis 分散式鎖的 5 種方案

資料庫工作筆記發表於2023-04-03

來源:悟空說架構


本地加鎖的方式在分散式的場景下不適用,所以本文我們來探討下如何引入分散式鎖解決本地鎖的問題。本篇所有程式碼和業務基於我的開源專案 PassJava。

本篇主要內容如下:

詳解 Redis 分散式鎖的 5 種方案

一、本地鎖的問題

首先我們來回顧下本地鎖的問題:

目前題目微服務被拆分成了四個微服務。前端請求進來時,會被轉發到不同的微服務。假如前端接收了 10 W 個請求,每個微服務接收 2.5 W 個請求,假如快取失效了,每個微服務在訪問資料庫時加鎖,透過鎖(synchronziedlock)來鎖住自己的執行緒資源,從而防止快取擊穿

這是一種本地加鎖的方式,在分散式情況下會帶來資料不一致的問題:比如服務 A 獲取資料後,更新快取 key =100,服務 B 不受服務 A 的鎖限制,併發去更新快取 key = 99,最後的結果可能是 99 或 100,但這是一種未知的狀態,與期望結果不一致。流程圖如下所示:

詳解 Redis 分散式鎖的 5 種方案

二、什麼是分散式鎖

基於上面本地鎖的問題,我們需要一種支援分散式叢集環境下的鎖:查詢 DB 時,只有一個執行緒能訪問,其他執行緒都需要等待第一個執行緒釋放鎖資源後,才能繼續執行。

生活中的案例:可以把鎖看成房門外的一把,所有併發執行緒比作,他們都想進入房間,房間內只能有一個人進入。當有人進入後,將門反鎖,其他人必須等待,直到進去的人出來。

詳解 Redis 分散式鎖的 5 種方案

我們來看下分散式鎖的基本原理,如下圖所示:

詳解 Redis 分散式鎖的 5 種方案

我們來分析下上圖的分散式鎖:

  • 1.前端將 10W 的高併發請求轉發給四個題目微服務。
  • 2.每個微服務處理 2.5 W 個請求。
  • 3.每個處理請求的執行緒在執行業務之前,需要先搶佔鎖。可以理解為“佔坑”。
  • 4.獲取到鎖的執行緒在執行完業務後,釋放鎖。可以理解為“釋放坑位”。
  • 5.未獲取到的執行緒需要等待鎖釋放。
  • 6.釋放鎖後,其他執行緒搶佔鎖。
  • 7.重複執行步驟 4、5、6。

大白話解釋:所有請求的執行緒都去同一個地方“佔坑”,如果有坑位,就執行業務邏輯,沒有坑位,就需要其他執行緒釋放“坑位”。這個坑位是所有執行緒可見的,可以把這個坑位放到 Redis 快取或者資料庫,這篇講的就是如何用 Redis 做“分散式坑位”

三、Redis 的 SETNX

Redis 作為一個公共可訪問的地方,正好可以作為“佔坑”的地方。

用 Redis 實現分散式鎖的幾種方案,我們都是用 SETNX 命令(設定 key 等於某 value)。只是高階方案傳的引數個數不一樣,以及考慮了異常情況。

我們來看下這個命令,SETNXset If not exist的簡寫。意思就是當 key 不存在時,設定 key 的值,存在時,什麼都不做。

在 Redis 命令列中是這樣執行的:

set <key> <value> NX

我們可以進到 redis 容器中來試下 SETNX 命令。

先進入容器:

docker exec -it <容器 id> redid-cli

然後執行 SETNX 命令:將 wukong 這個 key 對應的 value 設定成 1111

set wukong 1111 NX

返回 OK,表示設定成功。重複執行該命令,返回 nil表示設定失敗。

詳解 Redis 分散式鎖的 5 種方案

四、青銅方案

我們先用 Redis 的 SETNX 命令來實現最簡單的分散式鎖。

3.1 青銅原理

我們來看下流程圖:

詳解 Redis 分散式鎖的 5 種方案
  • 多個併發執行緒都去 Redis 中申請鎖,也就是執行 setnx 命令,假設執行緒 A 執行成功,說明當前執行緒 A 獲得了。
  • 其他執行緒執行 setnx 命令都會是失敗的,所以需要等待執行緒 A 釋放鎖。
  • 執行緒 A 執行完自己的業務後,刪除鎖。
  • 其他執行緒繼續搶佔鎖,也就是執行 setnx 命令。因為執行緒 A 已經刪除了鎖,所以又有其他執行緒可以搶佔到鎖了。

程式碼示例如下,Java 中 setnx 命令對應的程式碼為 setIfAbsent

setIfAbsent 方法的第一個引數代表 key,第二個引數代表值。

// 1.先搶佔鎖
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock""123");
if(lock) {
  // 2.搶佔成功,執行業務
  List<TypeEntity> typeEntityListFromDb = getDataFromDB();
  // 3.解鎖
  redisTemplate.delete("lock");
  return typeEntityListFromDb;
else {
  // 4.休眠一段時間
  sleep(100);
  // 5.搶佔失敗,等待鎖釋放
  return getTypeEntityListByRedisDistributedLock();
}

一個小問題:那為什麼需要休眠一段時間?

因為該程式存在遞迴呼叫,可能會導致棧空間溢位。

3.2 青銅方案的缺陷

青銅之所以叫青銅,是因為它是最初級的,肯定會帶來很多問題。

設想一種家庭場景:晚上小空一個人開鎖進入了房間,開啟了電燈?,然後突然斷電了,小空想開門出去,但是找不到門鎖位置,那小明就進不去了,外面的人也進不來。

詳解 Redis 分散式鎖的 5 種方案

從技術的角度看:setnx 佔鎖成功,業務程式碼出現異常或者伺服器當機,沒有執行刪除鎖的邏輯,就造成了死鎖

那如何規避這個風險呢?

設定鎖的自動過期時間,過一段時間後,自動刪除鎖,這樣其他執行緒就能獲取到鎖了。

四、白銀方案

4.1 生活中的例子

上面提到的青銅方案會有死鎖問題,那我們就用上面的規避風險的方案來設計下,也就是我們的白銀方案。

詳解 Redis 分散式鎖的 5 種方案

還是生活中的例子:小空開鎖成功後,給這款智慧鎖設定了一個沙漏倒數計時⏳,沙漏完後,門鎖自動開啟。即使房間突然斷電,過一段時間後,鎖會自動開啟,其他人就可以進來了。

4.2 技術原理圖

和青銅方案不同的地方在於,在佔鎖成功後,設定鎖的過期時間,這兩步是分步執行的。如下圖所示:

詳解 Redis 分散式鎖的 5 種方案

4.3 示例程式碼

清理 redis key 的程式碼如下

// 在 10s 以後,自動清理 lock
redisTemplate.expire("lock"10, TimeUnit.SECONDS);

完整程式碼如下:

// 1.先搶佔鎖
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock""123");
if(lock) {
    // 2.在 10s 以後,自動清理 lock
    redisTemplate.expire("lock"10, TimeUnit.SECONDS);
    // 3.搶佔成功,執行業務
    List<TypeEntity> typeEntityListFromDb = getDataFromDB();
    // 4.解鎖
    redisTemplate.delete("lock");
    return typeEntityListFromDb;
}

4.4 白銀方案的缺陷

白銀方案看似解決了執行緒異常或伺服器當機造成的鎖未釋放的問題,但還是存在其他問題:

因為佔鎖和設定過期時間是分兩步執行的,所以如果在這兩步之間發生了異常,則鎖的過期時間根本就沒有設定成功。

所以和青銅方案有一樣的問題:鎖永遠不能過期

五、黃金方案

5.1 原子指令

上面的白銀方案中,佔鎖和設定鎖過期時間是分步兩步執行的,這個時候,我們可以聯想到什麼:事務的原子性(Atom)。

原子性:多條命令要麼都成功執行,要麼都不執行。

將兩步放在一步中執行:佔鎖+設定鎖過期時間。

Redis 正好支援這種操作:

# 設定某個 key 的值並設定多少毫秒或秒 過期。
set <key> <value> PX <多少毫秒> NX

set <key> <value> EX <多少秒> NX

然後可以透過如下命令檢視 key 的變化

ttl <key>

下面演示下如何設定 key 並設定過期時間。注意:執行命令之前需要先刪除 key,可以透過客戶端或命令刪除。

# 設定 key=wukong,value=1111,過期時間=5000ms
set wukong 1111 PX 5000 NX
# 檢視 key 的狀態
ttl wukong

執行結果如下圖所示:每執行一次 ttl 命令,就可以看到 wukong 的過期時間就會減少。最後會變為 -2(已過期)。

詳解 Redis 分散式鎖的 5 種方案

5.2 技術原理圖

黃金方案和白銀方案的不同之處:獲取鎖的時候,也需要設定鎖的過期時間,這是一個原子操作,要麼都成功執行,要麼都不執行。如下圖所示:

詳解 Redis 分散式鎖的 5 種方案

5.3 示例程式碼

設定 lock 的值等於 123,過期時間為 10 秒。如果 10 秒 以後,lock 還存在,則清理 lock。

setIfAbsent("lock""123"10, TimeUnit.SECONDS);

5.4 黃金方案的缺陷

我們還是舉生活中的例子來看下黃金方案的缺陷。

5.4.1 使用者 A 搶佔鎖

詳解 Redis 分散式鎖的 5 種方案
  • 使用者 A 先搶佔到了鎖,並設定了這個鎖 10 秒以後自動開鎖,鎖的編號為 123
  • 10 秒以後,A 還在執行任務,此時鎖被自動開啟了。

5.4.2 使用者 B 搶佔鎖

詳解 Redis 分散式鎖的 5 種方案
  • 使用者 B 看到房間的鎖開啟了,於是搶佔到了鎖,設定鎖的編號為 123,並設定了過期時間 10 秒
  • 因房間內只允許一個使用者執行任務,所以使用者 A 和 使用者 B 執行任務產生了衝突
  • 使用者 A 在 15 s 後,完成了任務,此時 使用者 B 還在執行任務。
  • 使用者 A 主動開啟了編號為 123的鎖。
  • 使用者 B 還在執行任務,發現鎖已經被開啟了。
  • 使用者 B 非常生氣:我還沒執行完任務呢,鎖怎麼開了?

5.4.3 使用者 C 搶佔鎖

詳解 Redis 分散式鎖的 5 種方案
  • 使用者 B 的鎖被 A 主動開啟後,A 離開房間,B 還在執行任務。
  • 使用者 C 搶佔到鎖,C 開始執行任務。
  • 因房間內只允許一個使用者執行任務,所以使用者 B 和 使用者 C 執行任務產生了衝突。

從上面的案例中我們可以知道,因為使用者 A 處理任務所需要的時間大於鎖自動清理(開鎖)的時間,所以在自動開鎖後,又有其他使用者搶佔到了鎖。當使用者 A 完成任務後,會把其他使用者搶佔到的鎖給主動開啟。

這裡為什麼會開啟別人的鎖?因為鎖的編號都叫做 “123”,使用者 A 只認鎖編號,看見編號為 “123”的鎖就開,結果把使用者 B 的鎖開啟了,此時使用者 B 還未執行完任務,當然生氣了。

六、鉑金方案

6.1 生活中的例子

上面的黃金方案的缺陷也很好解決,給每個鎖設定不同的編號不就好了~

如下圖所示,B 搶佔的鎖是藍色的,和 A 搶佔到綠色鎖不一樣。這樣就不會被 A 開啟了。

做了個動圖,方便理解:

詳解 Redis 分散式鎖的 5 種方案

靜態圖更高畫質,可以看看:

詳解 Redis 分散式鎖的 5 種方案

6.2 技術原理圖

與黃金方案的不同之處:

  • 設定鎖的過期時間時,還需要設定唯一編號。
  • 主動刪除鎖的時候,需要判斷鎖的編號是否和設定的一致,如果一致,則認為是自己設定的鎖,可以進行主動刪除。
詳解 Redis 分散式鎖的 5 種方案

6.3 程式碼示例

// 1.生成唯一 id
String uuid = UUID.randomUUID().toString();
// 2. 搶佔鎖
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS);
if(lock) {
    System.out.println("搶佔成功:" + uuid);
    // 3.搶佔成功,執行業務
    List<TypeEntity> typeEntityListFromDb = getDataFromDB();
    // 4.獲取當前鎖的值
    String lockValue = redisTemplate.opsForValue().get("lock");
    // 5.如果鎖的值和設定的值相等,則清理自己的鎖
    if(uuid.equals(lockValue)) {
        System.out.println("清理鎖:" + lockValue);
        redisTemplate.delete("lock");
    }
    return typeEntityListFromDb;
else {
    System.out.println("搶佔失敗,等待鎖釋放");
    // 4.休眠一段時間
    sleep(100);
    // 5.搶佔失敗,等待鎖釋放
    return getTypeEntityListByRedisDistributedLock();
}
  • 1.生成隨機唯一 id,給鎖加上唯一值。
  • 2.搶佔鎖,並設定過期時間為 10 s,且鎖具有隨機唯一 id。
  • 3.搶佔成功,執行業務。
  • 4.執行完業務後,獲取當前鎖的值。
  • 5.如果鎖的值和設定的值相等,則清理自己的鎖。

6.4 鉑金方案的缺陷

上面的方案看似很完美,但還是存在問題:第 4 步和第 5 步並不是原子性的。

詳解 Redis 分散式鎖的 5 種方案
  • 時刻:0s。執行緒 A 搶佔到了鎖。

  • 時刻:9.5s。執行緒 A 向 Redis 查詢當前 key 的值。

  • 時刻:10s。鎖自動過期。

  • 時刻:11s。執行緒 B 搶佔到鎖。

  • 時刻:12s。執行緒 A 在查詢途中耗時長,終於拿多鎖的值。

  • 時刻:13s。執行緒 A 還是拿自己設定的鎖的值和返回的值進行比較,值是相等的,清理鎖,但是這個鎖其實是執行緒 B 搶佔的鎖。

那如何規避這個風險呢?鑽石方案登場。

七、鑽石方案

上面的執行緒 A 查詢鎖和刪除鎖的邏輯不是原子性的,所以將查詢鎖和刪除鎖這兩步作為原子指令操作就可以了。

7.1 技術原理圖

如下圖所示,紅色圈出來的部分是鑽石方案的不同之處。用指令碼進行刪除,達到原子操作。

詳解 Redis 分散式鎖的 5 種方案

7.2 程式碼示例

那如何用指令碼進行刪除呢?

我們先來看一下這段 Redis 專屬指令碼:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

這段指令碼和鉑金方案的獲取key,刪除key的方式很像。先獲取 KEYS[1] 的 value,判斷 KEYS[1] 的 value 是否和 ARGV[1] 的值相等,如果相等,則刪除 KEYS[1]。

那麼這段指令碼怎麼在 Java 專案中執行呢?

分兩步:先定義指令碼;用 redisTemplate.execute 方法執行指令碼。

// 指令碼解鎖
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);

上面的程式碼中,KEYS[1] 對應“lock”,ARGV[1] 對應 “uuid”,含義就是如果 lock 的 value 等於 uuid 則刪除 lock。

而這段 Redis 指令碼是由 Redis 內嵌的 Lua 環境執行的,所以又稱作 Lua 指令碼。

那鑽石方案是不是就完美了呢?有沒有更好的方案呢?

下篇,我們再來介紹另外一種分散式鎖的王者方案:Redisson。

八、總結

本篇透過本地鎖的問題引申出分散式鎖的問題。然後介紹了五種分散式鎖的方案,由淺入深講解了不同方案的改進之處。

從上面幾種方案的不斷演進的過程中,知道了系統中哪些地方可能存在異常情況,以及該如何更好地進行處理。

舉一反三,這種不斷演進的思維模式也可以運用到其他技術中。

下面總結下上面五種方案的缺陷和改進之處。

青銅方案

  • 缺陷:業務程式碼出現異常或者伺服器當機,沒有執行主動刪除鎖的邏輯,就造成了死鎖。
  • 改進:設定鎖的自動過期時間,過一段時間後,自動刪除鎖,這樣其他執行緒就能獲取到鎖了。

白銀方案

  • 缺陷:佔鎖和設定鎖過期時間是分步兩步執行的,不是原子操作。
  • 改進:佔鎖和設定鎖過期時間保證原子操作。

黃金方案

  • 缺陷:主動刪除鎖時,因鎖的值都是相同的,將其他客戶端佔用的鎖刪除了。
  • 改進:每次佔用的鎖,隨機設為較大的值,主動刪除鎖時,比較鎖的值和自己設定的值是否相等。

鉑金方案

  • 缺陷:獲取鎖、比較鎖的值、刪除鎖,這三步是非原子性的。中途又可能鎖自動過期了,又被其他客戶端搶佔了鎖,導致刪鎖時把其他客戶端佔用的鎖刪了。
  • 改進:使用 Lua 指令碼進行獲取鎖、比較鎖、刪除鎖的原子操作。

鑽石方案

  • 缺陷:非專業的分散式鎖方案。
  • 改進:Redission 分散式鎖。

王者方案,下篇見~

上述所有程式碼都基於 PassJava 開源專案,後端、前端、小程式都上傳到同一個倉庫裡面了,大家可以透過 github 或 碼雲訪問。地址如下:

Github:

碼雲

配套教程

參考資料:

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70027826/viewspace-2943384/,如需轉載,請註明出處,否則將追究法律責任。

相關文章