幂等性问题剖析
所謂冪等,簡單地說,就是對接口的多次調用所產生的結果和調用一次是一致的。擴展一下,這里的接口,可以理解為對外發布的HTTP接口或者Thrift接口,也可以是接收消息的內部接口,甚至是一個內部方法或操作。
數學上的定義:f(f(x)) = f(x)。x被函數f作用一次和作用無限次的結果是一樣的。冪等性應用在軟件系統中,我把它簡單定義為:某個函數或者某個接口使用相同參數調用一次或者無限次,其造成的后果是一樣的,在實際應用中一般針對于接口進行冪等性設計。舉個栗子,在系統中,調用方A調用系統B的接口進行用戶的扣費操作時,由于網絡不穩定,A重試了N次該請求,那么不管B是否接收到多少次請求,都應該保證只會扣除該用戶一次費用。
那么我們為什么需要接口具有冪等性呢?設想一下以下情形:
在App中下訂單的時候,點擊確認之后,沒反應,就又點擊了幾次。在這種情況下,如果無法保證該接口的冪等性,那么將會出現重復下單問題。
在接收消息的時候,消息推送重復。如果處理消息的接口無法保證冪等,那么重復消費消息產生的影響可能會非常大。
在分布式環境中,網絡環境更加復雜,因前端操作抖動、網絡故障、消息重復、響應速度慢等原因,對接口的重復調用概率會比集中式環境下更大,尤其是重復消息在分布式環境中很難避免。Tyler Treat也在《You Cannot Have Exactly-Once Delivery》一文中提到:
Within the context of a distributed system, you cannot have exactly-once message delivery.
分布式環境中,有些接口是天然保證冪等性的,如查詢操作。有些對數據的修改是一個常量,并且無其他記錄和操作,那也可以說是具有冪等性的。其他情況下,所有涉及對數據的修改、狀態的變更就都有必要防止重復性操作的發生。通過間接的實現接口的冪等性來防止重復操作所帶來的影響,成為了一種有效的解決方案。
GTIS#
GTIS就是這樣的一個解決方案。它是一個輕量的重復操作關卡系統,它能夠確保在分布式環境中操作的唯一性。我們可以用它來間接保證每個操作的冪等性。它具有如下特點:
高效:低延時,單個方法平均響應時間在2ms內,幾乎不會對業務造成影響;
可靠:提供降級策略,以應對外部存儲引擎故障所造成的影響;提供應用鑒權,提供集群配置自定義,降低不同業務之間的干擾;
簡單:接入簡捷方便,學習成本低。只需簡單的配置,在代碼中進行兩個方法的調用即可完成所有的接入工作;
靈活:提供多種接口參數、使用策略,以滿足不同的業務需求。
實現原理#
基本原理
GTIS的實現思路是將每一個不同的業務操作賦予其唯一性。這個唯一性是通過對不同操作所對應的唯一的內容特性生成一個唯一的全局ID來實現的。基本原則為:相同的操作生成相同的全局ID;不同的操作生成不同的全局ID。
生成的全局ID需要存儲在外部存儲引擎中,數據庫、Redis亦或是Tair等均可實現。考慮到Tair天生分布式和持久化的優勢,目前的GTIS存儲在Tair中。其相應的key和value如下:
key:將對于不同的業務,采用APP_KEY+業務操作內容特性生成一個唯一標識trans_contents。然后對唯一標識進行加密生成全局ID作為Key。
value:current_timestamp + trans_contents,current_timestamp用于標識當前的操作線程。
判斷是否重復,主要利用Tair的SETNX方法,如果原來沒有值則set且返回成功,如果已經有值則返回失敗。
內部流程
GTIS的內部實現流程為:
業務方在業務操作之前,生成一個能夠唯一標識該操作的transContents,傳入GTIS;
GTIS根據傳入的transContents,用MD5生成全局ID;
GTIS將全局ID作為key,current_timestamp+transContents作為value放入Tair進行setNx,將結果返回給業務方;
業務方根據返回結果確定能否開始進行業務操作;
若能,開始進行操作;若不能,則結束當前操作;
業務方將操作結果和請求結果傳入GTIS,系統進行一次請求結果的檢驗;
若該次操作成功,GTIS根據key取出value值,跟傳入的返回結果進行比對,如果兩者相等,則將該全局ID的過期時間改為較長時間;
GTIS返回最終結果。
實現難點
GTIS的實現難點在于如何保證其判斷重復的可靠性。由于分布式環境的復雜度和業務操作的不確定性,在上一章節分布式鎖的實現中考慮的網絡斷開或主機宕機等問題,同樣需要在GTIS中設法解決。這里列出幾個典型的場景:
如果操作執行失敗,理想的情況應該是另一個相同的操作可以立即進行。因此,需要對業務方的操作結果進行判斷,如果操作失敗,那么就需要立即刪除該全局ID;
如果操作超時或主機宕機,當前的操作無法告知GTIS操作是否成功。那么我們必須引入超時機制,一旦長時間獲取不到業務方的操作反饋,那么也需要該全局ID失效;
結合上兩個場景,既然全局ID會失效并且可能會被刪除,那就需要保證刪除的不是另一個相同操作的全局ID。這就需要將特殊的標識記錄下來,并由此來判斷。這里所用的標識為當前時間戳。
可以看到,解決這些問題的思路,也和上一章節中的實現有很多類似的地方。除此以外,還有更多的場景需要考慮和解決,所有分支流程如下:
使用說明#
使用時,業務方只需要在操作的前后調用GTIS的前置方法和后置方法,如下圖所示。如果前置方法返回可進行操作,則說明此時無重復操作,可以進行。否則則直接結束操作。
使用方需要考慮的主要是下面兩個參數:
空間全局性:業務方輸入的能夠標志操作唯一性的內容特性,可以是唯一性的String類型的ID,也可以是map、POJO等形式。如訂單ID等
時間全局性:確定在多長時間內不允許重復,1小時內還是一個月內亦或是永久。
此外,GTIS還提供了不同的故障處理策略和重試機制,以此來降低外部存儲引擎異常對系統造成的影響。
目前,GTIS已經持續迭代了7個版本,距離第一個版本有近1年之久,先后在美團點評多個項目中穩定運行。
回到頂部
結語#
在分布式環境中,操作互斥性問題和冪等性問題非常普遍。經過分析,我們找出了解決這兩個問題的基本思路和實現原理,給出了具體的解決方案。
針對操作互斥性問題,常見的做法便是通過分布式鎖來處理對共享資源的搶占。分布式鎖的實現,很大程度借鑒了多線程和多進程環境中的互斥鎖的實現原理。只要滿足一些存儲方面的基本條件,并且能夠解決如網絡斷開等異常情況,那么就可以實現一個分布式鎖。目前已經有基于Zookeeper和Redis等存儲引擎的比較典型的分布式鎖實現。但是由于單存儲引擎的局限,我們開發了基于ZooKeeper和Tair的多引擎分布式鎖Cerberus,它具有使用靈活方便等諸多優點,還提供了完善的一鍵降級方案。
針對操作冪等性問題,我們可以通過防止重復操作來間接的實現接口的冪等性。GTIS提供了一套可靠的解決方法:依賴于存儲引擎,通過對不同操作所對應的唯一的內容特性生成一個唯一的全局ID來防止操作重復。
目前Cerberus分布式鎖、GTIS都已應用在生產環境并平穩運行。兩者提供的解決方案已經能夠解決大多數分布式環境中的操作互斥性和冪等性的問題。值得一提的是,分布式鎖和GTIS都不是萬能的,它們對外部存儲系統的強依賴使得在環境不那么穩定的情況下,對可靠性會造成一定的影響。在并發量過高的情況下,如果不能很好的控制鎖的粒度,那么使用分布式鎖也是不太合適的。總的來說,分布式環境下的業務場景紛繁復雜,要解決互斥性和冪等性問題還需要結合當前系統架構、業務需求和未來演進綜合考慮。Cerberus分布式鎖和GTIS也會持續不斷地迭代更新,提供更多的引擎選擇、更高效可靠的實現方式、更簡捷的接入流程,以期滿足更復雜的使用場景和業務需求。
WEB資源或API方法的冪等性是指一次和多次請求某一個資源應該具有同樣的副作用。冪等性是系統的接口對外一種承諾(而不是實現), 承諾只要調用接口成功, 外部多次調用對系統的影響是一致的。冪等性是分布式系統設計中的一個重要概念,對超時處理、系統恢復等具有重要意義。聲明為冪等的接口會認為外部調用失敗是常態, 并且失敗之后必然會有重試。例如,在因網絡中斷等原因導致請求方未能收到請求返回值的情況下,如果該資源具備冪等性,請求方只需要重新請求即可,而無需擔心重復調用會產生錯誤。實際上,我們常用的HTTP協議的方法是具有冪等性語義要求的,比如:get方法用于獲取資源,不應有副作用,因此是冪等的;post方法用于創建資源,每次請求都會產生新的資源,因此不具備冪等性;put方法用于更新資源,是冪等的;delete方法用于刪除資源,也是冪等的。
回到頂部
常見用來保證冪等的手段:#
1.MVCC方案
多版本并發控制,該策略主要使用update with condition(更新帶條件來防止)來保證多次外部請求調用對系統的影響是一致的。在系統設計的過程中,合理的使用樂觀鎖,通過version或者updateTime(timestamp)等其他條件,來做樂觀鎖的判斷條件,這樣保證更新操作即使在并發的情況下,也不會有太大的問題。例如
1
2
select * from tablename where condition=#condition# //取出要跟新的對象,帶有版本versoin
update tableName set name=#name#,version=version+1 where version=#version#
在更新的過程中利用version來防止,其他操作對對象的并發更新,導致更新丟失。為了避免失敗,通常需要一定的重試機制。
2.去重表
在插入數據的時候,插入去重表,利用數據庫的唯一索引特性,保證唯一的邏輯。
3.悲觀鎖
select for update,整個執行過程中鎖定該訂單對應的記錄。注意:這種在DB讀大于寫的情況下盡量少用。
并發不高的后臺系統,或者一些任務JOB,為了支持冪等,支持重復執行,簡單的處理方法是,先查詢下一些關鍵數據,判斷是否已經執行過,在進行業務處理,就可以了。注意:核心高并發流程不要用這種方法。
5.狀態機冪等
在設計單據相關的業務,或者是任務相關的業務,肯定會涉及到狀態機,就是業務單據上面有個狀態,狀態在不同的情況下會發生變更,一般情況下存在有限狀態機,這時候,如果狀態機已經處于下一個狀態,這時候來了一個上一個狀態的變更,理論上是不能夠變更的,這樣的話,保證了有限狀態機的冪等。
業務要求:頁面的數據只能被點擊提交一次
發生原因:由于重復點擊或者網絡重發,或者nginx重發等情況會導致數據被重復提交
解決辦法:
集群環境:采用token加redis(redis單線程的,處理需要排隊)
單JVM環境:采用token加redis或token加jvm內存
處理流程:
數據提交前要向服務的申請token,token放到redis或jvm內存,token有效時間
提交后后臺校驗token,同時刪除token,生成新的token返回
token特點:要申請,一次有效性,可以限流
如銀聯提供的付款接口:需要接入商戶提交付款請求時附帶:source來源,seq序列號。source+seq在數據庫里面做唯一索引,防止多次付款,(并發時,只能處理一個請求)
總結: 冪等性應該是合格程序員的一個基因,在設計系統時,是首要考慮的問題,尤其是在像支付寶,銀行,互聯網金融公司等涉及的都是錢的系統,既要高效,數據也要準確,所以不能出現多扣款,多打款等問題,這樣會很難處理,用戶體驗也不好 。
這里需要關注幾個重點:
冪等不僅僅只是一次(或多次)請求對資源沒有副作用(比如查詢數據庫操作,沒有增刪改,因此沒有對數據庫有任何影響)。
冪等還包括第一次請求的時候對資源產生了副作用,但是以后的多次請求都不會再對資源產生副作用。
冪等關注的是以后的多次請求是否對資源產生的副作用,而不關注結果。
網絡超時等問題,不是冪等的討論范圍。
冪等性是系統服務對外一種承諾(而不是實現),承諾只要調用接口成功,外部多次調用對系統的影響是一致的。聲明為冪等的服務會認為外部調用失敗是常態,并且失敗之后必然會有重試。
回到頂部
什么情況下需要冪等#
業務開發中,經常會遇到重復提交的情況,無論是由于網絡問題無法收到請求結果而重新發起請求,或是前端的操作抖動而造成重復提交情況。 在交易系統,支付系統這種重復提交造成的問題有尤其明顯,比如:
用戶在APP上連續點擊了多次提交訂單,后臺應該只產生一個訂單;
向支付寶發起支付請求,由于網絡問題或系統BUG重發,支付寶應該只扣一次錢。 很顯然,聲明冪等的服務認為,外部調用者會存在多次調用的情況,為了防止外部多次調用對系統數據狀態的發生多次改變,將服務設計成冪等。
回到頂部
冪等VS防重#
上面例子中小明遇到的問題,只是重復提交的情況,和服務冪等的初衷是不同的。重復提交是在第一次請求已經成功的情況下,人為的進行多次操作,導致不滿足冪等要求的服務多次改變狀態。而冪等更多使用的情況是第一次請求不知道結果(比如超時)或者失敗的異常情況下,發起多次請求,目的是多次確認第一次請求成功,卻不會因多次請求而出現多次的狀態變化。
回到頂部
什么情況下需要保證冪等性#
以SQL為例,有下面三種場景,只有第三種場景需要開發人員使用其他策略保證冪等性:
SELECT col1 FROM tab1 WHER col2=2,無論執行多少次都不會改變狀態,是天然的冪等。
UPDATE tab1 SET col1=1 WHERE col2=2,無論執行成功多少次狀態都是一致的,因此也是冪等操作。
UPDATE tab1 SET col1=col1+1 WHERE col2=2,每次執行的結果都會發生變化,這種不是冪等的。
回到頂部
為什么要設計冪等性的服務#
冪等可以使得客戶端邏輯處理變得簡單,但是卻以服務邏輯變得復雜為代價。滿足冪等服務的需要在邏輯中至少包含兩點:
首先去查詢上一次的執行狀態,如果沒有則認為是第一次請求
在服務改變狀態的業務邏輯前,保證防重復提交的邏輯
回到頂部
冪等的不足#
冪等是為了簡化客戶端邏輯處理,卻增加了服務提供者的邏輯和成本,是否有必要,需要根據具體場景具體分析,因此除了業務上的特殊要求外,盡量不提供冪等的接口。
增加了額外控制冪等的業務邏輯,復雜化了業務功能;
把并行執行的功能改為串行執行,降低了執行效率。
回到頂部
保證冪等策略#
冪等需要通過唯一的業務單號來保證。也就是說相同的業務單號,認為是同一筆業務。使用這個唯一的業務單號來確保,后面多次的相同的業務單號的處理邏輯和執行效果是一致的。 下面以支付為例,在不考慮并發的情況下,實現冪等很簡單:①先查詢一下訂單是否已經支付過,②如果已經支付過,則返回支付成功;如果沒有支付,進行支付流程,修改訂單狀態為‘已支付’。
回到頂部
防重復提交策略#
上述的保證冪等方案是分成兩步的,第②步依賴第①步的查詢結果,無法保證原子性的。在高并發下就會出現下面的情況:第二次請求在第一次請求第②步訂單狀態還沒有修改為‘已支付狀態’的情況下到來。既然得出了這個結論,余下的問題也就變得簡單:把查詢和變更狀態操作加鎖,將并行操作改為串行操作。
樂觀鎖#
如果只是更新已有的數據,沒有必要對業務進行加鎖,設計表結構時使用樂觀鎖,一般通過version來做樂觀鎖,這樣既能保證執行效率,又能保證冪等。例如: UPDATE tab1 SET col1=1,version=version+1 WHERE version=#version# 不過,樂觀鎖存在失效的情況,就是常說的ABA問題,不過如果version版本一直是自增的就不會出現ABA的情況。(從網上找了一張圖片很能說明樂觀鎖,引用過來,出自Mybatis對樂觀鎖的支持)
防重表#
使用訂單號orderNo做為去重表的唯一索引,每次請求都根據訂單號向去重表中插入一條數據。第一次請求查詢訂單支付狀態,當然訂單沒有支付,進行支付操作,無論成功與否,執行完后更新訂單狀態為成功或失敗,刪除去重表中的數據。后續的訂單因為表中唯一索引而插入失敗,則返回操作失敗,直到第一次的請求完成(成功或失敗)。可以看出防重表作用是加鎖的功能。
分布式鎖#
這里使用的防重表可以使用分布式鎖代替,比如Redis。訂單發起支付請求,支付系統會去Redis緩存中查詢是否存在該訂單號的Key,如果不存在,則向Redis增加Key為訂單號。查詢訂單支付已經支付,如果沒有則進行支付,支付完成后刪除該訂單號的Key。通過Redis做到了分布式鎖,只有這次訂單訂單支付請求完成,下次請求才能進來。相比去重表,將放并發做到了緩存中,較為高效。思路相同,同一時間只能完成一次支付請求。
token令牌#
這種方式分成兩個階段:申請token階段和支付階段。 第一階段,在進入到提交訂單頁面之前,需要訂單系統根據用戶信息向支付系統發起一次申請token的請求,支付系統將token保存到Redis緩存中,為第二階段支付使用。 第二階段,訂單系統拿著申請到的token發起支付請求,支付系統會檢查Redis中是否存在該token,如果存在,表示第一次發起支付請求,刪除緩存中token后開始支付邏輯處理;如果緩存中不存在,表示非法請求。 實際上這里的token是一個信物,支付系統根據token確認,你是你媽的孩子。不足是需要系統間交互兩次,流程較上述方法復雜。
支付緩沖區#
把訂單的支付請求都快速地接下來,一個快速接單的緩沖管道。后續使用異步任務處理管道中的數據,過濾掉重復的待支付訂單。優點是同步轉異步,高吞吐。不足是不能及時地返回支付結果,需要后續監聽支付結果的異步返回。
現如今我們的系統大多拆分為分布式SOA,或者微服務,一套系統中包含了多個子系統服務,而一個子系統服務往往會去調用另一個服務,而服務調用服務無非就是使用RPC通信或者restful,既然是通信,那么就有可能再服務器處理完畢后返回結果的時候掛掉,這個時候用戶端發現很久沒有反應,那么就會多次點擊按鈕,這樣請求有多次,那么處理數據的結果是否要統一呢?那是肯定的!尤其再支付場景。
冪等性:就是用戶對于同一操作發起的一次請求或者多次請求的結果是一致的,不會因為多次點擊而產生了副作用。舉個最簡單的例子,那就是支付,用戶購買商品使用約支付,支付扣款成功,但是返回結果的時候網絡異常,此時錢已經扣了,用戶再次點擊按鈕,此時會進行第二次扣款,返回結果成功,用戶查詢余額返發現多扣錢了,流水記錄也變成了兩條...
在以前的單應用系統中,我們只需要把數據操作放入事務中即可,發生錯誤立即回滾,但是再響應客戶端的時候也有可能出現網絡中斷或者異常等等。
在增刪改查4個操作中,尤為注意就是增加或者修改,
查詢對于結果是不會有改變的,
刪除只會進行一次,用戶多次點擊產生的結果一樣
修改在大多場景下結果一樣
增加在重復提交的場景下會出現
那么如何設計接口才能做到冪等呢?
方法一、單次支付請求,也就是直接支付了,不需要額外的數據庫操作了,這個時候發起異步請求創建一個唯一的ticketId,就是門票,這張門票只能使用一次就作廢,具體步驟如下:
異步請求獲取門票
調用支付,傳入門票
根據門票ID查詢此次操作是否存在,如果存在則表示該操作已經執行過,直接返回結果;如果不存在,支付扣款,保存結果
返回結果到客戶端
如果步驟4通信失敗,用戶再次發起請求,那么最終結果還是一樣的
方法二、分布式環境下各個服務相互調用
這邊就要舉例我們的系統了,我們支付的時候先要扣款,然后更新訂單,這個地方就涉及到了訂單服務以及支付服務了。
用戶調用支付,扣款成功后,更新對應訂單狀態,然后再保存流水。
而在這個地方就沒必要使用門票ticketId了,因為會比較閑的麻煩
(支付狀態:未支付,已支付)
步驟:
1、查詢訂單支付狀態
2、如果已經支付,直接返回結果
3、如果未支付,則支付扣款并且保存流水
4、返回支付結果
如果步驟4通信失敗,用戶再次發起請求,那么最終結果還是一樣的
對于做過支付的朋友,冪等,也可以稱之為沖正,保證客戶端與服務端的交易一致性,避免多次扣款。
最后來看一下我們的訂單流程,雖然不是很復雜,但是最后在支付環境是一定要實現冪等性的
《新程序員》:云原生和全面數字化實踐50位技術專家共同創作,文字、視頻、音頻交互閱讀總結
- 上一篇: 什么是分布式系统中的幂等性
- 下一篇: 用redis构建分布式锁