Redis 實現分佈式鎖(Node.js)

分佈式鎖

在 course-se 的提交服務中,爲了限制同一用戶在規定時間(5秒)內,沒法進行二次提交,開發人員實現了基於 Redis 的分佈式鎖。一般,咱們稱該業務場景爲節流(Throttle)。javascript

在閱讀此部分代碼時,我一開始尋思着徹底可使用一個 Map 維護各個用戶及其剩餘時間的關係,何須使用 Redis 。後來,通過了仔細思考,我忽略了服務端服務是以集羣(Cluster) 的形式進行部署的,同一個用戶的請求可能被轉發至不一樣的 Node 進程,所以咱們須要實現分佈式鎖服務,而不是進程內的鎖服務。java

爲了提供分佈式鎖服務,咱們須要確保如下幾點:node

  • 互斥訪問:有且僅有一個客戶端能獲取到鎖。
  • 避免死鎖:對於任意客戶端,其最終都能獲取到鎖,即便已拿到鎖的客戶端因各類問題而未解鎖。

單節點鎖

course-se 的現有實現,是創建 Redis 服務是單節點的前提上,咱們分別探討現有實現如何確保互斥訪問避免死鎖git

在實現中,咱們經過 Lock 類的 acquire 函數實現互斥操做,該函數調用了 getset 原子指令判斷當前 key 是否已被設置:若未被設置,則設置值,表示可獲取鎖;不然,表示鎖已被佔用,不可獲取鎖。在對應 key 對應的值爲空時,客戶端獲取鎖,並設置值的過時時間避免出現因客戶端未手動解鎖形成的死鎖問題,實現的核心代碼以下。github

async acquire() {
    const key = this.genKey();
    // 若對應值爲空,表示可獲取鎖,則設置該值,獲取鎖;
    // 若對應值不爲空,表示鎖已被佔用,不可獲取。
    const val = await this.tryExec('getset', key, '1');
   	// 設置值的過時時間,避免出現因客戶端未手動解鎖形成的死鎖問題。
    await this.tryExec('expire', key, this.expireAfter);
    return val === null;
}

genKey() {
    const { curUser: { user_id }, asgn: { asgn_id }, ce: { isExam } } = this.paramData;
    return `submit-asgn:${user_id}-${asgn_id}-${Number(isExam)}`;
}
複製代碼

其實,上述代碼是存在問題的,咱們假象如下的場景:現有服務節點 node1 和 node2 ,node1 接收了一個用戶請求,成功調用 getset 獲取到鎖,但在準備設置過時時間(假設5s)時,node1 意外退出了,隨後 node2 接收到一樣的用戶請求,在調用 getset 沒法獲取鎖後,其執行了 expire 命令,爲 node1 的鎖進行續期,若在到期時間內,該用戶一直髮送同一請求,則致使該鎖沒法被釋放,形成死鎖。redis

爲了解決上述的潛在問題,咱們須要使用 SET key value NX PX expireAfter 原子指令,替換上述 getsetexpire 命令組合,咱們可根據命令操做結果是否爲空來判斷鎖的佔用狀況算法

async acquire() {
    const key = this.genKey();
    // 若 ret 不爲空,表示已獲取鎖,不然表示鎖已被佔用。
    // px 參數保證了值在必定時間後會過時,避免了死鎖。
    const ret = await this.tryExec('set', key, '1', 'nx', 'px', this.expireAfter);
    return ret === null;
}
複製代碼

函數 release 可用於鎖的釋放,具體代碼以下。npm

async release() {
    return this.tryExec('del', this.genKey());
}
複製代碼

上述釋放鎖的代碼也是存在潛在問題的,設想這樣的場景:node1 已獲取到鎖(其過時時間爲 5s),但因爲各類緣由,node1 花了 6s 的時間才完成業務,那麼在第 5 秒時,鎖已過時,若此時 node2 獲取到了該鎖,則在第 6 秒時,node1 手動調用的 release 將會釋放 node2 獲取到的鎖,進而給其餘節點提供了獲取鎖的可能性。bash

解決上述問題的方法是:在釋放鎖以前,判斷該鎖是不是本身獲取的。至於具體的實現,咱們能夠在加鎖的時候,把當前節點的惟一標識符設置爲 key 對應的 value ,並在釋放鎖以前,檢查 key 對應的 value 是否爲本身的惟一標識符。異步

但在現有的 Redis 指令集中,咱們並不能實如今 getdel 的原子操做,所以咱們只能使用 Lua 腳本。

// 定義 Lua 腳本實現 get 和 del 的原子操做。
this.redis.defineCommand('lua_unlock', {
  numberOfKeys: 1,
  lua         : ` local remote_value = redis.call("get",KEYS[1]) if (not remote_value) then return 0 elseif (remote_value == ARGV[1]) then return redis.call("del",KEYS[1]) else return -1 end`
});
複製代碼

多節點鎖

對於單節點鎖的實現,若是 Redis 是以主從方式進行部署的,會發生錯誤,這是由於主從同步是異步的,當主庫發生異常時,從庫還未獲取到鎖的信息,則可能致使多個進程持有鎖,考慮如下場景:

  1. node1 在 Master 節點獲取到了鎖。
  2. Master 在 node1 建立的鎖寫入至 Slave 以前宕機了。
  3. 因爲 Sentinel 機制,Slave 變成了 Master 節點,此時 Slave 沒有 node1 持有鎖的信息。
  4. node2 在 Slave 節點獲取到了和 node1 還持有的相同的鎖。

針對此狀況,咱們可以使用 Redlock 算法,實現魯棒性更優的分佈式鎖。假設如今有 N 個 Redis Master 節點,節點與節點之間徹底獨立,沒有使用分佈式協調算法,在這種狀況下客戶端獲取鎖的流程以下:

  1. 客戶端獲取當前時間(單位是毫秒)。
  2. 客戶端輪流使用相同的 key 和隨機值在 N 個節點上請求鎖。在該步驟裏,客戶端在每一個 Master 上請求鎖時,會有一個和總的鎖釋放時間相比小得多的超時時間,如鎖自動釋放時間是10秒,則每一個節點鎖請求的超時時間在5-50毫秒的範圍內。經過這種方式,可防止一個客戶端在某個宕掉的 Master 節點上阻塞過長時間。
  3. 客戶端計算第二步所花的總時間,只有當客戶端在大多數 Master 節點上成功獲取了鎖(這裏是3個),且總消耗時間不超過鎖釋放時間,則表示該鎖獲取成功。
  4. 若客戶端成功得到鎖,則鎖的自動釋放時間爲最初的鎖施放時間減去第二步所消耗的時間。
  5. 若客戶端獲取鎖失敗,不論是由於在第二步中獲取的鎖的數目不超過一半(\frac{N}{2} + 1),仍是由於總消耗時間超過了鎖的釋放時間,客戶端都會到每一個 Master 節點釋放鎖,即使是那些它認爲沒有獲取成功的鎖。

初步來看,Redlock 算法流程仍是很清晰的,在實際生產中,咱們可以使用 redlock 包提供強魯棒的分佈式鎖服務。

npm install --save redlock
複製代碼

關於 redlock 的具體使用方法可查看其說明文檔

相關文章
相關標籤/搜索