基于 Redis 的分布式鎖實現(xiàn)

1. 前言

關(guān)于分布式鎖的實現(xiàn),目前常用的方案有以下三類:

  1. 數(shù)據(jù)庫樂觀鎖;
  2. 基于分布式緩存實現(xiàn)的鎖服務(wù),典型代表有 Redis 和基于 Redis 的 RedLock;
  3. 基于分布式一致性算法實現(xiàn)的鎖服務(wù),典型代表有 ZooKeeper、Chubby 和 ETCD。

關(guān)于 Redis 實現(xiàn)分布式鎖,網(wǎng)上可以查到很多資料,筆者最初也借鑒了這些資料,但是,在分布式鎖的實現(xiàn)和使用過程中意識到這些資料普遍存在問題,容易誤導(dǎo)初學(xué)者,鑒于此,撰寫本文,希望為對分布式鎖感興趣的讀者提供一篇切實可用的參考文檔。

本場 Chat 將介紹以下內(nèi)容:

  1. 分布式鎖原理介紹;
  2. 基于 Redis 實現(xiàn)的分布式鎖的安全性分析
  3. 加鎖的正確方式及典型錯誤案例分析;
  4. 解鎖的正確方式及典型錯誤案例分析。

2. 分布式鎖原理介紹

2.1 分布式鎖基本約束條件

為了確保鎖服務(wù)可用,通常,分布式鎖需同時滿足以下四個約束條件:

  1. 互斥性:在任意時刻,只有一個客戶端能持有鎖;
  2. 安全性:即不會形成死鎖,當(dāng)一個客戶端在持有鎖的期間崩潰而沒有主動解鎖的情況下,其持有的鎖也能夠被正確釋放,并保證后續(xù)其它客戶端能加鎖;
  3. 可用性:就 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ù);
  4. 對稱性:對于任意一個鎖,其加鎖和解鎖必須是同一個客戶端,即,客戶端 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)如下:

enter image description here

2.3 加解鎖流程

基于 Redis 官方的文檔,對于一個嘗試獲取鎖的操作,流程如下:

步驟 1:向 Redis 節(jié)點(diǎn)發(fā)送命令,請求鎖:
SET lock_name my_random_value NX PX 30000

其中:

  1. lock_name:即鎖名稱,這個名稱應(yīng)是公開的,在分布式環(huán)境中,對于某一確定的公共資源,所有爭用方(客戶端)都應(yīng)該知道對應(yīng)鎖的名字。對于 Redis 而言,lock_name 就是 key-value 中的 key,具有唯一性。
  2. my_random_value 是由客戶端生成的一個隨機(jī)字符串,它要保證在足夠長的一段時間內(nèi)在所有客戶端的所有獲取鎖的請求中都是唯一的,用于唯一標(biāo)識鎖的持有者。
  3. NX 表示只有當(dāng) lock_name(key) 不存在的時候才能 SET 成功,從而保證只有一個客戶端能獲得鎖,而其它客戶端在鎖被釋放之前都無法獲得鎖。
  4. 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è)置自動過期時間,雖然可以解決死鎖的問題,但卻存在隱患.

典型場景:

  1. 客戶端 A 獲取鎖成功
  2. 客戶端 A 在某個操作上阻塞了很長時間(對于 Java 而言,如發(fā)生 full-GC)
  3. 過期時間到,鎖自動釋放
  4. 客戶端 B 獲取到了對應(yīng)同一個資源的鎖
  5. 客戶端 A 從阻塞中恢復(fù)過來,認(rèn)為自己依舊持有鎖,繼續(xù)操作同一個資源,導(dǎo)致互斥性失效

解決方案:

  1. 存在隱患的方案:第 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)上很多資料都采用了這種方案,鑒于其隱患,不推薦。
  2. 可取的方案:既然比較時間不可取,那么,還可以比較 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 是兩個操作,非原子性,那么解鎖本身也會存在破壞互斥性的可能。

典型場景:

  1. 客戶端 A 獲取鎖成功。
  2. 客戶端 A 訪問共享資源。
  3. 客戶端 A 為了釋放鎖,先執(zhí)行 GET 操作獲取鎖對應(yīng)的隨機(jī)字符串的值。
  4. 客戶端 A 判斷隨機(jī)字符串的值,與預(yù)期的值相等。
  5. 客戶端 A 由于某個原因阻塞住了很長時間。
  6. 過期時間到了,鎖自動釋放了。
  7. 客戶端 B 獲取到了對應(yīng)同一個資源的鎖。
  8. 客戶端 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ī)切換過程中喪失鎖的安全性。

典型場景:

  1. 客戶端 A 從 Master 獲取了鎖。
  2. Master 宕機(jī)了,存儲鎖的key還沒有來得及同步到 Slave 上。
  3. Slave 升級為 Master。
  4. 客戶端 B 從新的 Master 獲取到了對應(yīng)同一個資源的鎖。
  5. 客戶端 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ù)派思想。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容