如何使用 redis 實現分佈式冪等服務中間件

背景

在編程領域,冪等性是指對同一個系統,使用一樣的條件,一次請求和重複的屢次請求對系統資源的影響是一致的。redis

在分佈式系統裏,服務一般經過 RPC 或 HTTP 或其餘形式對外提供。無論怎樣,client 調用 server 服務都是將調用數據按特定協議封裝好,而後經過網絡發送給 server,server 將須要返回的數據一樣按特定協議封裝而後經過網絡發送給 client。因爲網絡環境的複雜性,client 在發起調用時,數據可能在到 server 鏈路中丟失,也可能在從 server 返回的鏈路中丟失。無論哪一種狀況,對 client 來講都是調用失敗。一般 client 會發起一次重試,若是是後者,那 server 就會收到屢次徹底同樣的請求。若是 server 的服務不是冪等的話,就可能出現問題。編程

典型的例子是銀行扣款服務,用函數表示爲 bool withdraw(account_id, amount) ,client 發起一次調用 withdraw(1001, 10) 請求從賬戶 1001 中扣除 10 元,若是發生了上圖所示的第 2 種錯誤,這時候 server 端在賬戶裏已經完成了扣款,但 client 並不知道,若是重試調用 withdraw(1001, 10) ,server 端又會從 賬戶 1001 扣除 10 元,顯然這並非 client 想要的。若是將 client 的此次扣款操做和後續的重試用一個統一的 id 來標識,server 針對一個 id 的相同請求只執行一次,這樣就能夠避免上述的問題了。也就是說扣款服務是冪等的。網絡

爲了方便 server 將服務實現成冪等的,本文介紹了一種使用 redis 實現的分佈式中間件方案。從上面的例子中能夠看出,實現冪等服務 client 除了服務正常的參數外還須要傳一個額外的 id 。這個 id 一般由 client 根據具體的業務場景決定,要求至少能保證一段時間內不會重複。架構

實現方案

實際上至關於實現一個特殊的分佈式互斥鎖,一把鎖只能被一個進程鎖一次,永遠不釋放(除非鎖過時了,默認過時時間1天,這裏爲了敘述方便簡單認爲永遠不釋放)。分佈式

一把互斥鎖被一個進程加鎖後其餘進程都拿不到鎖,經過這種方式實現冪等性。函數

第一個拿到互斥鎖的進程任務沒有執行完就掛掉,鎖又是不會釋放的,其餘進程也拿不到鎖,致使這個失敗的任務也不能被其餘進程從新執行。 爲了不這種狀況,將加鎖的操做分紅 2 步:學習

  1. TryAcquire 
    兩種狀況:
    • 1.1 拿到了鎖(鎖轉到 TryAcquired 狀態),這時候能夠執行正常的業務流程,執行完了須要再調用第二步 Confirm 明確鎖已被鎖住(鎖轉到 Confirmed 狀態),這以後其餘進程都拿不到這把鎖;
    • 1.2 沒拿到鎖,又分爲三種狀況:
      • 1.2.1 鎖處於 Comfirmed 狀態,這種狀況不該該繼續業務流程處理直接返回;
      • 1.2.2 鎖處於 TryAcquired 狀態,但超時時間沒到,說明這個時候有其餘進程拿到了鎖正在進行相應的業務流程,本進程不該該執行相應的業務流程直接返回;
      • 1.2.3 鎖處於 TryAcquired 狀態,但超時時間到了,說明已有其餘進程拿到了鎖,但好久沒有 Confirm ,有多是執行過程當中掛掉了,這時候本進程應該要執行相應的業務流程,而後調用第二步 Confirm 。
  2. Confirm 
    將鎖置成 Confirmed 狀態,表示互斥鎖被永久鎖住。

鎖的狀態轉換以下所示(expire 爲 redis key 過時):在此我向你們推薦一個架構學習交流羣。交流學習羣號:821169538 ui

使用 Redis 實現,key 爲互斥鎖的標識,value 爲鎖的狀態:spa

  • 0:初始狀態* -1:Confirmed 狀態
  • 其餘值:TryAcquired 狀態,value 爲業務執行截止時間 deadline

server 在增長了保證冪等性的流程圖以下(交易表示既定的業務執行流程):操作系統

省略了 redis 錯誤處理的分支,redis 錯誤 TryAcquire 直接返回 true 。

TryAcqurie 和 Comfirm 實現用僞碼描述以下:

// return value:
// true 能夠繼續業務流程,業務流程處理完後須要調用 Confirm
// false 不能繼續業務流程
TryAcquire(id, timeout) {
    reply = SET id (now+timeout) EX 86400 NX
    // 1.1
    if reply == 1 { 
        return true
    }
    // 1.2
    reply = GET id
    // 1.2.1
    if reply == Confirmed {
        return false
    }
    // 1.2.2
    if now < reply {
        return false
    }
    // 1.2.3
    delta = now + timeout - reply 
    new_reply = INCRBY id delta
    if new_reply == reply + delta {
        return true
    } else {
        DECRBY id delta 
    }
    return false
}

Comfirm(id) {
    SET id -1 XX
}

timeout 的設置

timeout 應該比正常的交易時間大,不然會致使多個進程都能拿到鎖不能保證冪等。可是又不能設得太大,不然會致使交易執行失敗時要過好久才能從新執行交易。

原子性保證

TryAcquire 和 Confirm 都應該保證原子性,Confirm 只有一個簡單的 SET 操做,這個沒有問題。TryAcquire 實際上分紅兩步:1.1 SETNX 和 1.2 GET&SET(不是 redis 是 GETSET 命令)。 上面的僞碼中 1.2 GET&SET 的 SET 換成了 INCRBY 並增長了一次返回值比較,至關於樂觀鎖的實現,因此 GET&SET 的原子性是 OK 的。

下面說明下爲何 1.1 和 1.2 整個過程沒有保證原子性也是 OK 的:

最壞的狀況下假設進程 a 進入 TryAcquire 執行完了 1.1 而後被操做系統調度出去了,此時進程 b 進入 TryAcquire 執行了整個流程拿到了鎖,而後執行了一次交易。這時候進程 a 從新被調度執行,這個時候因爲進程 b 更新了 deadline 甚至執行完了 Confirm,進程 a 會在 1.2.1 或 1.2.2 處退出而且不會執行交易,若是走到了 1.2.3 而且拿到了鎖說明進程 b 執行交易時掛掉了,這時由進程 a 從新執行交易也是正確的邏輯。

方案的缺陷

這個方案忽略了 redis 異常狀況,這種狀況下 TryAcquire 老是返回 true ,可能會使交易重複執行不能保證冪等。也能夠將 redis 異常返回給調用者,由調用者根據業務場景來決定是否須要從新執行交易。

另一種狀況進程經過 TryAcquire 拿到鎖後執行完了交易,但 Confirm 失敗(掛掉或者網絡問題),這種狀況在 dealine 到了後,其餘進程仍然能夠拿到鎖並執行交易,這時候也不能保證冪等。

相關文章
相關標籤/搜索