1. 程式人生 > >教你用 redis 實現分散式冪等服務中介軟體

教你用 redis 實現分散式冪等服務中介軟體

背景

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

在分散式系統裡,client 呼叫 server 提供的服務,由於網路環境的複雜性,呼叫可能有以下幾種情況:

server 收到 client 的請求,client 也收到 server 的響應結果

client 發出了請求,但 server 未收到,可能是 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 來標識:bool withdraw(id, account_id, amount),server 針對一個 id 的相同請求只執行一次,這樣就可以避免上述的問題了。此時扣款服務也是冪等的了。

實現方案

按照上面介紹的冪等的扣款服務的實現思路,抽象出一個通用的中間層,非冪等的服務要改造成冪等的,只需要增加一個額外的 id 引數。服務實現裡先根據此 id 去中間層查詢服務是否執行過,根據查詢結果決定的是否繼續後續的業務流程。中間層相當於一個特殊的分散式互斥鎖,根據 id 查詢的過程相當於對某把鎖嘗試加鎖的操作。鎖被鎖住後永遠不釋放(除非鎖過期了,這裡為了敘述方便簡單認為永遠不釋放)。鎖被一個程序鎖住後其他程序都無法再加鎖,這樣就保證了服務是冪等的了。

第一個對互斥鎖加鎖的程序任務沒有執行完就掛掉,鎖又是不會釋放的,其他程序又無法重複加鎖,導致這個失敗的任務也不能被其他程序重新執行。為了避免這種情況,將加鎖的操作分成 2 步:

TryAcquire

嘗試獲取鎖,結果有兩種情況:

1.1 拿到了鎖(鎖轉到 TryAcquired 狀態),這時候可以執行正常的業務流程,執行完了需要再呼叫第二步 Confirm 明確鎖已被鎖住(鎖轉到 Confirmed 狀態),這之後其他程序都拿不到這把鎖;

1.2 沒拿到鎖,可能是以下三種情況之一:

1.2.1 鎖處於 Confirmed 狀態,這種情況不應該繼續業務流程處理直接返回;

1.2.2 鎖處於 TryAcquired 狀態,但超時時間沒到,說明這個時候有其他程序拿到了鎖正在進行相應的業務流程,本程序不應該執行相應的業務流程直接返回;

1.2.3 鎖處於 TryAcquired 狀態,但超時時間到了,說明已有其他程序拿到了鎖,但很久沒有 Confirm ,有可能是執行過程中掛掉了,這時候本程序應該要執行相應的業務流程,然後呼叫第二步 Confirm 。

Confirm

將鎖置成 Confirmed 狀態,表示互斥鎖被永久鎖住。

鎖的狀態轉換如下所示(expire 為 redis key 過期):

 

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

0:初始狀態* -1:Confirmed 狀態

其他值:TryAcquired 狀態,value 為業務執行截止時間 deadline

server 在增加了保證冪等性的流程圖如下(交易表示既定的業務執行流程):

 

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

TryAcqurie 和 Confirm 實現用偽碼描述如下:

 

id 的取值

id 由 client 根據具體的業務場景決定,可以本地生成或者是從第三方服務獲取,要求需要保證能唯一標識某個業務下的一次交易。server 端將此 id 視為互斥鎖的唯一標識。

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 的。在此我向大家推薦一個架構學習交流裙。交流學習裙號:821169538,裡面會分享一些資深架構師錄製的視訊錄影

下面說明下為什麼 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 到了後,其他程序仍然可以拿到鎖並執行交易,這時候也不能保證冪等。

缺陷的本質是這個輕量級的解決方案無法保證分散式事務的原子性。