綱要
文章目的:本文旨在提煉一套分散式冪等問題的思考框架,而非解決某個具體的分散式冪等問題。在這個框架體系內,會有一些方案舉例說明。
文章目標:希望讀者能通過這套思考框架設計出符合自己業務的完備的冪等解決方案。
文章內容:
(1)背景介紹,為什麼會有冪等。
(2)什麼是冪等,這個定義非常重要,決定了整個思考框架。
(3)解決冪等問題的三部曲,也是作者的思考框架。
(4)總結
一 背景
分散式系統由眾多微服務組成,微服務之間必然存在大量的網路呼叫。下圖是一個服務間呼叫異常的例子,使用者提交訂單之後,請求到A服務,A服務落單之後,開始呼叫B服務,但是在A呼叫B的過程中,存在很多不確定性,例如B服務執行超時了,RPC直接返回A請求超時了,然後A返回給使用者一些錯誤提示,但實際情況是B有可能執行是成功的,只是執行時間過長而已。
使用者看到錯誤提示之後,往往會選擇在介面上重複點選,導致重複呼叫,如果B是個支付服務的話,使用者重複點選可能導致同一個訂單被扣多次錢。不僅僅是使用者可能觸發重複呼叫,定時任務、訊息投遞和機器重新啟動都可能會出現重複執行的情況。在分散式系統裡,服務呼叫出現各種異常的情況是很常見的,這些異常情況往往會使得系統間的狀態不一致,所以需要容錯補償設計,最常見的方法就是呼叫方實現合理的重試策略,被呼叫方實現應對重試的冪等策略。
二 什麼是冪等
對於冪等,有一個很常見的描述是:對於相同的請求應該返回相同的結果,所以查詢類介面是天然的冪等性介面。舉個例子:如果有一個查詢介面是查詢訂單的狀態,狀態是會隨著時間發生變化的,那麼在兩次不同時間的查詢請求中,可能返回不一樣的訂單狀態,這個查詢介面還是冪等介面嗎?
冪等的定義直接決定了我們如何去設計冪等方案,如果冪等的含義是相同請求返回相同結果,那實際上只需要快取第一次的返回結果,即可在後續重複請求時實現冪等了。但問題真的有這麼簡單嗎?
筆者更贊同這種定義:冪等指的是相同請求(identical request)執行一次或者多次所帶來的副作用(side-effects)是一樣的。
引自:https://developer.mozilla.org...
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。所以狀態機設成單向流轉是比較合理的。
四 總結
本文首先引出了冪等的定義:相同請求無副作用,然後提出了設計冪等方案的三部曲,並舉例說明。設計者要能夠清晰地定義相同請求,並且採用通用元件減少一些副作用的分析維度,再針對具體的副作用設計相應的解決方案,直至沒有任何副作用,才是真正完備的冪等解決方案。在實際業務中,實現三部曲不一定是嚴格的先後順序,但只要按照這三部曲來構思方案,必能開拓思路,化繁為簡。
公眾號簡介:作者是螞蟻金服的一線開發,分享自己的成長和思考之路。內容涉及資料、工程、演算法。
注:轉載請註明出處。本文提到的分散式鎖、業務鎖,悲觀鎖和樂觀鎖的選型,以及基於鎖的冪等元件的實現,將另起文章介紹,若感興趣可以關注公眾號,歡迎交流。