綱要
文章目的:本文旨在提煉一套分布式幂等問題的思考架構,而非解決某個具體的分布式幂等問題。在這個架構體系内,會有一些方案舉例說明。
文章目标:希望讀者能通過這套思考架構設計出符合自己業務的完備的幂等解決方案。
文章内容:
(1)背景介紹,為什麼會有幂等。
(2)什麼是幂等,這個定義非常重要,決定了整個思考架構。
(3)解決幂等問題的三部曲,也是作者的思考架構。
(4)總結
一 背景
分布式系統由衆多微服務組成,微服務之間必然存在大量的網絡調用。下圖是一個服務間調用異常的例子,使用者送出訂單之後,請求到A服務,A服務落單之後,開始調用B服務,但是在A調用B的過程中,存在很多不确定性,例如B服務執行逾時了,RPC直接傳回A請求逾時了,然後A傳回給使用者一些錯誤提示,但實際情況是B有可能執行是成功的,隻是執行時間過長而已。

使用者看到錯誤提示之後,往往會選擇在界面上重複點選,導緻重複調用,如果B是個支付服務的話,使用者重複點選可能導緻同一個訂單被扣多次錢。不僅僅是使用者可能觸發重複調用,定時任務、消息投遞和機器重新啟動都可能會出現重複執行的情況。在分布式系統裡,服務調用出現各種異常的情況是很常見的,這些異常情況往往會使得系統間的狀态不一緻,是以需要容錯補償設計,最常見的方法就是調用方實作合理的重試政策,被調用方實作應對重試的幂等政策。
二 什麼是幂等
對于幂等,有一個很常見的描述是:對于相同的請求應該傳回相同的結果,是以查詢類接口是天然的幂等性接口。舉個例子:如果有一個查詢接口是查詢訂單的狀态,狀态是會随着時間發生變化的,那麼在兩次不同時間的查詢請求中,可能傳回不一樣的訂單狀态,這個查詢接口還是幂等接口嗎?
幂等的定義直接決定了我們如何去設計幂等方案,如果幂等的含義是相同請求傳回相同結果,那實際上隻需要緩存第一次的傳回結果,即可在後續重複請求時實作幂等了。但問題真的有這麼簡單嗎?
筆者更贊同這種定義:幂等指的是相同請求(identical request)執行一次或者多次所帶來的副作用(side-effects)是一樣的。
引自: https://developer.mozilla.org/en-US/docs/Glossary/Idempotent An HTTP method is idempotent if an identical request can be made once or several times in a row with the same effect while leaving the server in the same state. In other words, an idempotent method should not have any side-effects (except for keeping statistics).
這個定義有一定的抽象,概括性比較強,在設計幂等方案時,其實就是将抽象部分具化。例如:什麼是相同的請求?哪些情況會有副作用?該如何避免副作用?且看三部曲。
三 解決方案三部曲
不少關于幂等的文章都稱自己的方案是通用解決方案,但筆者卻認為,不同的業務場景下,相同請求和副作用都是有差異性的,不同的副作用需要不同的方案來解決,不存在完全通用的解決方案。而三部曲旨在提煉出一種思考模式,并舉例說明,在該思考模式下,更容易設計出符合業務場景的幂等解決方案。
第一部曲:識别相同請求
幂等是為了解決重複執行同一請求的問題,那如何識别一個請求有沒有和之前的請求重複呢?有的方案是通過請求中的某個流水号字段來識别的,同一個流水号表示同一個請求。也有的方案是通過請求中某幾個字段甚至全部字段進行比較,進而來識别是否為同一個請求。是以在方案設計時,明确定義具體業務場景下什麼是相同請求,這是第一部曲。
方案舉例:token機制識别前端重複請求
在一條調用鍊路的後端系統中,一般都可以通過上遊系統傳遞的reqNo+source來識别是否是為重複的請求。如下圖,B系統是依賴于A系統傳遞的reqNo+source來識别相同請求的,但是A系統是直接和前端頁面互動的系統,如何識别使用者發起的請求是相同的呢?比如使用者在支付界面上點選了多次,A系統怎麼識别這是一次重複操作呢?
前端可以在第一次點選完成時,将按鈕設定為disable,這樣使用者無法在界面上重複點選第二次,但這隻是提升體驗的前端解決方案,不是真正安全的解決方案。
常見的服務端解決方案是采用token機制來實作防重複送出。如下圖,
(1)當使用者進入到表單頁面的時候,前端會從服務端申請到一個token,并儲存在前端。
(2)當使用者第一次點選送出的時候,會将該token和表單資料一并送出到服務端,服務端判斷該token是否存在,如果存在則執行業務邏輯。
(3)當使用者第二次點選送出的時候,會将該token和表單資料一并送出到服務端,服務端判斷該token是否存在,如果不存在則傳回錯誤,前端顯示送出失敗。
這個方案結合前後端,從前端視角,這是用于防止重複請求,從服務端視角,這個用于識别前端相同請求。服務端往往基于類似于redis之類的分布式緩存來實作,保證生成token的唯一性和操作token時的原子性即可。核心邏輯如下。
// SETNX keyName value: 如果key存在,則傳回0,如果不存在,則傳回1
// step1. 申請token
String token = generateUniqueToken();
// step2. 校驗token是否存在
if(redis.setNx(token, 1) == 1){
// do business
} else {
// 幂等邏輯
}
第二部曲:列出并減少副作用的分析次元
相同的請求重複執行業務邏輯,如果處理不當,會給系統帶來副作用。那什麼是副作用?從技術的角度了解就是傳回結果後還導緻某些“系統狀态”發生變化,無副作用的函數稱之為純函數,展現到業務的角度就是業務無法接受的非預期結果。最常見的有重複入庫、資料被錯誤變更等,大多數幂等方案就是圍繞解決這類問題來設計的。而系統往往可能在多個次元都存在副作用,例如:
(1)調用下遊次元:重複調用下遊會怎樣?如果下遊沒有幂等,重複調用會帶來什麼副作用?
(2)傳回上遊次元:例如第一次傳回上遊異常,第二次傳回上遊被幂等了?會給上遊帶來什麼副作用?
(3)并發執行次元:并發重複執行會怎樣?會有什麼副作用?
(4)分布式鎖次元:引入分布式鎖來防止并發執行?但是如果鎖出現不一緻性,會有什麼副作用?
(5)互動時序次元:有沒有異步互動,是否存在時序問題?會有什麼副作用?
(6)客戶體驗次元:從資料不一緻到最終一緻,必須在多少時間内完成?如果該時間内沒有完成,會有什麼副作用?例如大量客訴(秉承客戶第一的原則,在支付寶,客訴量太大會定級為生産環境故障)。
(7)業務核對次元:重複調用是否存在覆寫核對辨別的情況,帶來無法正常核對的副作用?在金融系統中,資金鍊路無法核對是無法接受的。
(8)資料品質次元:是否存在重複記錄?如果存在會有什麼副作用?
上面是一些常見的分析次元,不同行業的系統中會存在不一樣的次元,盡可能地總結出這些次元,并列入系統分析時的checklist中,能夠更好地完善幂等解決方案。沒有副作用才算是完備的幂等解決方案,但是副作用的次元太多,會提高幂等方案的複雜度。是以在能夠達成業務的前提下,減少一些分析次元,能夠使得幂等方案實作起來更加經濟有效。例如:如果有專門的幂等表存儲傳回給上遊的幂等結果,第(2)次元不用考慮了,如果用鎖來防止并發,第(3)個次元不考慮了,如果用單機鎖代替分布式鎖,第(4)個次元不考慮了。
這是解決幂等問題的第二部曲:列出并減少副作用的分析次元。在這部曲中,涉及的解決方案往往是解決某一個次元的副作用問題,适合以通用元件的形式存在,作為團隊内部的一個公共技術套路。
方案舉例:加鎖避免并發重複執行
很多幂等解決方案都和防并發有關,那麼幂等和并發到底有什麼關聯呢?兩者的聯系是:幂等解決的是重複執行的問題,重複執行既有串行重複執行(例如定時任務),也有并發重複執行。如果重複執行的業務邏輯沒有共享變量和資料變更操作時,并發重複執行是沒有副作用的,可以不考慮并發的問題。對于包含共享變量、涉及變更操作的服務(實際上這類服務居多),并發問題可能導緻亂序讀寫共享變量,重複插入資料等問題。特别是并發讀寫共享變量,往往都是發生生産故障後才被感覺到。
是以在并發執行的次元,将并發重複執行變成串行重複執行是最好的幂等解決方案。支付寶最常見的方法就是:一鎖二判三更新,如下圖。當一個請求過來之後:一鎖,鎖住要操作的資源;二判,識别是否為重複請求(第一部曲要定義的問題)、判斷業務狀态是否正常;三更新:執行業務邏輯。
Q&A
小A:鎖可能造成性能影響,先判後鎖再執行,可以提升效能。
大明:這樣可能會失去防并發的效果。還記得double check實作單例模式嗎?在加鎖前判斷了下,那加鎖後為啥還要判斷下?實際上第二次check才是必須的。想想看?
小A畫圖思考中...
小A:明白了,一鎖二判三更新,鎖和判的順序是不能變的,如果鎖沖突比較高,可以在鎖之前判斷下,提高效率,是以稱之為double check。
大明:是的,聰明。這兩個場景不一樣,但并發思路是一樣的。
private volatile static Girl theOnlyGirl;
// 實作單例時做了 double check
public static Girl getTheOnlyGirl() {
if (theOnlyGirl == null) { // 加鎖前check
synchronized (Girl.class) {
if (theOnlyGirl == null) { // 加鎖後check
theOnlyGirl = new Girl(); // 變更執行
}
}
}
return theOnlyGirl;
}
鎖的實作可以是分布式鎖,也是可以是資料庫鎖。分布式鎖本身會帶來鎖的一緻性問題,需要根據業務對系統穩定性的要求來考量。支付寶的很多系統是通過在業務資料庫中建立一個鎖記錄表來實作業務鎖元件,其分表邏輯和業務表的分表邏輯一緻,就可以實作單機資料庫鎖。如果沒有鎖元件,悲觀鎖鎖住業務單據也是可以滿足條件的,悲觀鎖要在事務中用select for update來實作,要注意死鎖問題,且where條件中必須命中索引,否則會鎖表,不鎖記錄。
并發次元幾乎是一個分布式幂等的通用分析次元,是以一個通用的鎖元件是很有必要的。但這也隻是解決了并發這一個次元的副作用。雖然沒有了并發重複執行的情況,但串行重複執行的情況依舊存在,重複執行才是幂等核心要解決的問題,重複執行如果還存在其它副作用,幂等問題就是沒有解決掉。
加鎖後業務的性能會降低,這個怎麼解決?筆者認為,大多數情況下架構的穩定性比系統性能的優先級更高,況且對于性能的優化有太多地方可以去實作,減少壞代碼、去除慢SQL、優化業務架構、水準擴充資料庫資源等方式。通過系統壓測來實作一個滿足SLA的服務才是評估全鍊路性能的正确方法。
第三部曲:識别細粒度副作用,針對性設計解決方案
在解決了部分次元的副作用之後,就需要針對剩餘次元存在的細粒度副作用進行逐一識别并解決了。在資料品質次元上,最大的一個副作用是重複資料。在互動次元上,最大的一個副作用是業務亂序執行。一般這類問題不設計成通用元件,可以開發人員自由發揮。本節用兩個常見方案做為例子。
方案舉例1:唯一性限制避免重複落庫
在資料表設計時,設計兩個字段:source、reqNo,source表示調用方,seqNo表示調用方發送過來的請求号。source和reqNo設定為組合唯一索引,保證單據不會重複落兩次。如果調用方沒有source和reqNo這兩個字段,可以根據業務實際情況将請求中的某幾個業務參數生成一個md5作為唯一性字段落到唯一性字段中來避免重複落庫。
核心邏輯如下:
try {
dao.insert(entity);
// do business
} catch (DuplicateKeyException e) {
dao.select(param);
// 幂等傳回
}
這裡直接insert單據,若果成功則表示沒請求過,舉行執行業務邏輯,如果抛出DuplicateKeyException異常,則表示已經執行過,做幂等傳回,簡單的服務通過這種方式也可以識别是否為重複請求(第一部曲)。
利用資料庫唯一索引來避免重複記錄,需要注意以下幾個問題:
(1)因為存在讀寫分離的設計,有可能insert操作的是主庫,但select查詢的卻是從庫,如果主備同步不及時,有可能select查出來也是空的。
(2)在資料庫有Failover機制的情況下,如果一個城市出現自然災害,很可能切換到另外一個城市的備用庫,那麼唯一性限制可能就會出現失效的情況,比如并發場景下第一次insert是在杭州的庫,然後此時failover将庫切到上海了,再一次同樣的請求insert也是成功的。
(3)資料庫擴容場景下,因為分庫規則發生變化,有可能第一次insert操作是在A庫,第二次insert操作是在B庫,唯一索引同樣不起作用。
(4)有的系統catch的是SQLIntegrityConstraintViolationException,這個是完整性限制,包含了唯一性限制,如果未給一個必填字段設值,也會抛這個異常,是以應該catch鍵重複異常DuplicateKeyException。
對于第(1)個問題,将insert 和select放在同一個事務中即可解決,對于(2)和(3),支付寶内部為了應對容量暴漲和FO,設計了一套基于資料複制技術的分布式資料平台,這個case筆者了解不深,後續有機會再讨論。
小A:如果我用唯一性限制來保證不會落重複資料,是不是可以不加鎖防并發了?
大明:兩者沒有直接關系,加鎖防并發解決的是并發次元的副作用問題,唯一性限制隻是解決重複資料這單個副作用的問題。如果沒有唯一性限制,串行重複執行也會導緻insert重複落資料的問題,唯一性限制本質上解決的是重複資料問題,不是并發問題。
方案舉例2:狀态機限制解決亂序問題
一個業務的生命周期往往存在不同的狀态,用狀态機來控制業務流程中的狀态轉換是不二之選。在實際業務中單向的狀态機是比較常用的,當狀态機處于下一個狀态時,是不能回到前面的狀态的。以下場景經常會用到狀态機做校驗:
(1)調用方調用逾時重試。
(2)消息投遞逾時重試。
(3)業務系統發起多個任務,但是期待按照發起順序有序傳回。
對于這種類問題,一般是在處理前先判斷狀态是否符合預期,如果符合預期再執行業務。當業務執行完成後,變更狀态時還會采取類似于于樂觀鎖的方式兜底校驗,例如,M狀态隻能從N狀态轉換而來,那麼更新單據時,會在sql中做狀态校驗。
update apply set status = 'M' where status = 'N'
如果狀态被設計成可逆的,就有可能産生ABA問題。即在update之前,狀态有可能做過這樣的變更:N -> M -> N。是以狀态機設成單向流轉是比較合理的。
四 總結
本文首先引出了幂等的定義:相同請求無副作用,然後提出了設計幂等方案的三部曲,并舉例說明。設計者要能夠清晰地定義相同請求,并且采用通用元件減少一些副作用的分析次元,再針對具體的副作用設計相應的解決方案,直至沒有任何副作用,才是真正完備的幂等解決方案。在實際業務中,實作三部曲不一定是嚴格的先後順序,但隻要按照這三部曲來構思方案,必能開拓思路,化繁為簡。