分布式鎖


分布式鎖

1 什么是分布式鎖?

在討論分布式鎖之前,我們先假設一個業務場景:

1.1 業務場景

在電商系統中,用戶購買商品需要扣減庫存,一般扣庫存有兩種方式:

  • 下單減庫存

    優點:用戶體驗好,下單成功,庫存直接扣減,用戶支付不會出現庫存不足。

    缺點:用戶一直不付款,這個商品的庫存就會被占用,其他人無法購買。

  • 支付減庫存

    優點:不會導致庫存被惡意鎖定,對商家有利。

    缺點:用戶體驗不好,用戶支付時可能商品庫存不足了,會導致交易失敗。

那么,我們一般為了用戶體驗,會采用下單減庫存,為了解決下單減庫存的缺陷,會創建一個定時任務,定時去清理超時未支付的訂單。

這個定時任務主要包含以下步驟:

  1. 查詢超時未支付的訂單,獲取訂單中的商品信息。
  2. 修改未支付訂單的狀態,改為取消。
  3. 恢復訂單中商品扣減的庫存。

如果我們給訂單服務搭建一個 100 個節點的超時訂單檢查服務集群,那么就會同時有 100 個定時任務觸發並執行,設想一下這樣的場景:

  • 訂單服務 A 和 B 同時執行了步驟 1。
  • 它們返回了同樣的商品和訂單信息。
  • 訂單服務 A 執行了步驟 2 和 3。
  • 訂單服務 B 執行了步驟 2 和 3。 商品庫存再次被增加。

因為任務的並發執行,出現了線程安全問題,商品庫存被增加多次。

為什么需要分布式鎖

對於線程安全問題,傳統的方法是給對線程操作的資源代碼加鎖。

理想狀態下,加了鎖以后,在當前訂單服務執行時,其他訂單需要等待當前服務完成業務后才能執行,這樣就避免了線程安全的問題。實際上這樣並不能解決問題。

1.2.1 線程鎖

我們通常使用的 synchronized 和 Lock 都是線程鎖,對同一個 JVM 進程內的多個線程有效。因為鎖的本質是在內存中存放一個標記,記錄獲取鎖的線程是誰,這個標記對每個線程都可見。

因此,鎖生效的前提是:

互斥:鎖的標記只有一個線程可以獲取。

共享:標記對所有線程可見。

然而我們啟動了多個訂單服務,就是多個 JVM,內存中的鎖顯然是不共享的。為了解決這個問題,能夠保證各個訂單服務能夠共享內存的鎖,分布式鎖就派上用場了。

1.2.2 分布式鎖

分布式鎖將鎖的標記變為進程可見,保證這個任務同一時刻只能被多個進程中的某一個執行,那么這就是一個分布式鎖。

分布式鎖有多種實現方式,基本原理類似,只要滿足如下要求即可:

  • 多進程可見
  • 互斥:同一時刻只能有一個進程獲得鎖,執行任務后釋放鎖。
  • 可重入(可選):同一個任務再次獲取鎖時不會死鎖。
  • 阻塞鎖(可選):獲取失敗時,具備重試機制,嘗試再次獲取鎖。
  • 高並發,高可用(可選)。

常見的實現方案包括:基於數據庫實現,基於 Redis 實現,基於 Zookeeper 實現。

2 Redis 實現分布式鎖

2.1 基本實現

我們先關注其中的兩個必要條件:

  • 多進程可見
  • 互斥,鎖可以釋放
  1. Redis 本身就是基於 JVM 之外的,因此滿足多進程可見的要求。

  2. 互斥,互斥是說只有一個進程能獲取鎖標記,這個我們可以基於 Redis 的 setnx 指令來實現。setnx 是 set when not exist 的意思。當多次執行 setnx 命令時,只有第一次執行能成功,返回1,其余均返回0。

127.0.0.1:6379> keys *
(empty list or set)
127.0.0.1:6379> SETNX lock 001
(integer) 1
127.0.0.1:6379> get lock
"001"
127.0.0.1:6379> SETNX lock 002
(integer) 0
127.0.0.1:6379> get lock
"001"

多個進程對同一個 key 進行 setnx 操作,只有一個會成功,滿足了互斥的需求。

  1. 釋放鎖

釋放鎖其實只需要把鎖的 key 刪除即可,使用 del 指令。不過還需要思考一個問題,如果我們的服務器突然宕機,那么這個鎖是不是就永遠無法刪除了那?

為了避免服務器宕機引起的鎖無法釋放的問題,我們可以再獲取鎖的時候,給鎖加一個有效時間,超時自動釋放,避免了鎖永遠不釋放的問題。

SETNX 指令沒有設置時間的功能,因此需要使用 set 指令,然后結合 set 的 NX 和 PX 參數來完成。

EX:過期時長,單位是秒。PX:過期時長,單位是毫秒。NX:等同與 SETNX。

127.0.0.1:6379> set lock 001 NX EX 30
OK
127.0.0.1:6379> set lock 002 NX EX 30    
nil (第二次執行失敗)
127.0.0.1:6379> ttl lock
(integer) 12    
127.0.0.1:6379> get lock
"001"
127.0.0.1:6379>

步驟:

  • 通過 set 命令設置鎖
  • 判斷返回結果是否 OK。
    • Nil,失敗,結束或者重試(自旋鎖)
    • OK,獲取成功
      • 執行業務
      • 釋放鎖
  • 異常情況,服務宕機,超時自動釋放鎖。
2.2 互斥性

上面的版本中,會有一定的安全問題。

  • 3個進程,A,B 和 C 在執行任務,並爭搶鎖,此時 A 獲得了鎖,並設置自動釋放鎖時間為 10s。
  • A 開始執行業務,因為時間較長,超過了10s,此時鎖被自動釋放了。
  • B 搶到鎖開始執行,此時 A 執行完畢,刪除鎖,於是 B 剛得到的鎖又被釋放了,而 B 的業務其實還在執行。
  • C 獲得了鎖,開始執行。

問題出現了,B 和 C 同時獲取到了鎖,違反了互斥性。其實問題就是當前線程刪除了其他線程的鎖。

那么如何判斷當前獲取的鎖是不是自己的鎖那?

可以在 set 鎖時,存入當前線程的唯一標識,刪除之前判斷一下這個標識是不是自己的,如果不是自己的,就不要刪除。

2.3 重入性

如果我們在獲取鎖以后,執行代碼的過程中,再次嘗試獲取鎖,執行 setnx 肯定會失敗,因為鎖已經存在了。這樣可能會導致死鎖,這樣的鎖就是不可重入的。

重入鎖

可重入鎖,也叫所遞歸鎖,指的是在同一個線程內,外層函數獲得鎖之后,內層遞歸函數仍然可以獲取到該鎖。同一個線程再次進入到同步代碼塊時,可以使用自己已獲取到的鎖。

實現:

  • 獲取鎖:首先嘗試獲取鎖,如果獲取失敗,判斷這個鎖是否是自己的,如果是則允許再次獲取,而且必須記錄重復獲取鎖的次數。
  • 釋放鎖:釋放鎖不能直接刪除了,因為鎖是可重入的,如果鎖進入了多次,在最內層直接刪除鎖,導致外部的業務在沒有鎖的情況下執行,會有安全問題。因此必須獲取累計的重入次數,釋放時減去重入次數,如果減到了 0,則可以刪除鎖。

因此,存儲在鎖中的信息就必須包含:key,線程標識,可重入次數,需要使用 hash 結構。

  • EXISTS key:判斷一個 key 是否存在。
  • HEXISTS key field:判斷一個 hash 的 field 是否存在。
  • HSET key field value:給一個 hash 的 field 值增加指定數值。
  • HINCRBY key field increment:給一個 hash 的 field 值增加指定數值。
  • EXPIRE key seconds: 給一個 key 設置過期時間。
  • DEL key:刪除指定 key。

假設我們設置的鎖的 key 為 lock, hashKey 為當前線程的 id:“threadID”,鎖自動釋放的時間為 20 秒。

獲取鎖的步驟:

  1. 判斷 lock 是否存在 EXISTS lock
    • 存在,說明有獲取獲取鎖了,下面判斷是不是自己的鎖
      • 判斷當前線程的 id 座位 hashKey 事發后存在 HEXISTS lock threadId
      • 不存在,說明鎖已經有了,且不是自己獲取的,獲取鎖失敗,結束。
      • 存在,說明鎖是自己的,重入次數 +1,HINCRBY lock threadId 1, 去到步驟 3。
      1. 不存在,說明可以獲取鎖,HSET key threadId 1
      1. 設置鎖的自動釋放時間,EXPIRE lock 20

釋放鎖的步驟:

  1. 判斷當前線程 id 作為 hashkey 是否存在: HEXISTS lock threadId
    • 不存在,說明鎖已經失效,結束
    • 存在,說明鎖還在,重入次數減一:HINCRBY lock threadId -1,獲取新的重入次數。
  2. 判斷重入次數是否為0:
    • 為 0,說明鎖全部釋放,刪除 key,DEL Lock
    • 大於 0,說明鎖還在使用,重置有效時間:EXPIRE lock 20
2.4 Lua 腳本

上面探討的實現方案都需要多行 redis 命令才能實現,這時我們就需要考慮原子性的問題,如果不能保證原子性,整個過程的問題還是很大的。

Redis 中使用 Lua 腳本來保證原子性。

執行 Lua 腳本

EVAL script numkeys key [key ...] arg [arg ...]
summary: Execute a lua script server side
since: 2.6.0
  • script:腳本內容,或者腳本地址。
  • numkeys:腳本中用到的 key 的數量,接下來 numkeys 個參數會作為 key 參數,剩下的作為 arg 參數。
  • key: 作為 key 的參數,會被存入腳本環境中的 KEYS 數組,角標從 1 開始。
  • arg: 其他參數,會被存入腳本環境中的 ARGV 數組,角標從 1 開始。

緩存 Lua 腳本

SCRIPT LOAD script
summary: Load the specified lua script into the script cache.
since: 2.6.0

將一段腳本緩存起來,生成一個 SHA1 值並返回,作為腳本字典的 key,方便下次使用,參數 script 就是腳本內容或者地址。

127.0.0.1:6379>
127.0.0.1:6379> SCRIPT LOAD "return 'hello world!'"
"absd9sd9fsdjdkfjs9ds0d0r1klj1209i"
127.0.0.1:6379>

此處返回的 absd9sd9fsdjdkfjs9ds0d0r1klj1209i 就是腳本緩存后得到 sha1 值。

執行緩存腳本

EVALSHA sha1 numkeys key[key ...] arg[arg ...]
summary: Execute a lua script server side
since: 2.6.0

與 EVAL 類似,執行一段腳本,區別是通過腳本的 sha1 值,去腳本緩存中查找,然后執行。

Lua 基本語法

1)變量聲明

局部變量,使用 local 關鍵字即可:

local a = 123

2)打印結果

print('hello world')

3)條件控制

if()
then
      ....
else if()
then
      ....
else
      .....
end           

4)循環語句

while(ture)
do
    print('')
end    

5)Lua 調用 Reids 指令

當我們在 Redis 中允許 Lua 腳本時,有一個內置變量 redis,並且具備兩個函數:

  • redis.call("命令名稱","參數1","參數2"......), 執行指定的 redis 命令,遇到錯誤會直接返回錯誤。
  • redis.pcall("命令名稱","參數1","參數2"......), 執行指定的 redis 命令,遇到錯誤會以 Lua 表的形式返回。

例如:

redis.call('SET','num','123');

運行這段 Lua 腳本的含義就是執行 Redis 命令:set num 123

我們編寫 Lua 腳本時並不希望把 set 后面的 key 和 value 寫死,而是可以由調用腳本的人來指定,把 key 和 value 作為參數傳入腳本執行。Lua 腳本中使用內置變量來接收用戶傳入的 key 和 arg 參數。

  • KEYS: 用來存放 key 參數。
  • ARGV:用來存放 key 以外的參數。

我們在腳本中可以從數組中根據角標取出用戶傳入的參數。

reids.call('SET',KEYS[1],ARGV[1])

編寫分布式鎖腳本

  1. 普通互斥鎖
-- 判斷鎖是否是自己的
if(redis.call('GET',KEYS[1]) == ARGV[1]) then
    -- 是則刪除鎖
    return redis.call('DEL',KEYS[1])
end
-- 不是則直接返回
return 0
  1. 可重入鎖

獲取鎖:

local key = KEYS[1]; --鎖的 key
local threadId = ARGV[1]; -- 線程唯一標識
local releaseTime = ARGV[2]; -- 鎖的自動釋放時間

if(reids.call('exists', key) == 0) then --判斷是否存在
    redis.call('hset',key,threadId,'1'); -- 不存在,獲取鎖,設置重入次數
    reids.call('expired',key,releaseTime); -- 設置有效期
    return 1; -- 返回結果
end;

if(redis.call('hexists',key,threadId) == 1) then -- 鎖已經存在,判斷 threadId 是否是自己的
    redis.call('hincrby',key,threadId,'1'); -- 是自己,獲取鎖,重入次數加1。
    redis.call('expired',key,releaseTime); -- 設置有效期
    return 1; -- 返回結果
end;

retun 0; -- 走到這里,說明獲得鎖的線程不是自己,獲取鎖失敗

釋放鎖:

local key = KEYS[1]; --鎖的 key
local threadId = ARGV[1]; -- 線程唯一標識
local releaseTime = ARGV[2]; -- 鎖的自動釋放時間

if(redis.call('HEXISTS',key,threadId) == 0) then -- 判斷當前鎖是否被自己持有
    return nil; --如果不是自己,則直接返回
end;

local count = reis.call('HINCRBY',key,threadId,-1); --是自己的鎖,則重入次數減一
if(count > 0) then
    redis.call('EXPIRE',key,releaseTime);
    return nil;
else
    reids.call('DEL',key); -- 等於0署名可以釋放鎖,直接刪除
    return nil;
end;    
Zookeeper 實現分布式鎖

Zookeeper 是一種提供配置管理,分布式協同以及命名的中心化服務。

Zookeeper 包含一系列的節點,叫做 znode,好像文件系統一樣,每一個 znode 表示一個目錄。znode 有一些特性:

  • 有序節點:加入當前父節點為 /lock, 我們可以在這個父節點下面創建子節點,生成子節點的序號可以是有序的。
  • 臨時節點:客戶端可以建立一個臨時節點,在會話結束或者超時后,zookeeper 會自動刪除該節點。
  • 事件監聽:在讀取數據時,我們可以同時對節點設置監聽事件,當節點數據或者結構發生變化時,zookeeper 會通知客戶端。

Zookeeper 分布式鎖的落地方案:

  1. 使用 zookeeper 的臨時節點和有序節點,每個線程獲取鎖就是在 zookeeper 創建一個臨時有序節點,比如在 /lock/ 目錄下。
  2. 創建節點成功后,獲取 /lock 目錄下所有臨時節點,在判斷當前線程創建的節點是否是所有節點的序號的最小節點。
  3. 如果當前線程創建的節點是所有節點序號最小的節點,則認為獲取鎖成功。
  4. 如果當前線程創建的節點不是所有節點序號最小的節點,則對節點序號的前一個節點一個事件監聽。比如當前線程獲取到的節點序號為 /lock/003, 則對 /lock/002 添加一個事件監聽。
  5. 如果鎖釋放了,會喚醒下一個序號的節點,然后重新執行第三步,判斷是否自己是序號最小的節點。

來看看 Zookeeper 是否滿足分布式鎖的一些特性:

  • 互斥:因為只有一個最小節點,因此滿足互斥性。
  • 鎖釋放:使用 Zookeeper 可以有效解決鎖無法釋放的問題,因為在創建鎖的時候,客戶端會在 ZK 中創建一個臨時節點,一但客戶端獲取到鎖后突然掛掉,這個臨時節點會自動刪除,其他客戶端就可以再次獲得鎖。
  • 阻塞鎖,使用 Zookeeper 可以實現阻塞的鎖,客戶端可以通過創建順序節點,並且在節點上綁定監聽,一旦節點有變化,Zookeeper 會通知客戶端,客戶端可以檢查自己創建的節點是不是當前所有節點中序號最小的,如果是,那么自己就可以獲取到鎖,便可以執行業務邏輯了。
  • 可重入,使用 Zookeeper 也可以有效的解決不可重入的問題,客戶端在創建節點的時候,把當前客戶端的主機信息和線程信息寫入到節點中,下次想要獲取鎖的時候和當前最小節點中的數據對比一下就可以了。如果和自己的信息一樣,那么自己可以直接獲取到鎖,如果不一樣就在創建一個臨時的順序節點,參與排隊。
  • 高可用:使用 Zookeeper 可以有效低解決單點問題,ZK 是集群部署的。
  • 高性能:Zookeeper 集群是滿足強一致性的,因此犧牲一些性能,與 Redis 相比略顯不足。
總結

Redis 實現:實現比較簡單,性能最高,但是可靠性難以維護。

Zookeeper:實現最簡單,可靠性最高,性能比 Redis 低。

下一章我們會對市面上成熟的分布式鎖框架進行介紹,並且會將這一章的代碼進行完善和測試。


免責聲明!

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



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