Redis 分布式鎖的實現原理


一、前言

分布式鎖相信大家一定不會陌生, 想要用好或者自己寫一個卻沒那么簡單

想要達到上述的條件, 一定要 掌握分布式鎖的應用場景, 以及分布式鎖的不同實現, 不同實現之間有什么區別

二、分布式鎖場景

如果想真正了解分布式鎖, 需要結合一定場景; 舉個例子, 某夕夕上搶購 AirPods Pro 的 100 元優惠券

如果使用下面這段代碼當作搶購優惠券的后台程序, 我們一起看一下, 可能存在什么樣的問題

很明顯的就是這段流程在並發場景下並不安全, 會導致優惠券發放超過預期, 類似電商搶購超賣問題。分布式情況下只能通過分布式鎖 來解決多個服務資源共享的問題了

三、分布式鎖

分布式鎖的定義:

  保證同一時間只能有一個客戶端對共享資源進行操作

另外有幾點要求也是必須要滿足的:

  • 1、不會發生死鎖。 即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證后續其他客戶端能加鎖
  • 2、具有容錯性。 只要大部分的Redis節點正常運行,客戶端就可以加鎖和解鎖
  • 3、解鈴還須系鈴人。 加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了

分布式鎖實現大致分為三種, Redis、Zookeeper、數據庫, 文章以 Redis 展開分布式鎖的討論。

四、分布式鎖演進史

先來構思下分布式鎖實現思路

要求:

  • 首先我們必須保證同一時間只有一個客戶端(部署的優惠券服務)操作數量加減。
  • 其次本次客戶端操作完成后, 需要讓 其它客戶端繼續執行。

實現:

  • 1、客戶端一存放一個標志位, 如果添加成功, 操作減優惠券數量操作
  • 2、客戶端二添加標志位失敗, 本次減庫存操作失敗(或繼續嘗試獲取等)
  • 3、客戶端一優惠券操作完成后, 需要將標志位釋放, 以便其余客戶端對庫存進行操作

4.1 第一版 setnx

向 Redis 中添加一個 lockKey 鎖標志位, 如果添加成功則能夠繼續向下執行扣減優惠券數量操作, 最后再釋放此標志位

由於使用的是 Spring 提供的 Redis 封裝的 Start 包, 所有有些命令與 Redis 原生命令不相符

1 setIfAbsent(key, val) -> setnx(key, val)

加了簡單的幾行代碼, 一個簡單的分布式鎖的雛形就出來了。

4.2 第二版 expire

上面第一版基於 setnx 命令實現分布式鎖的缺陷也是很明顯的, 那就是一定情況下可能發生死鎖

畫個圖, 舉個例子說明哈

上圖說明, 線程1在成功獲取鎖后, 執行流程時異常結束, 沒有執行釋放鎖操作, 這樣就會產生死鎖

  如果方法執行異常導致的線程被回收, 那么可以將解鎖操作放到 finally 塊中。但是還有存在死鎖問題, 如果獲得鎖的線程在執行中, 服務被強制停止或服務器宕機, 鎖依然不會得到釋放。這種極端情況下我們還是要考慮的, 畢竟不能只想着服務沒問題對吧。對 Redis 的鎖標志位加上過期時間就能很好的防止死鎖問題, 繼續更改下程序代碼。

  雖然小紅旗處對分布式鎖添加了過期時間, 但依然無法避免極端情況下的死鎖問題。那就是如果在客戶端加鎖成功后, 還沒有設置過期時間時宕機如果想要避免添加鎖時死鎖, 那就對添加鎖標志位與添加過期時間命令保證一個原子性, 要么一起成功, 要么一起失敗

4.3 第三版 set

我們的添加鎖原子命令就要登場了, 從 Redis 2.6.12 版本起, 提供了可選的 字符串 set 復合命令。

1 SET key value [expiration EX seconds|PX milliseconds] [NX|XX]

可選參數如下:

  • EX: 設置超時時間,單位是秒
  • PX: 設置超時時間,單位是毫秒
  • NX: IF NOT EXIST 的縮寫,只有 KEY不存在的前提下 才會設置值
  • XX: IF EXIST 的縮寫,只有在 KEY存在的前提下 才會設置值

繼續完善分布式鎖的應用程序, 代碼如下:

我使用的 2.0.9.RELEASE 版本的 SpringBoot, RedisTemplate 中不支持 set 復合命令, 所以臨時換個 Jedis 來實現。

加鎖以及設置過期時間確實保證了原子性, 但是這樣的分布式鎖就沒有問題了么?

我們根據圖片以及流程描述設想一下這個場景:

  • 1、線程一獲取鎖成功, 設置過期時間五秒, 接着執行業務邏輯
  • 2、接着線程一獲取鎖后執行業務流程, 執行的時間超過了過期時間, 鎖標志位過期進行釋放, 此時線程二獲取鎖成功
  • 3、然鵝此時線程一執行完業務后, 開始執行釋放鎖的流程, 然后順手就把線程二獲取的鎖釋放了

如果線上真的發生上述問題, 那真的是xxx, 更甚者可能存在線程一將線程二的鎖釋放掉之后, 線程三獲取到鎖, 然后線程二執行完將線程三的鎖釋放

4.4 第四版 verify value

事當如今, 只能創建辨別客戶端身份的唯一值了, 將加鎖及解鎖歸一化, 上代碼~

  這一版的代碼相當於我們添加鎖標志位時, 同時為每個客戶端設置了 uuid 作為鎖標志位的 val, 解鎖時需要判斷鎖的 val 是否和自己客戶端的相同, 辨別成功才會釋放鎖但是上述代碼執行業務邏輯如果拋出異常, 鎖只能等待過期時間, 我們可以將解鎖操作放到 finally 塊。

大眼一看, 上上下下實現了四版分布式鎖, 也該沒問題了吧。

真相就是: 解鎖時, 由於判斷鎖和刪除標志位並不是原子性的, 所以可能還是會存在誤刪

  • 1、線程一獲取鎖后, 執行流程balabala… 判斷鎖也是自家的, 這時 CPU 轉頭去做別的事情了, 恰巧線程一的鎖過期時間到了
  • 2、線程二此時順理成章的獲取到了分布式鎖, 執行業務邏輯balabala…
  • 3、線程一再次分配到時間片繼續執行刪除操作

解決這種非原子操作的方式只能 將判斷元素值和刪除標志位當作一個原子操作。

4.5 第五版 lua

很不友好的是, del 刪除操作並沒有提供原子命令, 所以我們需要想點辦法

Redis在 2.6 推出了腳本功能, 允許開發者使用 Lua 語言編寫腳本傳到 Redis 中執行

4.5.1 使用 Lua 腳本有什么好處呢?

  • 1、減少網絡開銷

    原本我們需要向 Redis 服務請求多次命令, 可以將命令寫在 Lua 腳本中, 這樣執行只會發起一次網絡請求

  • 2、原子操作

    Redis 會將 Lua 腳本中的命令當作一個整體執行, 中間不會插入其它命令

  • 3、復用

    客戶端發送的腳步會存儲 Redis 中, 其他客戶端可以復用這一腳本而不需要使用代碼完成相同的邏輯

那我們編寫一個簡單的 Lua 腳本實現原子刪除操作。

重點就在 Lua 腳本這一塊, 重點說一下這塊的邏輯

script 腳本就是我們在 Redis 中執行的 Lua 腳本, 后面跟的兩個 List 分別是 KEYS、ARGV。

KEYS[1]: lockKey

ARGV[1]: lockValue

代碼不是很多, 也比較簡單, 就是在 Java 中代碼實現的邏輯放到了一個 Lua 腳本中

 1 # 獲取 KEYS[1] 對應的 Val  2 local cliVal = redis.call('get', KEYS[1])  3 # 判斷 KEYS[1] 與 ARGV[1] 是否保持一致  4 if(cliVal == ARGV[1]) then  5   # 刪除 KEYS[1]  6   redis.call('del', KEYS[1])  7   return 'OK' 
 8 else
 9   return nil 10 end

五、Redisson框架

5.1 引言

  現在面試,一般都會聊聊分布式系統這塊的東西。通常面試官都會從服務框架(Spring Cloud、Dubbo)聊起,一路聊到分布式事務、分布式鎖、ZooKeeper等知識。所以咱們這篇文章就來聊聊分布式鎖這塊知識,具體的來看看Redis分布式鎖的實現原理。

  說實話,如果在公司里落地生產環境用分布式鎖的時候,一定是會用開源類庫的,比如Redis分布式鎖,一般就是用Redisson框架就好了,非常的簡便易用。大家如果有興趣,可以去看看Redisson的官網,看看如何在項目中引入Redisson的依賴,然后基於Redis實現分布式鎖的加鎖與釋放鎖。

下面給大家看一段簡單的使用代碼片段,先直觀的感受一下:

怎么樣,上面那段代碼,是不是感覺簡單的不行!

此外,人家還支持redis單實例、redis哨兵、redis cluster、redis master-slave等各種部署架構,都可以給你完美實現。

5.2 Redisson實現Redis分布式鎖的底層原理

  好的,接下來就通過一張手繪圖,給大家說說Redisson這個開源框架對Redis分布式鎖的實現原理。

5.2.1 加鎖機制

  咱們來看上面那張圖,現在某個客戶端要加鎖。如果該客戶端面對的是一個redis cluster集群,他首先會根據hash節點選擇一台機器。 

  這里注意,僅僅只是選擇一台機器!這點很關鍵!

  緊接着,就會發送一段lua腳本到redis上,那段lua腳本如下所示:

5.2.1.1 為啥要用lua腳本呢?

因為一大坨復雜的業務邏輯,可以通過封裝在lua腳本中發送給redis,保證這段復雜業務邏輯執行的原子性

5.2.1.2 lua腳本分析

5.2.1.2.1 參數分析

KEYS[1]代表的是你加鎖的那個key,比如說:RLock lock = redisson.getLock("myLock");

這里你自己設置了加鎖的那個鎖key就是“myLock”。

ARGV[1]代表的就是鎖key的默認生存時間,默認30秒。

ARGV[2]代表的是加鎖的客戶端的ID,類似於下面這樣:

8743c9c0-0795-4907-87fd-6c719a6b4586:1

5.2.1.2.1 代碼執行分析

給大家解釋一下,第一段if判斷語句,就是用“exists myLock”命令判斷一下,如果你要加鎖的那個鎖key不存在的話,你就進行加鎖

如何加鎖呢?很簡單,用下面的命令:

hset myLock 

    8743c9c0-0795-4907-87fd-6c719a6b4586:1 1

通過這個命令設置一個hash數據結構,這行命令執行后,會出現一個類似下面的數據結構:

上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”這個客戶端對“myLock”這個鎖key完成了加鎖。

接着會執行“pexpire myLock 30000”命令,設置myLock這個鎖key的生存時間是30秒。

好了,到此為止,ok,加鎖完成了。

5.2.2 鎖互斥機制

那么在這個時候,如果客戶端2來嘗試加鎖,執行了同樣的一段lua腳本,會咋樣呢?

很簡單,第一個if判斷會執行“exists myLock”,發現myLock這個鎖key已經存在了

接着第二個if判斷,判斷一下,myLock鎖key的hash數據結構中,是否包含客戶端2的ID,但是明顯不是的,因為那里包含的是客戶端1的ID

所以,客戶端2會獲取到pttl myLock返回的一個數字,這個數字代表了myLock這個鎖key的剩余生存時間比如還剩15000毫秒的生存時間。

此時客戶端2會進入一個while循環,不停的嘗試加鎖(類似於自旋鎖)

5.2.3 watch dog自動延期機制

客戶端1加鎖的鎖key默認生存時間才30秒,如果超過了30秒,客戶端1還想一直持有這把鎖,怎么辦呢?

簡單!只要客戶端1一旦加鎖成功,就會啟動一個watch dog看門狗,他是一個后台線程,會每隔10秒檢查一下,如果客戶端1還持有鎖key,那么就會不斷的延長鎖key的生存時間

5.2.4 可重入加鎖機制

那如果客戶端1都已經持有了這把鎖了,結果可重入的加鎖會怎么樣呢?

比如下面這種代碼:

這時我們來分析一下上面那段lua腳本。

第一個if判斷肯定不成立,“exists myLock”會顯示鎖key已經存在了

第二個if判斷會成立,因為myLock的hash數據結構中包含的那個ID,就是客戶端1的那個ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”

此時就會執行可重入加鎖的邏輯,他會用:

incrby myLock 

 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1

通過這個命令,對客戶端1的加鎖次數,累加1

此時myLock數據結構變為下面這樣:

大家看到了吧,那個myLock的hash數據結構中的那個客戶端ID,就對應着加鎖的次數

5.2.5 可重入加鎖機制

如果執行lock.unlock(),就可以釋放分布式鎖,此時的業務邏輯也是非常簡單的。

其實說白了,就是每次都對myLock數據結構中的那個加鎖次數減1

如果發現加鎖次數是0了,說明這個客戶端已經不再持有鎖了,此時就會用:

“del myLock”命令,從redis里刪除這個key

然后呢,另外的客戶端2就可以嘗試完成加鎖了。

這就是所謂的分布式鎖的開源Redisson框架的實現機制。

一般我們在生產系統中,可以用Redisson框架提供的這個類庫來基於redis進行分布式鎖的加鎖與釋放鎖。

5.2.6 上述Redis分布式鎖的缺點

其實上面那種方案最大的問題,就是如果你對某個redis master實例,寫入了myLock這種鎖key的value,此時會異步復制給對應的master slave實例

  但是這個過程中一旦發生redis master宕機,主備切換,redis slave變為了redis master。接着就會導致,客戶端2來嘗試加鎖的時候,在新的redis master上完成了加鎖,而客戶端1也以為自己成功加了鎖。因為此時存儲分布式鎖的哈希結構中的並沒有對鎖的相關信息進行更正。此時就會導致多個客戶端對一個分布式鎖完成了加鎖。

  這時系統在業務語義上一定會出現問題,導致各種臟數據的產生所以這個就是redis cluster,或者是redis master-slave架構的主從異步復制導致的redis分布式鎖的最大缺陷:在redis master實例宕機的時候,可能導致多個客戶端同時完成加鎖

六、每秒上千訂單場景下的分布式鎖高並發優化

6.1 背景

面試官給他出了一個場景題:

  假如下單時,用分布式鎖來防止庫存超賣,但是是每秒上千訂單的高並發場景,如何對分布式鎖進行高並發優化來應對這個場景

  既然面試官限定死了用分布式鎖來解決庫存超賣,我估計就是想問一個點:在高並發場景下如何優化分布式鎖的並發性能因為在實際落地生產的時候,分布式鎖這個東西保證了數據的准確性,但是它天然並發能力有點弱

6.2 電商高並發秒殺場景下的庫存超賣解決方案

  因為庫存超賣問題是有很多種技術解決方案的,比如悲觀鎖,分布式鎖,樂觀鎖,隊列串行化,Redis原子操作,等等吧。

6.3 庫存超賣現象是怎么產生的?

先來看看如果不用分布式鎖,所謂的電商庫存超賣是啥意思?大家看看下面的圖:

  這個圖,其實很清晰了,假設訂單系統部署兩台機器上,不同的用戶都要同時買10台iphone,分別發了一個請求給訂單系統。接着每個訂單系統實例都去數據庫里查了一下,當前iphone庫存是12台。倆大兄弟一看,樂了,12台庫存大於了要買的10台數量啊!於是乎,每個訂單系統實例都發送SQL到數據庫里下單,然后扣減了10個庫存,其中一個將庫存從12台扣減為2台,另外一個將庫存從2台扣減為-8台。現在完了,庫存出現了負數!淚奔啊,沒有20台iphone發給兩個用戶啊!這可如何是好。

6.4 用分布式鎖如何解決庫存超賣問題?

6.4.1 實現

我們用分布式鎖如何解決庫存超賣問題呢?其實很簡單,回憶一下上次我們說的那個分布式鎖的實現原理:

  同一個鎖key,同一時間只能有一個客戶端拿到鎖,其他客戶端會陷入無限的等待來嘗試獲取那個鎖,只有獲取到鎖的客戶端才能執行下面的業務邏輯

6.4.2 分析

代碼大概就是上面那個樣子,現在我們來分析一下,為啥這樣做可以避免庫存超賣?

  大家可以順着上面的那個步驟序號看一遍,馬上就明白了。從上圖可以看到,只有一個訂單系統實例可以成功加分布式鎖,然后只有他一個實例可以查庫存、判斷庫存是否充足、下單扣減庫存,接着釋放鎖。

釋放鎖之后,另外一個訂單系統實例才能加鎖,接着查庫存,一下發現庫存只有2台了,庫存不足,無法購買,下單失敗。不會將庫存扣減為-8的。

6.5 可以解決庫存超賣問題的其他方案

  比如悲觀鎖,分布式鎖,樂觀鎖,隊列串行化,異步隊列分散,Redis原子操作,等等,很多方案。

6.6 分布式鎖的方案在高並發場景下的缺陷

  分布式鎖一旦加了之后,對同一個商品的下單請求,會導致所有客戶端都必須對同一個商品的庫存鎖key進行加鎖比如,對iphone這個商品的下單,都必對“iphone_stock”這個鎖key來加鎖。這樣會導致對同一個商品的下單請求,就必須串行化,一個接一個的處理

  假設加鎖之后,釋放鎖之前,查庫存 -> 創建訂單 -> 扣減庫存,這個過程性能很高吧,算他全過程20毫秒,這應該不錯了。

  那么1秒是1000毫秒,只能容納50個對這個商品的請求依次串行完成處理。

  比如一秒鍾來50個請求,都是對iphone下單的,那么每個請求處理20毫秒,一個一個來,最后1000毫秒正好處理完50個請求。

流程如下圖:

  所以看到這里,大家起碼也明白了,簡單的使用分布式鎖來處理庫存超賣問題,存在什么缺陷。

  缺陷就是同一個商品多用戶同時下單的時候,會基於分布式鎖串行化處理,導致沒法同時處理同一個商品的大量下單的請求

  這種方案,要是應對那種低並發、無秒殺場景的普通小電商系統,可能還可以接受。因為如果並發量很低,每秒就不到10個請求,沒有瞬時高並發秒殺單個商品的場景的話,其實也很少會對同一個商品在一秒內瞬間下1000個訂單,因為小電商系統沒那場景。

6.7 分布式鎖的方案在高並發場景下的性能優化

6.7.1 優化依據

  面試官說,我現在就卡死,庫存超賣就是用分布式鎖來解決,而且一秒對一個iphone下上千訂單,怎么優化?

  現在按照剛才的計算,你一秒鍾只能處理針對iphone的50個訂單。

  其實說出來也很簡單,相信很多人看過java里的ConcurrentHashMap的源碼和底層原理,應該知道里面的核心思路,就是分段加鎖

  把數據分成很多個段,每個段是一個單獨的鎖,所以多個線程過來並發修改數據的時候,可以並發的修改不同段的數據。不至於說,同一時間只能有一個線程獨占修改ConcurrentHashMap中的數據。

  另外,Java 8中新增了一個LongAdder類,也是針對Java 7以前的AtomicLong進行的優化,解決的是CAS類操作在高並發場景下,使用樂觀鎖思路,會導致大量線程長時間重復循環。LongAdder中也是采用了類似的分段CAS操作,失敗則自動遷移到下一個分段進行CAS的思路。

  所以分布式鎖的優化思路也是可以類似處理。

6.7.2 優化原理圖

6.7.3 原理分析

  其實這就是分段加鎖。你想,假如你現在iphone有1000個庫存,那么你完全可以給拆成20個庫存段,要是你願意,可以在數據庫的表里建20個庫存字段,比如stock_01,stock_02,類似這樣的,也可以在redis之類的地方放20個庫存key。

  總之,就是把你的1000件庫存給他拆開,每個庫存段是50件庫存,比如stock_01對應50件庫存,stock_02對應50件庫存。

  接着,每秒1000個請求過來了,好!此時其實可以是自己寫一個簡單的隨機算法,每個請求都是隨機在20個分段庫存里,選擇一個進行加鎖。

  bingo!這樣就好了,同時可以有最多20個下單請求一起執行,每個下單請求鎖了一個庫存分段,然后在業務邏輯里面,就對數據庫或者是Redis中的那個分段庫存進行操作即可,包括查庫存 -> 判斷庫存是否充足 -> 扣減庫存。

  這相當於什么呢?相當於一個20毫秒,可以並發處理掉20個下單請求,那么1秒,也就可以依次處理掉20 * 50 = 1000個對iphone的下單請求了。

  一旦對某個數據做了分段處理之后,有一個坑大家一定要注意:就是如果某個下單請求,咔嚓加鎖,然后發現這個分段庫存里的庫存不足了,此時咋辦?

  這時你得自動釋放鎖,然后立馬換下一個分段庫存,再次嘗試加鎖后嘗試處理這個過程一定要實現

6.7.3 存在的問題

不足肯定是有的,最大的不足,大家發現沒有,很不方便啊!實現太復雜了

  • 首先,你得對一個數據分段存儲,一個庫存字段本來好好的,現在要分為20個分段庫存字段;
  • 其次,你在每次處理庫存的時候,還得自己寫隨機算法,隨機挑選一個分段來處理;
  • 最后,如果某個分段中的數據不足了,你還得自動切換到下一個分段數據去處理。

這個過程都是要手動寫代碼實現的,還是有點工作量,挺麻煩的。

七、Redis並發競爭的解決方案

7.1 分布式鎖

7.1.1 整體技術方案

這種情況,主要是准備一個分布式鎖,大家去搶鎖,搶到鎖就做set操作。

加鎖的目的實際上就是把並行讀寫改成串行讀寫的方式,從而來避免資源競爭。

7.1.2 Redis分布式鎖的實現

  主要用到的redis函數是setnx(),用SETNX實現分布式鎖。

  利用SETNX非常簡單地實現分布式鎖。例如:某客戶端要獲得一個名字youzhi的鎖,客戶端使用下面的命令進行獲取:

    • SETNX lock.youzhi<current Unix time + lock timeout + 1>

  如返回1,則該客戶端獲得鎖,把lock.youzhi的鍵值設置為時間值表示該鍵已被鎖定,該客戶端最后可以通過DEL lock.foo來釋放該鎖。
  如返回0,表明該鎖已被其他客戶端取得,這時我們可以先返回或進行重試等對方完成或等待鎖超時。

7.1.3 順序執行

7.1.3.1 時間戳實現

由於上面舉的例子,要求key的操作需要順序執行,所以需要保存一個時間戳判斷set順序。

系統A key 1 {ValueA 7:00}

系統B key 1 { ValueB 7:05}

假設系統B先搶到鎖,將key1設置為{ValueB 7:05}。接下來系統A搶到鎖,發現自己的key1的時間戳早於緩存中的時間戳(7:00<7:05),那就不做set操作了。

7.1.3.2 原子操作

  LUA腳本 + set(ex、px、nx、xx)原子指令;Redis 會將 Lua 腳本中的命令當作一個整體執行, 中間不會插入其它命令。

7.1.4 什么是分布式鎖

  因為傳統的加鎖的做法(如java的synchronized和Lock)這里沒用,只適合單點。因為這是分布式環境,需要的是分布式鎖。當然,分布式鎖可以基於很多種方式實現,比如zookeeper、redis等,不管哪種方式實現,基本原理是不變的:用一個狀態值表示鎖,對鎖的占用和釋放通過狀態值來標識。

7.2 消息隊列

  在並發量過大的情況下,可以通過消息中間件進行處理,把並行讀寫進行串行化把Redis.set操作放在隊列中使其串行化,必須的一個一個執行。這種方式在一些高並發的場景中算是一種通用的解決方案。

八、參考文章

https://mp.weixin.qq.com/s?__biz=MzU0OTk3ODQ3Ng==&mid=2247483893&idx=1&sn=32e7051116ab60e41f72e6c6e29876d9&chksm=fba6e9f6ccd160e0c9fa2ce4ea1051891482a95b1483a63d89d71b15b33afcdc1f2bec17c03c&mpshare=1&scene=23&srcid=1121Vlt0Mey0OD5eYWt8HPyB#rd

https://www.cnblogs.com/williamjie/p/11250679.html

https://blog.csdn.net/C18298182575/article/details/100879960

https://blog.csdn.net/C18298182575/article/details/92786579


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM