分布式鎖
1 什么是分布式鎖?
在討論分布式鎖之前,我們先假設一個業務場景:
1.1 業務場景
在電商系統中,用戶購買商品需要扣減庫存,一般扣庫存有兩種方式:
下單減庫存
優點:用戶體驗好,下單成功,庫存直接扣減,用戶支付不會出現庫存不足。
缺點:用戶一直不付款,這個商品的庫存就會被占用,其他人無法購買。
支付減庫存
優點:不會導致庫存被惡意鎖定,對商家有利。
缺點:用戶體驗不好,用戶支付時可能商品庫存不足了,會導致交易失敗。
那么,我們一般為了用戶體驗,會采用下單減庫存,為了解決下單減庫存的缺陷,會創建一個定時任務,定時去清理超時未支付的訂單。
這個定時任務主要包含以下步驟:
- 查詢超時未支付的訂單,獲取訂單中的商品信息。
- 修改未支付訂單的狀態,改為取消。
- 恢復訂單中商品扣減的庫存。
如果我們給訂單服務搭建一個 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 基本實現
我們先關注其中的兩個必要條件:
- 多進程可見
- 互斥,鎖可以釋放
Redis 本身就是基於 JVM 之外的,因此滿足多進程可見的要求。
互斥,互斥是說只有一個進程能獲取鎖標記,這個我們可以基於 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 操作,只有一個會成功,滿足了互斥的需求。
- 釋放鎖
釋放鎖其實只需要把鎖的 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 秒。
獲取鎖的步驟:
- 判斷 lock 是否存在
EXISTS lock
- 存在,說明有獲取獲取鎖了,下面判斷是不是自己的鎖
- 判斷當前線程的 id 座位 hashKey 事發后存在
HEXISTS lock threadId
- 不存在,說明鎖已經有了,且不是自己獲取的,獲取鎖失敗,結束。
- 存在,說明鎖是自己的,重入次數 +1,
HINCRBY lock threadId 1
, 去到步驟 3。
- 判斷當前線程的 id 座位 hashKey 事發后存在
-
- 不存在,說明可以獲取鎖,
HSET key threadId 1
。
- 不存在,說明可以獲取鎖,
-
- 設置鎖的自動釋放時間,
EXPIRE lock 20
。
- 設置鎖的自動釋放時間,
- 存在,說明有獲取獲取鎖了,下面判斷是不是自己的鎖
釋放鎖的步驟:
- 判斷當前線程 id 作為 hashkey 是否存在:
HEXISTS lock threadId
。- 不存在,說明鎖已經失效,結束
- 存在,說明鎖還在,重入次數減一:
HINCRBY lock threadId -1
,獲取新的重入次數。
- 判斷重入次數是否為0:
- 為 0,說明鎖全部釋放,刪除 key,
DEL Lock
。 - 大於 0,說明鎖還在使用,重置有效時間:
EXPIRE lock 20
。
- 為 0,說明鎖全部釋放,刪除 key,
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])
編寫分布式鎖腳本
- 普通互斥鎖
-- 判斷鎖是否是自己的
if(redis.call('GET',KEYS[1]) == ARGV[1]) then
-- 是則刪除鎖
return redis.call('DEL',KEYS[1])
end
-- 不是則直接返回
return 0
- 可重入鎖
獲取鎖:
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 分布式鎖的落地方案:
- 使用 zookeeper 的臨時節點和有序節點,每個線程獲取鎖就是在 zookeeper 創建一個臨時有序節點,比如在 /lock/ 目錄下。
- 創建節點成功后,獲取 /lock 目錄下所有臨時節點,在判斷當前線程創建的節點是否是所有節點的序號的最小節點。
- 如果當前線程創建的節點是所有節點序號最小的節點,則認為獲取鎖成功。
- 如果當前線程創建的節點不是所有節點序號最小的節點,則對節點序號的前一個節點一個事件監聽。比如當前線程獲取到的節點序號為 /lock/003, 則對 /lock/002 添加一個事件監聽。
- 如果鎖釋放了,會喚醒下一個序號的節點,然后重新執行第三步,判斷是否自己是序號最小的節點。
來看看 Zookeeper 是否滿足分布式鎖的一些特性:
- 互斥:因為只有一個最小節點,因此滿足互斥性。
- 鎖釋放:使用 Zookeeper 可以有效解決鎖無法釋放的問題,因為在創建鎖的時候,客戶端會在 ZK 中創建一個臨時節點,一但客戶端獲取到鎖后突然掛掉,這個臨時節點會自動刪除,其他客戶端就可以再次獲得鎖。
- 阻塞鎖,使用 Zookeeper 可以實現阻塞的鎖,客戶端可以通過創建順序節點,並且在節點上綁定監聽,一旦節點有變化,Zookeeper 會通知客戶端,客戶端可以檢查自己創建的節點是不是當前所有節點中序號最小的,如果是,那么自己就可以獲取到鎖,便可以執行業務邏輯了。
- 可重入,使用 Zookeeper 也可以有效的解決不可重入的問題,客戶端在創建節點的時候,把當前客戶端的主機信息和線程信息寫入到節點中,下次想要獲取鎖的時候和當前最小節點中的數據對比一下就可以了。如果和自己的信息一樣,那么自己可以直接獲取到鎖,如果不一樣就在創建一個臨時的順序節點,參與排隊。
- 高可用:使用 Zookeeper 可以有效低解決單點問題,ZK 是集群部署的。
- 高性能:Zookeeper 集群是滿足強一致性的,因此犧牲一些性能,與 Redis 相比略顯不足。
總結
Redis 實現:實現比較簡單,性能最高,但是可靠性難以維護。
Zookeeper:實現最簡單,可靠性最高,性能比 Redis 低。
下一章我們會對市面上成熟的分布式鎖框架進行介紹,並且會將這一章的代碼進行完善和測試。