1. 前言
關(guān)于分布式鎖的實現(xiàn),目前常用的方案有以下三類:
- 數(shù)據(jù)庫樂觀鎖;
- 基于分布式緩存實現(xiàn)的鎖服務(wù),典型代表有 Redis 和基于 Redis 的 RedLock;
- 基于分布式一致性算法實現(xiàn)的鎖服務(wù),典型代表有 ZooKeeper、Chubby 和 ETCD。
關(guān)于 Redis 實現(xiàn)分布式鎖,網(wǎng)上可以查到很多資料,筆者最初也借鑒了這些資料,但是,在分布式鎖的實現(xiàn)和使用過程中意識到這些資料普遍存在問題,容易誤導(dǎo)初學(xué)者,鑒于此,撰寫本文,希望為對分布式鎖感興趣的讀者提供一篇切實可用的參考文檔。
本場 Chat 將介紹以下內(nèi)容:
- 分布式鎖原理介紹;
- 基于 Redis 實現(xiàn)的分布式鎖的安全性分析
- 加鎖的正確方式及典型錯誤案例分析;
- 解鎖的正確方式及典型錯誤案例分析。
2. 分布式鎖原理介紹
2.1 分布式鎖基本約束條件
為了確保鎖服務(wù)可用,通常,分布式鎖需同時滿足以下四個約束條件:
- 互斥性:在任意時刻,只有一個客戶端能持有鎖;
- 安全性:即不會形成死鎖,當(dāng)一個客戶端在持有鎖的期間崩潰而沒有主動解鎖的情況下,其持有的鎖也能夠被正確釋放,并保證后續(xù)其它客戶端能加鎖;
- 可用性:就 Redis 而言,當(dāng)提供鎖服務(wù)的 Redis master 節(jié)點(diǎn)發(fā)生宕機(jī)等不可恢復(fù)性故障時,slave 節(jié)點(diǎn)能夠升主并繼續(xù)提供服務(wù),支持客戶端加鎖和解鎖;對基于分布式一致性算法實現(xiàn)的鎖服務(wù),如 ETCD 而言,當(dāng) leader 節(jié)點(diǎn)宕機(jī)時,follow 節(jié)點(diǎn)能夠選舉出新的 leader 繼續(xù)提供鎖服務(wù);
- 對稱性:對于任意一個鎖,其加鎖和解鎖必須是同一個客戶端,即,客戶端 A 不能把客戶端 B 加的鎖給解了。
2.2 基于 Redis 實現(xiàn)分布式鎖(以 Redis 單機(jī)模式為例)
基于 Redis 實現(xiàn)的鎖服務(wù)的思路是比較簡單直觀的:我們把鎖數(shù)據(jù)存儲在分布式環(huán)境中的一個節(jié)點(diǎn),所有需要獲取鎖的調(diào)用方(客戶端),都需訪問該節(jié)點(diǎn),如果鎖數(shù)據(jù)(key-value 鍵值對)已經(jīng)存在,則說明已經(jīng)有其它客戶端持有該鎖,可等待其釋放(key-value 被主動刪除或者因過期而被動刪除)再嘗試獲取鎖;如果鎖數(shù)據(jù)不存在,則寫入鎖數(shù)據(jù)(key-value),其中 value 需要保證在足夠長的一段時間內(nèi)在所有客戶端的所有獲取鎖的請求中都是唯一的,以便釋放鎖的時候進(jìn)行校驗;鎖服務(wù)使用完畢之后,需要主動釋放鎖,即刪除存儲在 Redis 中的 key-value 鍵值對。其架構(gòu)如下:
2.3 加解鎖流程
基于 Redis 官方的文檔,對于一個嘗試獲取鎖的操作,流程如下:
步驟 1:向 Redis 節(jié)點(diǎn)發(fā)送命令,請求鎖:
SET lock_name my_random_value NX PX 30000
其中:
-
lock_name:即鎖名稱,這個名稱應(yīng)是公開的,在分布式環(huán)境中,對于某一確定的公共資源,所有爭用方(客戶端)都應(yīng)該知道對應(yīng)鎖的名字。對于 Redis 而言,lock_name 就是 key-value 中的 key,具有唯一性。 -
my_random_value是由客戶端生成的一個隨機(jī)字符串,它要保證在足夠長的一段時間內(nèi)在所有客戶端的所有獲取鎖的請求中都是唯一的,用于唯一標(biāo)識鎖的持有者。 - NX 表示只有當(dāng) lock_name(key) 不存在的時候才能 SET 成功,從而保證只有一個客戶端能獲得鎖,而其它客戶端在鎖被釋放之前都無法獲得鎖。
- PX 30000 表示這個鎖節(jié)點(diǎn)有一個 30 秒的自動過期時間(目的是為了防止持有鎖的客戶端故障后,無法主動釋放鎖而導(dǎo)致死鎖,因此要求鎖的持有者必須在過期時間之內(nèi)執(zhí)行完相關(guān)操作并釋放鎖)。
步驟 2:如果步驟 1 的命令返回成功,則代表獲取鎖成功,否則獲取鎖失敗。
對于一個擁有鎖的客戶端,釋放鎖流程如下:
1.向 Redis 結(jié)點(diǎn)發(fā)送命令,獲取鎖對應(yīng)的 value:
GET lock_name
2.如果查詢回來的 value 和客戶端自身的 my_random_value 一致,則可確認(rèn)自己是鎖的持有者,可以發(fā)起解鎖操作,即主動刪除對應(yīng)的 key,發(fā)送命令:
DEL lock_name
通過 Redis-cli 執(zhí)行上述命令,顯示如下:
100.X.X.X:6379> set lock_name my_random_value NX PX 30000
OK
100.X.X.X:6379> get lock_name
"my_random_value"
100.X.X.X:6379> del lock_name
(integer) 1
100.X.X.X:6379> get lock_name
(nil)
3. 基于 Redis 的分布式鎖的安全性分析
3.1 預(yù)防死鎖
典型死鎖場景:
一個客戶端獲取鎖成功,但是在釋放鎖之前崩潰了,此時該客戶端實際上已經(jīng)失去了對公共資源的操作權(quán),但卻沒有辦法請求解鎖(刪除 key-value 鍵值對),那么,它就會一直持有這個鎖,而其它客戶端永遠(yuǎn)無法獲得鎖。
解決方案:
可以在加鎖時為鎖設(shè)置過期時間,當(dāng)過期時間到達(dá),Redis會自動刪除對應(yīng)的key-value,從而避免死鎖。需要注意的是,這個過期時間需要結(jié)合具體業(yè)務(wù)綜合評估設(shè)置,以保證鎖的持有者能夠在過期時間之內(nèi)執(zhí)行完相關(guān)操作并釋放鎖。
3.2 設(shè)置鎖自動過期時間以預(yù)防死鎖存在的隱患
為了避免死鎖,可利用 Redis 為鎖數(shù)據(jù)(key-value)設(shè)置自動過期時間,雖然可以解決死鎖的問題,但卻存在隱患.
典型場景:
- 客戶端 A 獲取鎖成功
- 客戶端 A 在某個操作上阻塞了很長時間(對于 Java 而言,如發(fā)生 full-GC)
- 過期時間到,鎖自動釋放
- 客戶端 B 獲取到了對應(yīng)同一個資源的鎖
- 客戶端 A 從阻塞中恢復(fù)過來,認(rèn)為自己依舊持有鎖,繼續(xù)操作同一個資源,導(dǎo)致互斥性失效
解決方案:
- 存在隱患的方案:第 5 步中,客戶端 A 恢復(fù)回來后,可以比較下目前已經(jīng)持有鎖的時間,如果發(fā)現(xiàn)已經(jīng)過期,則放棄對共享資源的操作即可避免互斥性失效的問題。但是,客戶端 A 所在節(jié)點(diǎn)的時間和 Redis 節(jié)點(diǎn)的時間很可能不一致(如:客戶端與 Redis 節(jié)點(diǎn)不在同一臺服務(wù)器,而不同服務(wù)器時間通常不完全同步),因此,嚴(yán)格來講,任何依賴兩個節(jié)點(diǎn)時間比較結(jié)果的互斥性算法,都存在隱患。目前網(wǎng)上很多資料都采用了這種方案,鑒于其隱患,不推薦。
- 可取的方案:既然比較時間不可取,那么,還可以比較
my_random_value:客戶端A恢復(fù)后,在操作共享資源前應(yīng)比較目前自身所持有鎖的my_random_value與 Redis 中存儲的my_random_value是否一致,如果不相同,說明已經(jīng)不再持有鎖,則放棄對共享資源的操作以避免互斥性失效的問題。
3.3 解鎖操作的原子性
為了保證每次解鎖操作都能正確性的進(jìn)行,需要引入全局唯一的 my_random_value。具體而言,解鎖需要兩步,先查詢(get)鎖對應(yīng)的 value,與自己加鎖時設(shè)置的 my_random_value 進(jìn)行對比,如果相同,則可確認(rèn)這把鎖是自己加的,然后再發(fā)起解鎖(del)。需要注意的是,get 和 del 是兩個操作,非原子性,那么解鎖本身也會存在破壞互斥性的可能。
典型場景:
- 客戶端 A 獲取鎖成功。
- 客戶端 A 訪問共享資源。
- 客戶端 A 為了釋放鎖,先執(zhí)行 GET 操作獲取鎖對應(yīng)的隨機(jī)字符串的值。
- 客戶端 A 判斷隨機(jī)字符串的值,與預(yù)期的值相等。
- 客戶端 A 由于某個原因阻塞住了很長時間。
- 過期時間到了,鎖自動釋放了。
- 客戶端 B 獲取到了對應(yīng)同一個資源的鎖。
- 客戶端 A 從阻塞中恢復(fù)過來,執(zhí)行 DEL 操縱,釋放掉了客戶端 B 持有的鎖。
解決方案:
保障解鎖操作的原子性,如何保障呢?在實踐中,筆者總結(jié)出兩種方案:
1. 使用 Redis 事務(wù)功能,使用 watch 命令監(jiān)控鎖對應(yīng)的 key,釋放鎖則采用事務(wù)功能(multi 命令),如果持有的鎖已經(jīng)因過期而釋放(或者過期釋放后又被其它客戶端持有),則 key 對應(yīng)的 value 將改變,釋放鎖的事務(wù)將不會被執(zhí)行,從而避免錯誤的釋放鎖,示例代碼如下:
Jedis jedis = new Jedis("127.0.0.1", 6379);
// “自旋”,等待鎖
String result = null;
while (true)
{
// 申請鎖,只有當(dāng)“l(fā)ock_name”不存在時才能申請成功,返回“OK",鎖的過期時間設(shè)置為5s
result = jedis.set("lock_name", "my_random_value", SET_IF_NOT_EXIST,
SET_WITH_EXPIRE_TIME, 5000);
if ("OK".equals(result))
{
break;
}
}
// 監(jiān)控鎖對應(yīng)的key,如果其它的客戶端對這個key進(jìn)行了更改,那么本次事務(wù)會被取消。
jedis.watch("lock_name");
// 成功獲取鎖,則操作公共資源,自定義流程
// to do something...
// 釋放鎖之前,校驗是否持有鎖
if (jedis.get("lock_name").equals("my_random_value"))
{
// 開啟事務(wù)功能,
Transaction multi = jedis.multi();
// 模擬客戶端阻塞10s,鎖超時,自動清除
try
{
Thread.sleep(5000);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
// 客戶端恢復(fù),繼續(xù)釋放鎖
multi.del("lock_name");
// 執(zhí)行事務(wù)(如果其它的客戶端對這個key進(jìn)行了更改,那么本次事務(wù)會被取消,不會執(zhí)行)
multi.exec();
}
// 釋放資源
jedis.unwatch();
jedis.close();
2. Redis 支持 Lua 腳本并保證其原子性,使用 Lua 腳本實現(xiàn)鎖校驗與釋放,并使用 Redis 的 evel 函數(shù)執(zhí)行 Lua 腳本,代碼如下:
Jedis jedis = new Jedis("127.0.0.1", 6379);
// “自旋”,等待鎖
String result = null;
while (true)
{
// 申請鎖,只有當(dāng)“l(fā)ock_name”不存在時才能申請成功,返回“OK",鎖的過期時間設(shè)置為5s
result = jedis.set("lock_name", "my_random_value", SET_IF_NOT_EXIST,
SET_WITH_EXPIRE_TIME, 5000);
if ("OK".equals(result))
{
break;
}
}
// 成功獲取鎖,則操作公共資源,自定義流程
// to do something...
// Lua腳本,用于校驗并釋放鎖
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
try
{
// 模擬客戶端阻塞10s,鎖超時,自動清除
Thread.sleep(10000);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
// 執(zhí)行Lua腳本,校驗并釋放鎖
jedis.eval(script, Collections.singletonList("lock_name"),
Collections.singletonList("my_random_value"));
jedis.close();
3.4 Redis 節(jié)點(diǎn)故障后,主備切換的數(shù)據(jù)一致性
考慮 Redis 節(jié)點(diǎn)宕機(jī),如果長時間無法恢復(fù),則導(dǎo)致鎖服務(wù)長時間不可用。為了保證鎖服務(wù)的可用性,通常的方案是給這個 Redis 節(jié)點(diǎn)掛一個 Slave(多個也可以),當(dāng) Master 節(jié)點(diǎn)不可用的時候,系統(tǒng)自動切到 Slave 上。但是由于 Redis 的主從復(fù)制(replication)是異步的,這可能導(dǎo)致在宕機(jī)切換過程中喪失鎖的安全性。
典型場景:
- 客戶端 A 從 Master 獲取了鎖。
- Master 宕機(jī)了,存儲鎖的key還沒有來得及同步到 Slave 上。
- Slave 升級為 Master。
- 客戶端 B 從新的 Master 獲取到了對應(yīng)同一個資源的鎖。
- 客戶端 A 和客戶端 B 同時持有了同一個資源的鎖,鎖的安全性被打破。
解決方案:
方案1:設(shè)想下,如果要避免上述情況,可以采用一個比較“土”的方法:自認(rèn)為持有鎖的客戶端在對敏感公共資源進(jìn)行寫操作前,先進(jìn)行校驗,確認(rèn)自己是否確實持有鎖,校驗的方式前面已經(jīng)介紹過——通過比較自己的 my_random_value 和 Redis 服務(wù)端中實際存儲的 my_random_value。
顯然,這里仍存在一個問題:如果校驗完畢后,Master 數(shù)據(jù)尚未同步到 Slave 的情況下 Master 宕機(jī),該如何是好?誠然,我們可以為 Redis 服務(wù)端設(shè)置較短的主從復(fù)置周期,以盡量避免上述情況出現(xiàn),但是,隱患還是客觀存在的。
方案2:多數(shù)派思想:針對問題場景,Redis 的作者 Antirez 提出了 RedLock,其原理基于分布式一致性算法的核心理念:多數(shù)派思想。