redis分佈式鎖3種實現方式對比分析

大家春節在家搶紅包玩的不亦樂乎,搶紅包服務邏輯看起來非常簡單,實際上要做好這個服務有很多細節考慮,特別是money相關服務是不允許出錯的,每個紅包的數字都是真金白銀,要求服務的魯棒性非常高,背後包含着很多後臺服務技術細節。

拋磚引玉,今天就來說說其中一個技術細節,這也是在我在之前的文章中提到,但沒展開講的內容:高併發編程中redis分佈式鎖實現

這裏羅列出3種redis實現的分佈式鎖,並分別對比說明各自特點。


Redis單實例分佈式鎖

實現一:SETNX實現的分佈式鎖

setnx用法參考redis官方文檔

語法

SETNX key value

key設置值爲value,如果key不存在,這種情況下等同SET命令。當key存在時,什麼也不做。SETNX是”SET if Not eXists”的簡寫。

返回值:

  • 1 設置key成功

  • 0 設置key失敗

加鎖步驟

  1. SETNX lock.foo <current Unix time + lock timeout + 1>

    如果客戶端獲得鎖,SETNX返回1,加鎖成功。

    如果SETNX返回0,那麼該鍵已經被其他的客戶端鎖定。

  2. 接上一步,SETNX返回0加鎖失敗,此時,調用GET lock.foo獲取時間戳檢查該鎖是否已經過期:

  • 若舊的時間戳已過期,則表示加鎖成功。

  • 若舊的時間戳還未過期(說明被其他客戶端搶去並設置了時間戳),代表加鎖失敗,需要等待重試。

  • 如果沒有過期,則休眠一會重試。

  • 如果已經過期,則可以獲取該鎖。具體的:調用GETSET lock.foo <current Unix timestamp + lock timeout + 1>基於當前時間設置新的過期時間。

    注意: 這裏設置的時候因爲在SETNXGETSET之間有個窗口期,在這期間鎖可能已被其他客戶端搶去,所以這裏需要判斷GETSET的返回值,他的返回值是SET之前舊的時間戳:

解鎖步驟

解鎖相對簡單,只需GET lock.foo時間戳,判斷是否過期,過期就調用刪除DEL lock.foo


實現二:SET實現的分佈式鎖

set用法參考官方文檔

語法

SET key value [EX seconds|PX milliseconds] [NX|XX]

將鍵key設定爲指定的“字符串”值。如果 key 已經保存了一個值,那麼這個操作會直接覆蓋原來的值,並且忽略原始類型。當set命令執行成功之後,之前設置的過期時間都將失效。

從2.6.12版本開始,redis爲SET命令增加了一系列選項:

  • EX seconds – Set the specified expire time, in seconds.

  • PX milliseconds – Set the specified expire time, in milliseconds.

  • NX – Only set the key if it does not already exist.

  • XX – Only set the key if it already exist.

  • EX seconds – 設置鍵key的過期時間,單位時秒

  • PX milliseconds – 設置鍵key的過期時間,單位是毫秒

  • NX – 只有鍵key不存在的時候纔會設置key的值

  • XX – 只有鍵key存在的時候纔會設置key的值

版本>= 6.0

  • KEEPTTL -- 保持 key 之前的有效時間TTL

加鎖步驟

一條命令即可加鎖: SET resource_name my_random_value NX PX 30000

The command will set the key only if it does not already exist (NX option), with an expire of 30000 milliseconds (PX option). The key is set to a value “myrandomvalue”. This value must be unique across all clients and all lock requests.

這個命令只有當key 對應的鍵不存在resource_name時(NX選項的作用)才生效,同時設置30000毫秒的超時,成功設置其值爲my_random_value,這是個在所有redis客戶端加鎖請求中全局唯一的隨機值。

解鎖步驟

解鎖時需要確保my_random_value和加鎖的時候一致。下面的Lua腳本可以完成

if redis.call("get",KEYS[1]) == ARGV[1] then
  return redis.call("del",KEYS[1])
else
  return 0
end

這段Lua腳本在執行的時候要把前面的my_random_value作爲ARGV[1]的值傳進去,把resource_name作爲KEYS[1]的值傳進去。釋放鎖其實包含三步操作:’GET’、判斷和’DEL’,用Lua腳本來實現能保證這三步的原子性。


Redis集羣分佈式鎖


實現三:Redlock

前面兩種分佈式鎖的實現都是針對單redis master實例,既不是有互爲備份的slave節點也不是多master集羣,如果是redis集羣,每個redis master節點都是獨立存儲,這種場景用前面兩種加鎖策略有鎖的安全性問題。

比如下面這種場景:

  1. 客戶端1從Master獲取了鎖。

  2. Master宕機了,存儲鎖的key還沒有來得及同步到Slave上。

  3. Slave升級爲Master。

  4. 客戶端2從新的Master獲取到了對應同一個資源的鎖。

於是,客戶端1和客戶端2同時持有了同一個資源的鎖。鎖的安全性被打破。

針對這種多redis服務實例的場景,redis作者antirez設計了Redlock (Distributed locks with Redis)算法,就是我們接下來介紹的。

加鎖步驟

集羣加鎖的總體思想是嘗試鎖住所有節點,當有一半以上節點被鎖住就代表加鎖成功。集羣部署你的數據可能保存在任何一個redis服務節點上,一旦加鎖必須確保集羣內任意節點被鎖住,否則也就失去了加鎖的意義。

具體的:

  1. 獲取當前時間(毫秒數)。

  2. 按順序依次向N個Redis節點執行獲取鎖的操作。這個獲取操作跟前面基於單Redis節點的獲取鎖的過程相同,包含隨機字符串my_random_value,也包含過期時間(比如PX 30000,即鎖的有效時間)。爲了保證在某個Redis節點不可用的時候算法能夠繼續運行,這個獲取鎖的操作還有一個超時時間(time out),它要遠小於鎖的有效時間(幾十毫秒量級)。客戶端在向某個Redis節點獲取鎖失敗以後,應該立即嘗試下一個Redis節點。這裏的失敗,應該包含任何類型的失敗,比如該Redis節點不可用,或者該Redis節點上的鎖已經被其它客戶端持有(注:Redlock原文中這裏只提到了Redis節點不可用的情況,但也應該包含其它的失敗情況)。

  3. 計算整個獲取鎖的過程總共消耗了多長時間,計算方法是用當前時間減去第1步記錄的時間。如果客戶端從大多數Redis節點(>= N/2+1)成功獲取到了鎖,並且獲取鎖總共消耗的時間沒有超過鎖的有效時間(lock validity time),那麼這時客戶端才認爲最終獲取鎖成功;否則,認爲最終獲取鎖失敗。

  4. 如果最終獲取鎖成功了,那麼這個鎖的有效時間應該重新計算,它等於最初的鎖的有效時間減去第3步計算出來的獲取鎖消耗的時間。

  5. 如果最終獲取鎖失敗了(可能由於獲取到鎖的Redis節點個數少於N/2+1,或者整個獲取鎖的過程消耗的時間超過了鎖的最初有效時間),那麼客戶端應該立即向所有Redis節點發起釋放鎖的操作(即前面介紹的Redis Lua腳本)。

解鎖步驟

客戶端向所有Redis節點發起釋放鎖的操作,不管這些節點當時在獲取鎖的時候成功與否。

算法實現

上面描述的算法已經有現成的實現,各種語言版本。

  • Redlock-rb (Ruby implementation). There is also a fork of Redlock-rb that adds a gem for easy distribution and perhaps more.

  • Redlock-py (Python implementation).

  • Aioredlock (Asyncio Python implementation).

  • Redlock-php (PHP implementation).

  • PHPRedisMutex (further PHP implementation)

  • cheprasov/php-redis-lock (PHP library for locks)

  • Redsync (Go implementation).

  • Redisson (Java implementation).

  • Redis::DistLock (Perl implementation).

  • Redlock-cpp (C++ implementation).

  • Redlock-cs (C#/.NET implementation).

  • RedLock.net (C#/.NET implementation). Includes async and lock extension support.

  • ScarletLock (C# .NET implementation with configurable datastore)

  • Redlock4Net (C# .NET implementation)

  • node-redlock (NodeJS implementation). Includes support for lock extension.

比如我用的C++實現

源碼在這 https://github.com/jacket-code/redlock-cpp

創建分佈式鎖管理類CRedLock

CRedLock * dlm = new CRedLock();
dlm->AddServerUrl("127.0.0.1", 5005);
dlm->AddServerUrl("127.0.0.1", 5006);
dlm->AddServerUrl("127.0.0.1", 5007);

加鎖並設置超時時間

CLock my_lock;
bool flag = dlm->Lock("my_resource_name", 1000, my_lock);

加鎖並保持直到釋放

CLock my_lock;
bool flag = dlm->ContinueLock("my_resource_name", 1000, my_lock);

my_resource_name是加鎖標識;1000是鎖的有效期,單位毫秒。

加鎖失敗返回false, 加鎖成功返回Lock結構如下

class CLock {
public:
   int m_validityTime; => 9897.3020019531 // 當前鎖可以存活的時間, 毫秒
   sds m_resource; => my_resource_name // 要鎖住的資源名稱
   sds m_val; => 53771bfa1e775 // 鎖住資源的進程隨機名字
};

解鎖

dlm->Unlock(my_lock);


總結

綜上所述,三種實現方式。

  • 單redis實例場景,分佈式鎖實現一和實現二都可以,實現二更簡潔推薦用實現二,用實現三也可以,但是實現三對單實例場景有點複雜略顯笨重。

  • 多redis實例場景推薦用實現三最安全,不過實現三也不是完美無瑕,也有針對這種算法缺陷的討論(節點宕機同步時延、時間同步假設),大家還需要根據自身業務場景靈活選擇或定製實現自己的分佈式鎖。


參考

https://redis.io/topics/distlock

https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

http://zhangtielei.com/posts/blog-redlock-reasoning.html

本文分享自微信公衆號 - 後端技術學堂(lemon10240)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章