一文掌握分佈式鎖原理,附詳細方案
本文主要介紹分佈式鎖的概念和分佈式鎖的設計原則,以及常見的分佈式鎖的實現方式。
分佈式鎖的概念和設計原則
什麼是分佈式鎖
要介紹分佈式鎖,首先要提到與分佈式鎖相對應的是線程鎖、進程鎖。
-
線程鎖:主要用來給方法、代碼塊加鎖。當某個方法或代碼使用鎖,在同一時刻僅有一個線程執行該方法或該代碼段。線程鎖只在同一 JVM 中有效果,因爲線程鎖的實現在根本上是依靠線程之間共享內存實現的,比如 synchronized 是共享對象頭,顯示鎖 Lock 是共享某個變量(state)。
-
進程鎖:爲了控制同一操作系統中多個進程訪問某個共享資源,因爲進程具有獨立性,各個進程無法訪問其他進程的資源,因此無法通過 synchronized 等線程鎖實現進程鎖。
-
分佈式鎖:當多個進程不在同一個系統中 (比如分佈式系統中控制共享資源訪問),用分佈式鎖控制多個進程對資源的訪問。
分佈式鎖的設計原則
分佈式鎖的最小設計原則:安全性和有效性
Redis 的官網上對使用分佈式鎖提出至少需要滿足如下三個要求:
-
互斥(屬於安全性):在任何給定時刻,只有一個客戶端可以持有鎖。
-
無死鎖(屬於有效性):即使鎖定資源的客戶端崩潰或被分區,也總是可以獲得鎖;通常通過超時機制實現。
-
容錯性(屬於有效性):只要大多數 Redis 節點都啓動,客戶端就可以獲取和釋放鎖。
除此之外,分佈式鎖的設計中還可以 / 需要考慮:
-
加鎖解鎖的同源性:A 加的鎖,不能被 B 解鎖
-
獲取鎖是非阻塞的:如果獲取不到鎖,不能無限期等待;
-
高性能:加鎖解鎖是高性能的
分佈式鎖的實現方案
就體系的角度而言,談談常見的分佈式鎖的實現方案。
-
基於數據庫實現分佈式鎖
-
基於數據庫表(鎖表,很少使用)
-
樂觀鎖 (基於版本號)
-
悲觀鎖 (基於排它鎖)
-
基於 redis 實現分佈式鎖
-
單個 Redis 實例:setnx(key, 當前時間 + 過期時間) + Lua
-
Redis 集羣模式:Redlock
-
基於 zookeeper 實現分佈式鎖
-
臨時有序節點來實現的分佈式鎖, Curator
基於數據庫實現
基於數據庫如何實現分佈式鎖?有什麼缺陷?
基於數據庫表(鎖表,很少使用)
最簡單的方式可能就是直接創建一張鎖表,然後通過操作該表中的數據來實現了。當我們想要獲得鎖的時候,就可以在該表中增加一條記錄,想要釋放鎖的時候就刪除這條記錄。
爲了更好的演示,我們先創建一張數據庫表,參考如下:
CREATE TABLE database_lock (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource` int NOT NULL COMMENT '鎖定的資源',
`description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
PRIMARY KEY (id),
UNIQUE KEY uiq_idx_resource (resource)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='數據庫分佈式鎖表';
當我們想要獲得鎖時,可以插入一條數據:
INSERT INTO database_lock(resource, description) VALUES (1, 'lock');
當需要釋放鎖的時,可以刪除這條數據:
DELETE FROM database_lock WHERE resource=1;
基於悲觀鎖
悲觀鎖實現思路?
-
在對任意記錄進行修改前,先嚐試爲該記錄加上排他鎖(exclusive locking)。
-
如果加鎖失敗,說明該記錄正在被修改,那麼當前查詢可能要等待或者拋出異常。具體響應方式由開發者根據實際需要決定。
-
如果成功加鎖,那麼就可以對記錄做修改,事務完成後就會解鎖了。
-
其間如果有其他對該記錄做修改或加排他鎖的操作,都會等待我們解鎖或直接拋出異常。
以 MySQL InnoDB 中使用悲觀鎖爲例?
要使用悲觀鎖,我們必須關閉 mysql 數據庫的自動提交屬性,因爲 MySQL 默認使用 autocommit 模式,也就是說,當你執行一個更新操作後,MySQL 會立刻將結果進行提交。set autocommit=0;
//0.開始事務
begin;/begin work;/start transaction; (三者選一就可以)
//1.查詢出商品信息
select status from t_goods where id=1 for update;
//2.根據商品信息生成訂單
insert into t_orders (id,goods_id) values (null,1);
//3.修改商品status爲2
update t_goods set status=2;
//4.提交事務
commit;/commit work;
上面的查詢語句中,我們使用了select…for update
的方式,這樣就通過開啓排他鎖的方式實現了悲觀鎖。此時在 t_goods 表中,id 爲 1 的 那條數據就被我們鎖定了,其它的事務必須等本次事務提交之後才能執行。這樣我們可以保證當前的數據不會被其它事務修改。
上面我們提到,使用select…for update
會把數據給鎖住,不過我們需要注意一些鎖的級別,MySQL InnoDB 默認行級鎖。行級鎖都是基於索引的,如果一條 SQL 語句用不到索引是不會使用行級鎖的,會使用表級鎖把整張表鎖住,這點需要注意。
基於樂觀鎖
樂觀併發控制(又名 “樂觀鎖”,Optimistic Concurrency Control,縮寫 “OCC”)是一種併發控制的方法。它假設多用戶併發的事務在處理時不會彼此互相影響,各事務能夠在不產生鎖的情況下處理各自影響的那部分數據。在提交數據更新之前,每個事務會先檢查在該事務讀取數據後,有沒有其他事務又修改了該數據。如果其他事務有更新的話,正在提交的事務會進行回滾。
以使用版本號實現樂觀鎖爲例?
使用版本號時,可以在數據初始化時指定一個版本號,每次對數據的更新操作都對版本號執行 + 1 操作。並判斷當前版本號是不是該數據的最新的版本號。
1.查詢出商品信息
select (status,status,version) from t_goods where id=#{id}
2.根據商品信息生成訂單
3.修改商品status爲2
update t_goods
set status=2,version=version+1
where id=#{id} and version=#{version};
需要注意的是,樂觀鎖機制往往基於系統中數據存儲邏輯,因此也具備一定的侷限性。由於樂觀鎖機制是在我們的系統中實現的,對於來自外部系統的用戶數據更新操作不受我們系統的控制,因此可能會造成髒數據被更新到數據庫中。在系統設計階段,我們應該充分考慮到這些情況,並進行相應的調整(如將樂觀鎖策略在數據庫存儲過程中實現,對外只開放基於此存儲過程的數據更新途徑,而不是將數據庫表直接對外公開)。
- 缺陷
對數據庫依賴,開銷問題,行鎖變表鎖問題,無法解決數據庫單點和可重入的問題。
基於 redis 如何實現分佈式鎖
基於 redis 如何實現分佈式鎖?這裏一定要看 Redis 的官網在新窗口打開的分佈式鎖的實現這篇文章。
SetNXPX + Lua
加鎖:set NX PX
+ 重試 + 重試間隔
向 Redis 發起如下命令: SET productId:lock 0xx9p03001 NX PX 30000
其中,"productId" 由自己定義,可以是與本次業務有關的 id,"0xx9p03001" 是一串隨機值,必須保證全局唯一 (原因在後文中會提到),“NX"指的是當且僅當 key(也就是案例中的"productId:lock”) 在 Redis 中不存在時,返回執行成功,否則執行失敗。"PX 30000" 指的是在 30 秒後,key 將被自動刪除。執行命令後返回成功,表明服務成功的獲得了鎖。
@Override
public boolean lock(String key, long expire, int retryTimes, long retryDuration) {
// use JedisCommands instead of setIfAbsense
boolean result = setRedis(key, expire);
// retry if needed
while ((!result) && retryTimes-- > 0) {
try {
log.debug("lock failed, retrying..." + retryTimes);
Thread.sleep(retryDuration);
} catch (Exception e) {
return false;
}
// use JedisCommands instead of setIfAbsense
result = setRedis(key, expire);
}
return result;
}
private boolean setRedis(String key, long expire) {
try {
RedisCallback<String> redisCallback = connection -> {
JedisCommands commands = (JedisCommands) connection.getNativeConnection();
String uuid = SnowIDUtil.uniqueStr();
lockFlag.set(uuid);
return commands.set(key, uuid, NX, PX, expire); // 看這裏
};
String result = redisTemplate.execute(redisCallback);
return !StringUtil.isEmpty(result);
} catch (Exception e) {
log.error("set redis occurred an exception", e);
}
return false;
}
解鎖:採用 lua 腳本
在刪除 key 之前,一定要判斷服務 A 持有的 value 與 Redis 內存儲的 value 是否一致。如果貿然使用服務 A 持有的 key 來刪除鎖,則會誤將服務 B 的鎖釋放掉。
if redis.call("get", KEYS[1])==ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
基於 RedLock 實現分佈式鎖
這是 Redis 作者推薦的分佈式集羣情況下的方式,請看這篇文章 Is Redlock safe? 在新窗口打開
假設有兩個服務 A、B 都希望獲得鎖,有一個包含了 5 個 redis master 的 Redis Cluster,執行過程大致如下:
-
客戶端獲取當前時間戳,單位: 毫秒
-
服務 A 輪尋每個 master 節點,嘗試創建鎖。(這裏鎖的過期時間比較短,一般就幾十毫秒) RedLock 算法會嘗試在大多數節點上分別創建鎖,假如節點總數爲 n,那麼大多數節點指的是 n/2+1。
-
客戶端計算成功建立完鎖的時間,如果建鎖時間小於超時時間,就可以判定鎖創建成功。如果鎖創建失敗,則依次 (遍歷 master 節點) 刪除鎖。
-
只要有其它服務創建過分佈式鎖,那麼當前服務就必須輪尋嘗試獲取鎖。
基於 Redis 的客戶端
這裏 Redis 的客戶端(Jedis, Redisson, Lettuce 等)都是基於上述兩類形式來實現分佈式鎖的,只是兩類形式的封裝以及一些優化(比如 Redisson 的 watch dog)。
以基於 Redisson 實現分佈式鎖爲例(支持了 單實例、Redis 哨兵、redis cluster、redis master-slave 等各種部署架構):
特色
-
redisson 所有指令都通過 lua 腳本執行,保證了操作的原子性
-
redisson 設置了 watchdog 看門狗,“看門狗” 的邏輯保證了沒有死鎖發生
-
redisson 支持 Redlock 的實現方式。
過程
-
線程去獲取鎖,獲取成功: 執行 lua 腳本,保存數據到 redis 數據庫。
-
線程去獲取鎖,獲取失敗: 訂閱瞭解鎖消息,然後再嘗試獲取鎖,獲取成功後,執行 lua 腳本,保存數據到 redis 數據庫。
互斥
如果這個時候客戶端 B 來嘗試加鎖,執行了同樣的一段 lua 腳本。第一個 if 判斷會執行 “exists myLock”,發現 myLock 這個鎖 key 已經存在。接着第二個 if 判斷,判斷 myLock 鎖 key 的 hash 數據結構中,是否包含客戶端 B 的 ID,但明顯沒有,那麼客戶端 B 會獲取到 pttl myLock 返回的一個數字,代表 myLock 這個鎖 key 的剩餘生存時間。此時客戶端 B 會進入一個 while 循環,不聽的嘗試加鎖。
watch dog 自動延時機制
客戶端 A 加鎖的鎖 key 默認生存時間只有 30 秒,如果超過了 30 秒,客戶端 A 還想一直持有這把鎖,怎麼辦?其實只要客戶端 A 一旦加鎖成功,就會啓動一個 watch dog 看門狗,它是一個後臺線程,會每隔 10 秒檢查一下,如果客戶端 A 還持有鎖 key,那麼就會不斷的延長鎖 key 的生存時間。
可重入
每次 lock 會調用 incrby,每次 unlock 會減一。
進一步理解
-
藉助 Redis 實現分佈式鎖時,有一個共同的缺陷: 當獲取鎖被拒絕後,需要不斷的循環,重新發送獲取鎖 (創建 key) 的請求,直到請求成功。這就造成空轉,浪費寶貴的 CPU 資源。
-
RedLock 算法本身有爭議,具體看這篇文章 How to do distributed locking 在新窗口打開 以及作者的回覆 Is Redlock safe? 在新窗口打開
說幾個核心點:
- 順序節點
創建一個用於發號的節點 “/test/lock”,然後以它爲父親節點的前綴爲“/test/lock/seq-” 依次發號:
- 獲得最小號得鎖
由於序號的遞增性,可以規定排號最小的那個獲得鎖。所以,每個線程在嘗試佔用鎖之前,首先判斷自己是排號是不是當前最小,如果是,則獲取鎖。
- 節點監聽機制
每個線程搶佔鎖之前,先搶號創建自己的 ZNode。同樣,釋放鎖的時候,就需要刪除搶號的 Znode。搶號成功後,如果不是排號最小的節點,就處於等待通知的狀態。等誰的通知呢?不需要其他人,只需要等前一個 Znode 的通知就可以了。當前一個 Znode 刪除的時候,就是輪到了自己佔有鎖的時候。第一個通知第二個、第二個通知第三個,擊鼓傳花似的依次向後。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/vupf4Rc-LyeIMxD_uqnY7g