Redis分散式鎖的五大演進攻略

来源:https://www.cnblogs.com/88223100/archive/2023/01/20/Five-evolution-strategies-of-Redis-distributed-lock.html
-Advertisement-
Play Games

本文我們來探討下如何引入分散式鎖解決本地鎖的問題。本篇所有代碼和業務基於我的開源項目 PassJava。 本篇主要內容如下: 一、本地鎖的問題 首先我們來回顧下本地鎖的問題: 目前題目微服務被拆分成了四個微服務。前端請求進來時,會被轉發到不同的微服務。假如前端接收了 10 W 個請求,每個微服務接收 ...


 

本文我們來探討下如何引入分散式鎖解決本地鎖的問題。本篇所有代碼和業務基於我的開源項目 PassJava。

 

本篇主要內容如下:

 

圖片

 

一、本地鎖的問題

 

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

 

目前題目微服務被拆分成了四個微服務。前端請求進來時,會被轉發到不同的微服務。假如前端接收了 10 W 個請求,每個微服務接收 2.5 W 個請求,假如緩存失效了,每個微服務在訪問資料庫時加鎖,通過鎖(synchronzied 或 lock)來鎖住自己的線程資源,從而防止緩存擊穿

 

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

 

圖片

 

二、什麼是分散式鎖

 

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

 

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

 

圖片

 

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

 

圖片

 

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

 

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> redis-cli

 

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

set wukong 1111 NX

 

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

 

圖片

 

四、青銅方案

 

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

 

1、青銅原理

 

我們來看下流程圖:

 

圖片

 

  • 多個併發線程都去 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();
}

 

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

 

因為該程式存在遞歸調用,可能會導致棧空間溢出。

 

2、青銅方案的缺陷

 

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

 

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

 

圖片

 

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

 

那如何規避這個風險呢?

 

設置鎖的自動過期時間,過一段時間後,自動刪除鎖,這樣其他線程就能獲取到鎖了。

 

五、白銀方案

 

1、生活中的例子

 

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

 

圖片

 

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

 

2、技術原理圖

 

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

 

圖片

 

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、白銀方案的缺陷

 

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

 

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

 

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

 

六、黃金方案

 

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(已過期)。

 

圖片

 

2、技術原理圖

 

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

 

圖片

 

3、示例代碼

 

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

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

 

4、黃金方案的缺陷

 

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

 

1)用戶 A 搶占鎖

 

圖片

 

  • 用戶 A 先搶占到了鎖,並設置了這個鎖 10 秒以後自動開鎖,鎖的編號為 123。

 

  • 10 秒以後,A 還在執行任務,此時鎖被自動打開了。

 

2)用戶 B 搶占鎖

 

圖片

 

  • 用戶 B 看到房間的鎖打開了,於是搶占到了鎖,設置鎖的編號為 123,並設置了過期時間 10 秒。

 

  • 因房間內只允許一個用戶執行任務,所以用戶 A 和 用戶 B 執行任務產生了衝突。

 

  • 用戶 A 在 15 s 後,完成了任務,此時 用戶 B 還在執行任務。

 

  • 用戶 A 主動打開了編號為 123的鎖。

 

  • 用戶 B 還在執行任務,發現鎖已經被打開了。

 

  • 用戶 B 非常生氣:我還沒執行完任務呢,鎖怎麼開了?

 

3)用戶 C 搶占鎖

 

圖片

 

  • 用戶 B 的鎖被 A 主動打開後,A 離開房間,B 還在執行任務。

 

  • 用戶 C 搶占到鎖,C 開始執行任務。

 

  • 因房間內只允許一個用戶執行任務,所以用戶 B 和 用戶 C 執行任務產生了衝突。

 

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

 

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

 

七、鉑金方案

 

1、生活中的例子

 

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

 

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

 

做了個動圖,方便理解:

 

圖片

動圖演示

 

靜態圖更高清,可以看看:

 

圖片

 

2、技術原理圖

 

與黃金方案的不同之處:

 

  • 設置鎖的過期時間時,還需要設置唯一編號。

 

  • 主動刪除鎖的時候,需要判斷鎖的編號是否和設置的一致,如果一致,則認為是自己設置的鎖,可以進行主動刪除。

 

圖片

 

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();
}

 

  • 生成隨機唯一 id,給鎖加上唯一值。

 

  • 搶占鎖,並設置過期時間為 10 s,且鎖具有隨機唯一 id。

 

  • 搶占成功,執行業務。

 

  • 執行完業務後,獲取當前鎖的值。

 

  • 如果鎖的值和設置的值相等,則清理自己的鎖。

 

4、鉑金方案的缺陷

 

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

 

圖片

 

  • 時刻:0s。線程 A 搶占到了鎖。

 

  • 時刻:9.5s。線程 A 向 Redis 查詢當前 key 的值。

 

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

 

  • 時刻:11s。線程 B 搶占到鎖。

 

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

 

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

 

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

 

八、鑽石方案

 

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

 

1、技術原理圖

 

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

 

圖片

 

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 腳本。

 

九、總結

 

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

 

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

 

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

 

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

 

1)青銅方案

 

  • 缺陷:業務代碼出現異常或者伺服器宕機,沒有執行主動刪除鎖的邏輯,就造成了死鎖。

 

  • 改進:設置鎖的自動過期時間,過一段時間後,自動刪除鎖,這樣其他線程就能獲取到鎖了。

 

2)白銀方案

 

  • 缺陷:占鎖和設置鎖過期時間是分步兩步執行的,不是原子操作。

 

  • 改進:占鎖和設置鎖過期時間保證原子操作。

 

3)黃金方案

 

  • 缺陷:主動刪除鎖時,因鎖的值都是相同的,將其他客戶端占用的鎖刪除了。

 

  • 改進:每次占用的鎖,隨機設為較大的值,主動刪除鎖時,比較鎖的值和自己設置的值是否相等。

 

4)鉑金方案

 

  • 缺陷:獲取鎖、比較鎖的值、刪除鎖,這三步是非原子性的。中途又可能鎖自動過期了,又被其他客戶端搶占了鎖,導致刪鎖時把其他客戶端占用的鎖刪了。

 

  • 改進:使用 Lua 腳本進行獲取鎖、比較鎖、刪除鎖的原子操作。

 

5)鑽石方案

 

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

 

  • 改進:Redission 分散式鎖。

 

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

 

Github:https://github.com/Jackson0714/PassJava-Platform

 

碼雲:https://gitee.com/jayh2018/PassJava-Platform

 

配套教程:www.passjava.cn

 

>>>>

參考資料

 

  • http://redis.cn/commands/set.html

  • https://www.bilibili.com/video/BV1np4y1C7Yf

 

作者丨悟空聊架構

本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/Five-evolution-strategies-of-Redis-distributed-lock.html


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 字元格式設置是指用戶對字元的屏幕顯示和列印輸出形式的設定。Word文檔中的字元格式有:字體、字型大小、字體顏色、高亮顏色、邊框、下劃線、斜體、陰影字、著重號等等。帶格式的文本可以突出內容重點,引起讀者註意。應用字元格式可以使簡單的文檔變得比只使用純文本更具吸引力。本文將為您介紹一種高效便捷的方法,通過編 ...
  • 這周收到兩片基於LGT8F328P LQFP32的Arduino Mini EVB, 機器上沒有 Arduino 環境需要新安裝, 正好感受一下新出的 Arduino IDE 2.x, 記錄一下 Ubuntu 20.04/22.04 下安裝 Arduino IDE 2.x 的過程. ...
  • 最近幾天用Python寫了個介面後臺服務,把它部在了伺服器的300埠上。之後,我又要把一個二級功能變數名稱解析到該伺服器的300埠上,此時就要用到NGINX了。因為之前對NGINX的使用並不熟悉,特此記錄下來,方便回顧。用了該方法,就能為伺服器上的多個不同介面服務綁定不同的功能變數名稱了,這也就是把多個功能變數名稱解析 ...
  • 打算整理彙編語言與介面微機這方面的學習記錄。 參考資料 西電《微機原理與系統設計》周佳社 西交《微機原理與介面技術》 課本《彙編語言與介面技術》王讓定 小甲魚《彙編語言》 1. 介紹 2022年春學習了MIPS視角下的電腦體繫結構與組成原理,同年夏自學了操作系統(科普級別的瞭解),彙編語言是學習操 ...
  • 使用子查詢 子查詢 查詢(query) 任何SQL語句都是查詢。但此術語一般指SELECT語句。 SQL還允許創建子查詢(subquery),即嵌套在其他查詢中的查詢。 利用子查詢進行過濾 SELECT cust_id FROM orders WHERE order_num IN (SELECT o ...
  • 比較簡單,只是模擬彩票出數字的過程,不計算單一數字的出現概率。 傳統上來說,每次彩票出號的概率都是獨立事件,單純的在可選數字內隨機實現即可。 本文探索的是實現簡單的預測分析,包含歷史開獎結果的連續事件。 舉例說明:(模擬三個數字,數字區域1-10) 第一次開獎 1,2,3 第二次預測 [4-10] ...
  • 前言 在公司年會期間我做了個抽獎小項目,我把它分享出來,有用得著的可以看下。 瀏覽鏈接:http://xisite.top/original/luck-draw/index.html 項目鏈接:https://gitee.com/xi1213/luck-draw (歡迎star!) 項目截圖: 實現 ...
  • 1、使用 Array.prototype.some() 方法代替 some() 方法會在找到第一個符合條件的元素時停止迴圈。 例如: let array = [1, 2, 3, 4, 5]; array.some(function(element, index, array) { if (eleme ...
一周排行
    -Advertisement-
    Play Games
  • 概述:在C#中,++i和i++都是自增運算符,其中++i先增加值再返回,而i++先返回值再增加。應用場景根據需求選擇,首碼適合先增後用,尾碼適合先用後增。詳細示例提供清晰的代碼演示這兩者的操作時機和實際應用。 在C#中,++i 和 i++ 都是自增運算符,但它們在操作上有細微的差異,主要體現在操作的 ...
  • 上次發佈了:Taurus.MVC 性能壓力測試(ap 壓測 和 linux 下wrk 壓測):.NET Core 版本,今天計劃準備壓測一下 .NET 版本,來測試並記錄一下 Taurus.MVC 框架在 .NET 版本的性能,以便後續持續優化改進。 為了方便對比,本文章的電腦環境和測試思路,儘量和... ...
  • .NET WebAPI作為一種構建RESTful服務的強大工具,為開發者提供了便捷的方式來定義、處理HTTP請求並返迴響應。在設計API介面時,正確地接收和解析客戶端發送的數據至關重要。.NET WebAPI提供了一系列特性,如[FromRoute]、[FromQuery]和[FromBody],用 ...
  • 原因:我之所以想做這個項目,是因為在之前查找關於C#/WPF相關資料時,我發現講解圖像濾鏡的資源非常稀缺。此外,我註意到許多現有的開源庫主要基於CPU進行圖像渲染。這種方式在處理大量圖像時,會導致CPU的渲染負擔過重。因此,我將在下文中介紹如何通過GPU渲染來有效實現圖像的各種濾鏡效果。 生成的效果 ...
  • 引言 上一章我們介紹了在xUnit單元測試中用xUnit.DependencyInject來使用依賴註入,上一章我們的Sample.Repository倉儲層有一個批量註入的介面沒有做單元測試,今天用這個示例來演示一下如何用Bogus創建模擬數據 ,和 EFCore 的種子數據生成 Bogus 的優 ...
  • 一、前言 在自己的項目中,涉及到實時心率曲線的繪製,項目上的曲線繪製,一般很難找到能直接用的第三方庫,而且有些還是定製化的功能,所以還是自己繪製比較方便。很多人一聽到自己畫就害怕,感覺很難,今天就分享一個完整的實時心率數據繪製心率曲線圖的例子;之前的博客也分享給DrawingVisual繪製曲線的方 ...
  • 如果你在自定義的 Main 方法中直接使用 App 類並啟動應用程式,但發現 App.xaml 中定義的資源沒有被正確載入,那麼問題可能在於如何正確配置 App.xaml 與你的 App 類的交互。 確保 App.xaml 文件中的 x:Class 屬性正確指向你的 App 類。這樣,當你創建 Ap ...
  • 一:背景 1. 講故事 上個月有個朋友在微信上找到我,說他們的軟體在客戶那邊隔幾天就要崩潰一次,一直都沒有找到原因,讓我幫忙看下怎麼回事,確實工控類的軟體環境複雜難搞,朋友手上有一個崩潰的dump,剛好丟給我來分析一下。 二:WinDbg分析 1. 程式為什麼會崩潰 windbg 有一個厲害之處在於 ...
  • 前言 .NET生態中有許多依賴註入容器。在大多數情況下,微軟提供的內置容器在易用性和性能方面都非常優秀。外加ASP.NET Core預設使用內置容器,使用很方便。 但是筆者在使用中一直有一個頭疼的問題:服務工廠無法提供請求的服務類型相關的信息。這在一般情況下並沒有影響,但是內置容器支持註冊開放泛型服 ...
  • 一、前言 在項目開發過程中,DataGrid是經常使用到的一個數據展示控制項,而通常表格的最後一列是作為操作列存在,比如會有編輯、刪除等功能按鈕。但WPF的原始DataGrid中,預設只支持固定左側列,這跟大家習慣性操作列放最後不符,今天就來介紹一種簡單的方式實現固定右側列。(這裡的實現方式參考的大佬 ...