冪等性原理與實踐
一、冪等的概念
概念源自百度百科:
冪等(idempotent、idempotence)是一個數學與計算機學概念,常見於抽象代數中。
在程式設計中一個冪等操作的特點是其任意多次執行所產生的影響均與一次執行的影響相同。冪等函式,或冪等方法,是指可以使用相同引數重複執行,並能獲得相同結果的函式。這些函式不會影響系統狀態,也不用擔心重複執行會對系統造成改變。例如,“setTrue()”函式就是一個冪等函式,無論多次執行,其結果都是一樣的。更復雜的操作冪等保證是利用唯一交易號(流水號)實現。
在程式設計中對冪等概念的疑問:函式的一次執行,因為系統內部出現了臨時性異常(外部呼叫網路超時等),返回了錯誤結果,那麼是不是後面的多次執行都該返回同樣的異常?
顯然這並不是我們想要的冪等。因此我將冪等定義為:一個函式在正確執行的情況下,執行多次的結果和執行一次的結果一致。
什麼情況下需要處理冪等
- 前端的表單重複提交;
- 無線端網路抖動的多次提交;
- 系統間呼叫的超時重試;
- 分散式呼叫的最終一致性:一次請求呼叫了多個系統,部分成功,部分失敗,需要重試,等等。
二、併發和冪等的誤區
舉個例子
併發:服務的一次會話還未結束又來一個新會話的場景,多次會話引數相同;
例子:一次使用者表單提交,因為點的快或網路抖動,近乎同時連續提交多次;
冪等:服務的多次會話分多次請求到伺服器,多次會話引數相同;
例子:一次使用者表單提交,使用者開了兩個視窗,間隔兩個小時分別提交;
我們會注意到併發也是冪等裡邊的一種啊,為什麼要做區分?我們得從併發的處理方式上來講了。我們知道併發一般的處理是加全域性鎖,我們不妨假設系統提供的服務是單執行緒的,那麼還需要處理併發麼?顯然不需要了。此時我們就得去處理冪等的問題,即多次請求進來後怎麼保證執行結果唯一。然而好多研發同學對這種表單重複提交的,做一個併發鎖,認為就是做了冪等,這個理解是錯誤的。
再次強調下,一定注意冪等要處理的問題是多次執行的唯一性,並不是對併發的控制。
三、冪等處理的場景
我們將冪等處理的場景分為大任務和小任務兩種場景:
3.1 小任務場景
這個場景的處理方式很簡單,可以追求強一致性。在冪等函式中,先判斷冪等記錄是否存在。如果存在,直接返回;如果不存在,開啟一個事務。在事務中,處理業務邏輯,並插入冪等記錄。
3.2 大任務場景
在大任務場景下,需要將大任務拆成多個小任務分別執行。在每個小任務中,都可以有事務,每個小任務使用同樣的冪等Key,或者任務名 + 冪等Key。但是,沒有事務能夠保證所有的小任務同時成功。因此,存在部分成功的場景。針對部分成功的場景,可以利用重試機制做到最終一致性。大任務場景最多見的就是一次請求呼叫了多個系統,分散式強一致性的解決方案一般都很重,對一致性實時性要求不是很高的系統,一般我們都採用重試做到最終一致性。
大任務場景又分為同步執行小任務和非同步執行小任務兩種:
- 同步執行小任務:各小任務依次執行,中間的小任務不返回結果,僅在最後一個小任務或之後返回結果;
- 非同步執行小任務:各小任務單獨執行,互相不感知,也沒有地方返回結果。
四、冪等處理的實現
4.1 冪等鍵的設計及實現
- UUID 或 時間戳:對於任何請求,均可使用。但是需要每次請求都維護該值和具體業務的對應關係,會比較繁瑣;
- 唯一業務單據號:對於只請求一次且需要確保成功的場景,比如使用者支付的交易單號。呼叫方使用該冪等鍵確保一次請求的唯一,被調方使用該冪等鍵確保服務的唯一;
- 唯一業務單據號 + 狀態:對於一個單據請求多次的場景,比如使用者訂單號,下單時需要用該單號佔庫存,出庫時需要用該單號扣庫存;
- 單據號 + 使用者ID + 裝置ID:對於多人作業系統中,一個單據多個人可以同時作業,採用這些組合資訊做為冪等鍵來唯一標識一次請求;
從上邊這些舉例可以看出,冪等鍵實際上就是找到某個或某幾個業務欄位來唯一標識一次請求。對於正常的單據系統唯一業務鍵比較好找,但是對於某些實操系統,實在是找不出這種可以唯一標識的欄位怎麼辦?那就採用UUID、時間戳等方式去做。這種非業務冪等鍵的方式,一定要確保冪等鍵和請求的對應關係做到持久化,不然冪等的校驗就無從實現。同時這種方式也就意味著會有額外的儲存成本和處理成本。
4.2 冪等表資料的儲存設計
一次請求進來,需要對請求的引數以及系統處理的結果做持久化,一般一張冪等資料儲存表需要有如上引數,具體參見表格備註。不同系統可根據不同需求使用 Redis/Tair 或 DB 實現。
- 如果使用 DB,也可拆分成多張表儲存,把重試所需引數等資訊單獨儲存;
- 如果簡單使用Tair的Key/Value實現的話,需保證冪等Key和引數MD5是存在的,其他引數可選,但這樣會丟失一些功能,比如重試的引數中哪個欄位不一樣無法校驗。比如實操系統中前端獲取要重試的冪等Key,沒有辦法拿到,後面會重點講到;
4.3 一般冪等處理的標準流程
總結:
- 很清晰地區分了併發、冪等的處理,邏輯處理清晰不混亂;
- 適用於大、小任務場景,大任務的併發場景也可以很好的支援;
業務失敗場景的考慮:
- 不管業務執行成功與否,都記錄冪等記錄,與此對應的,請求再次進來會去判斷如果是已經執行失敗的請求,則會去重試,從而保證最終一致性;如果確實是因為引數等問題失敗,需要更換引數,那呼叫方就需要更換冪等鍵重試,比如訂單下發,倉庫物理庫存不足,使用者就需要取消訂單重新下,一次冪等鍵的更換就是一次新的請求,一次冪等鍵的更換一定意味著一次新的業務語義。
- 常見的一種實現是當業務失敗時不記錄冪等記錄,那麼就需要確保所有的失敗業務全部回滾,對於分散式的呼叫會比較繁瑣,也難以保證全部回滾,故此捨棄這種實現。
- 對於實操系統或無業務語義的請求,業務邏輯處理失敗時,需要考慮區分需重試異常和其他異常,因為對於這種系統來說,前端的一次錄入,引數錯了就錯了,重新換一個就是。不像訂單下發那樣,引數錯了需要取消訂單再重新下。後面會重點講。
大家可以參照上圖一個大任務場景,對照著上邊的處理流程去推演各種場景
- 同步小任務的場景下:ABC三個系統均使用類似的處理流程,因是同步小任務,所以A可以總控BC是否失敗,A也可以在重試時直接校驗兩次引數是否一致,並保證BC不會有不同的結果,此時BC的實現就可以簡單點了;
- 非同步小任務的場景下:A無法控制BC是否失敗,但是BC可以自己用這套流程來保證,在請求重試時,各自保證重試引數的一致性,從而保證各自系統執行結果的唯一性;
4.4 反例:一種沒有分清併發和冪等的實現
因為兩種概念混淆,統一用redis實現,加鎖的Key也是冪等Key,使用Redis的Value值來區分是併發鎖還是冪等命中,導致整個處理流程特別複雜,還沒有很好的重試校驗機制,是一種典型的沒有分清併發和冪等的概念的設計實現,值得參考。
如上,對於正常的系統間呼叫,這些冪等的知識和設計實現已經足夠了,但是在實操系統或跟前端互動頻繁的系統中,在具體的實現中還是會有很多問題,下面會繼續深入探討實操系統中冪等實現的大小坑。
五、冪等在實操系統中的實踐
先科普下什麼是實操系統:允許單人多次執行 或 多人同時執行某種單據的系統。比如WMS系統裡的揀貨,一個揀貨單,需要掃碼槍一件件商品掃過去,哪怕是同樣的商品也是要一件件掃;或者說一個揀貨單,可以逐件掃幾件,輸數量幾件,再逐件掃幾件等,隨心所欲,想怎麼玩怎麼玩。
那麼實操系統的難點在哪裡?
- 冪等鍵不好確定:一個員工,一個揀貨單,10件A商品,需要掃描10次,每次請求都是同樣的引數,用什麼來唯一識別?
- 跟前端的互動很複雜:冪等鍵誰來控制?後端生成還是前端生成?什麼時候要更換冪等鍵,後端決定還是前端決定?
- 處理失敗了無法保證前端重試:前端頁面具有瞬時性,即使用者錄入資料沒有持久化,使用者一旦退出頁面再進來,操作的引數也隨之丟失,無法保證使用者重試直到成功。不像系統間呼叫,服務方一個函式失敗,呼叫方根據自己儲存的資料再次重試直到成功即可。
- 前端實操的隨意性:可能就是因為使用者隨意的操作,引數校驗失敗了,此時不能像系統間呼叫籠統的讓上游重試或換單,需要做更細粒度的區分,哪些異常必須重試,哪些異常不用重試可以直接忽略;
基於這幾個難點問題,我們分別去討論。
5.1 實操系統中冪等的處理流程
對於難點4,我們對之前的標準流程做了點改造,增加了業務失敗場景的處理。對於大任務場景下的部分成功,必須要再發起重試以保證最終一致性。對於小任務場景下的全部失敗,或者一些引數校驗異常壓根沒有執行具體業務邏輯的場景,可以直接忽略此次處理,不用儲存本次冪等執行記錄。
這麼做的原因前邊也提到了,實操場景不同於系統間呼叫,系統間一次呼叫失敗,要麼呼叫方一直重試,要麼更換冪等鍵重試,比如訂單下發的場景,失敗了上游系統一直重試提交訂單,如果要修改請求引數,也只能是使用者前臺取消訂單並重新下新的訂單,也即新的訂單請求。實操系統中,使用者前端輸入的數量很隨意,失敗了就重新輸入數量重試,有時大任務場景的部分失敗,必須要讓使用者前端重試,而有的小任務場景或引數校驗等異常,則可以忽略,使用者可以繼續下一步。將不同的異常場景做區分,以應對難點4中使用者操作的隨意性。
5.2 冪等鍵該由前端還是後端生成
要討論清楚到底是哪一端生成,首先就需要清楚後端會有哪些返回結果。一般一次請求,後端的返回分為如下幾種:
- 成功:實操可繼續下一步
- 失敗
- 需重試異常:如前邊講的部分成功,部分失敗場景,一定得重試,從而保證最終一致性
- 其他異常:其他異常可重試也可不重試,可根據具體業務來,未改變系統狀態
- 無返回:一般指超時,需要重試,因無法確定後端情況
針對不同的返回,我們分別討論下前後端生成冪等鍵在處理上會有什麼區別
I. 前端生成冪等鍵
- 成功:前端可進行下一步操作,並自行更換冪等鍵;
- 失敗:前端需要根據後臺返回的不同錯誤碼或錯誤型別決定是否要更換冪等key和是否重試
- 需重試異常:前端不可更換冪等鍵,並在前端確保使用者重試直到成功,如若退出,再進來前端冪等鍵則會丟失,導致資料異常;
- 其他異常:前端可不用更換冪等鍵,繼續下一步;
- 無返回:即超時,前端不換冪等鍵,並在前端確保使用者重試直到成功,如果退出,再進來前端冪等鍵則會丟失,有可能後端部分失敗,導致資料異常;
II. 後端生成冪等鍵
- 成功:後端更換冪等鍵,前端拿到後直接進行下一步操作;
- 失敗:後端根據自身的業務異常決定是否要更換冪等鍵;
- 需重試異常:後端業務部分成功部分失敗,需要重試來解決。不更換冪等鍵,前端需提示使用者重試,使用者可退出,退出再進來後端會返回這次部分失敗的冪等鍵及資訊,前端提示使用者繼續操作;
- 其他異常:後端所有業務處理都失敗,更換冪等鍵,繼續下一步;
- 無返回:即超時,前端還是使用原來的冪等鍵,並提示使用者重試,如果退出再進來,如果後端已經成功或完全失敗則會新生成冪等鍵,如果後端部分成功,即需要重試,則會返回部分失敗的冪等鍵及資訊,前端提示使用者繼續操作;
通過如上的說明已經很清晰了,我們做一個比較發現,在失敗的場景下,很明顯後端的這些業務邏輯不應該讓前端感知,哪天后端的錯誤型別或錯誤碼變了,前端就需要跟著改,並且由前端強制使用者一直重試,體驗太差,不可取。故此,冪等鍵還是後端生成更為合理。
關於冪等鍵是前端還是後端生成,我們和前端同學爭(si)論(bi)了好久,到今天也會看到前端同學曾經因為妥協,在底層為所有請求加了一個引數idempotentKey,每次請求都會自動生成一個新的。但是經過我們的對比分析,我們得出結論,實操系統的冪等鍵從後端獲取比較合理,並且,後端獲取冪等鍵的處理流程也可以如冪等函式的處理邏輯一樣標準化。那對於難點1也就有了結論,後端生成更為合理。
5.3 獲取冪等key的標準流程
I. 後端系統的標準返回
基於上邊的討論,我們可以確定和前端互動的標準報文如上所示。
- 成功:就是簡單的頁面資料返回和下一步的新冪等Key。
- 失敗:通過冪等型別欄位,將失敗分為了重試異常和其他異常,重試異常需用頁面原有引數讓使用者重試,其他異常可直接忽略,使用新的冪等Key繼續下一步操作。
- 獲取冪等鍵:返回值增加了兩個引數
- 冪等型別:如果根據前端業務上下文找到有需重試的冪等記錄,則返回retry,否則就是新的冪等鍵;
- 冪等引數:如果是retry型別的冪等鍵,前端需根據上次的引數渲染頁面,讓使用者直接重試即可。
II. 後端獲取冪等Key的一個標準實現
這裡的冪等上下文,即前邊4.2章節中講的`biz_info`,一次請求的冪等,肯定有對應的業務單據或Task等資訊,我們需要根據這些資訊找到對應的執行記錄,並查詢是否有未成功的冪等記錄,如果有則返回對應的冪等Key和上次的請求引數,供前端重試;如果沒有則生成新的冪等Key。
這樣我們也就解決了難點3前端無法保證使用者重試的問題,通過請求引數的持久化,可以讓使用者不管什麼時候進來都可以重試。
5.4 實操系統冪等鍵的設計
對於難點1如何確定冪等鍵,我們分別從非業務冪等鍵、業務冪等鍵兩種型別進行討論。
第一種:非業務冪等鍵
使用UUID、時間戳等其他唯一欄位。如5.1節討論,這種非業務冪等鍵,必須保證有持久化,每次的任務執行都有記錄,這樣來保證請求和冪等鍵的關係有跡可循,對複雜系統間最終一致性的實現也提供了基礎。
其實這種做法對於實操系統來講最合適不過了,因為實操系統沒有辦法找到一個唯一的業務鍵來標識每一次請求,所以採用系統生成唯一標識的方法來做,將該唯一標識和系統的每一次請求繫結,並持久化,從而完成對每次請求的標識。如5.3節中討論,前端頁面每次進來時都會請求冪等鍵,如果有失敗記錄我們就會返回該記錄讓前端重試,此時我們可以把實操的步驟看做流水線,一個環節都不能跳過,從而保證系統的絕對正確。
第二種:業務冪等鍵
對於實操系統,其實經過分析還是可以找出業務冪等鍵的,比如使用 單據id + 已錄數量 + 本次錄入數量 做組合冪等鍵,來唯一標識一次實操請求。這樣,前端就不用每次生成冪等鍵,等請求到達後端,由後端系統自己去獲取自己需要的引數拼接成冪等鍵。對於失敗重試的情況,只要前端的引數命中後端的組合冪等鍵,就能達到重試的目的。
表格中是實操系統中幾種組合冪等鍵,其中已錄數量、錄入數量這兩個值可以很好的從業務角度區分出每次的請求。其他的欄位可以根據不同的實操場景來增加,只要能唯一標識出這次的請求即可。
同樣的,如果使用業務冪等鍵,我們也分析下後端返回的情況:
- 成功:前端直接進行下一步操作,無需關心冪等鍵,因為冪等鍵就在操作的引數裡;
- 失敗:不用區分重試或不重試異常,因為區分了也沒有用。前端可提示使用者重試,但無法保證使用者一定重試。使用者可退出重進,是否重試完全取決於使用者錄入的引數,引數和之前一樣,則會觸發重試;
- 無返回:前端可提示使用者重試,使用者可退出再進,只要再進來錄入引數和之前一樣,即會觸發重試;
對於重試的場景,舉一個極端例子,比如超市收銀系統,一件商品買了10件,可以前邊逐件掃描3件,後面掃描一件輸入數量3,再掃一件輸入數量2,再掃逐件掃2件。此時比如輸數量3的操作,大任務場景部分失敗(比如庫存扣減成功,但是賬本新增失敗了),但後面的數量錄入時,引數沒有和之前3件重複的,那也就相當於前端沒有發起重試,可能會導致資料的最終不一致。
當然我們可以像非業務冪等處理那樣去處理可重試異常,頁面剛進來時獲取需重試的冪等引數,實際請求執行時對需重試的請求做記錄等等,但是這樣的話要業務冪等鍵的意義就不大了,我不用去費工夫想如何找到業務冪等鍵,而是簡單生成一個UUID就可以解決問題。
最後一個難點,實操系統的冪等鍵該如何設計,有了上邊的比較,如果可以簡單使用業務冪等鍵最好,如果某些場景不滿足,使用非業務冪等鍵也不失為一個更好的選擇。