冪等性問題剖析

所謂冪等,簡單地說,就是對接口的屢次調用所產生的結果和調用一次是一致的。擴展一下,這裏的接口,能夠理解爲對外發布的HTTP接口或者Thrift接口,也能夠是接收消息的內部接口,甚至是一個內部方法或操做。html

數學上的定義:f(f(x)) = f(x)。x被函數f做用一次和做用無限次的結果是同樣的。冪等性應用在軟件系統中,我把它簡單定義爲:某個函數或者某個接口使用相同參數調用一次或者無限次,其形成的後果是同樣的,在實際應用中通常針對於接口進行冪等性設計。舉個栗子,在系統中,調用方A調用系統B的接口進行用戶的扣費操做時,因爲網絡不穩定,A重試了N次該請求,那麼無論B是否接收到多少次請求,都應該保證只會扣除該用戶一次費用。前端

那麼咱們爲何須要接口具備冪等性呢?設想一下如下情形:nginx

  • 在App中下訂單的時候,點擊確認以後,沒反應,就又點擊了幾回。在這種狀況下,若是沒法保證該接口的冪等性,那麼將會出現重複下單問題。
  • 在接收消息的時候,消息推送重複。若是處理消息的接口沒法保證冪等,那麼重複消費消息產生的影響可能會很是大。

在分佈式環境中,網絡環境更加複雜,因前端操做抖動、網絡故障、消息重複、響應速度慢等緣由,對接口的重複調用機率會比集中式環境下更大,尤爲是重複消息在分佈式環境中很難避免。Tyler Treat也在《You Cannot Have Exactly-Once Delivery》一文中提到:程序員

Within the context of a distributed system, you cannot have exactly-once message delivery.redis

分佈式環境中,有些接口是自然保證冪等性的,如查詢操做。有些對數據的修改是一個常量,而且無其餘記錄和操做,那也能夠說是具備冪等性的。其餘狀況下,全部涉及對數據的修改、狀態的變動就都有必要防止重複性操做的發生。經過間接的實現接口的冪等性來防止重複操做所帶來的影響,成爲了一種有效的解決方案。數據庫

GTIS

GTIS就是這樣的一個解決方案。它是一個輕量的重複操做關卡系統,它可以確保在分佈式環境中操做的惟一性。咱們能夠用它來間接保證每一個操做的冪等性。它具備以下特色:api

  • 高效:低延時,單個方法平均響應時間在2ms內,幾乎不會對業務形成影響;
  • 可靠:提供降級策略,以應對外部存儲引擎故障所形成的影響;提供應用鑑權,提供集羣配置自定義,下降不一樣業務之間的干擾;
  • 簡單:接入簡捷方便,學習成本低。只需簡單的配置,在代碼中進行兩個方法的調用便可完成全部的接入工做;
  • 靈活:提供多種接口參數、使用策略,以知足不一樣的業務需求。

實現原理

基本原理緩存

GTIS的實現思路是將每個不一樣的業務操做賦予其惟一性。這個惟一性是經過對不一樣操做所對應的惟一的內容特性生成一個惟一的全局ID來實現的。基本原則爲:相同的操做生成相同的全局ID;不一樣的操做生成不一樣的全局ID。服務器

生成的全局ID須要存儲在外部存儲引擎中,數據庫、Redis亦或是Tair等都可實現。考慮到Tair天生分佈式和持久化的優點,目前的GTIS存儲在Tair中。其相應的key和value以下:restful

  • key:將對於不一樣的業務,採用APP_KEY+業務操做內容特性生成一個惟一標識trans_contents。而後對惟一標識進行加密生成全局ID做爲Key。
  • value:current_timestamp + trans_contents,current_timestamp用於標識當前的操做線程。

判斷是否重複,主要利用Tair的SETNX方法,若是原來沒有值則set且返回成功,若是已經有值則返回失敗。

內部流程

GTIS的內部實現流程爲:

  1. 業務方在業務操做以前,生成一個可以惟一標識該操做的transContents,傳入GTIS;
  2. GTIS根據傳入的transContents,用MD5生成全局ID;
  3. GTIS將全局ID做爲key,current_timestamp+transContents做爲value放入Tair進行setNx,將結果返回給業務方;
  4. 業務方根據返回結果肯定可否開始進行業務操做;
  5. 若能,開始進行操做;若不能,則結束當前操做;
  6. 業務方將操做結果和請求結果傳入GTIS,系統進行一次請求結果的檢驗;
  7. 若該次操做成功,GTIS根據key取出value值,跟傳入的返回結果進行比對,若是二者相等,則將該全局ID的過時時間改成較長時間;
  8. 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)等其餘條件,來作樂觀鎖的判斷條件,這樣保證更新操做即便在併發的狀況下,也不會有太大的問題。例如

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讀大於寫的狀況下儘可能少用。

4. select + insert
併發不高的後臺系統,或者一些任務JOB,爲了支持冪等,支持重複執行,簡單的處理方法是,先查詢下一些關鍵數據,判斷是否已經執行過,在進行業務處理,就能夠了。注意:核心高併發流程不要用這種方法。

5.狀態機冪等
在設計單據相關的業務,或者是任務相關的業務,確定會涉及到狀態機,就是業務單據上面有個狀態,狀態在不一樣的狀況下會發生變動,通常狀況下存在有限狀態機,這時候,若是狀態機已經處於下一個狀態,這時候來了一個上一個狀態的變動,理論上是不可以變動的,這樣的話,保證了有限狀態機的冪等。

6. token機制,防止頁面重複提交

業務要求:頁面的數據只能被點擊提交一次
發生緣由:因爲重複點擊或者網絡重發,或者nginx重發等狀況會致使數據被重複提交
解決辦法:

  • 集羣環境:採用token加redis(redis單線程的,處理須要排隊)
  • 單JVM環境:採用token加redis或token加jvm內存

處理流程:

  • 數據提交前要向服務的申請token,token放到redis或jvm內存,token有效時間
  • 提交後後臺校驗token,同時刪除token,生成新的token返回

  token特色:要申請,一次有效性,能夠限流 

7. 對外提供接口的api如何保證冪等 

如銀聯提供的付款接口:須要接入商戶提交付款請求時附帶:source來源,seq序列號。source+seq在數據庫裏面作惟一索引,防止屢次付款,(併發時,只能處理一個請求)

總結: 冪等性應該是合格程序員的一個基因,在設計系統時,是首要考慮的問題,尤爲是在像支付寶,銀行,互聯網金融公司等涉及的都是錢的系統,既要高效,數據也要準確,因此不能出現多扣款,多打款等問題,這樣會很難處理,用戶體驗也很差 。

這裏須要關注幾個重點:

  1. 冪等不只僅只是一次(或屢次)請求對資源沒有反作用(好比查詢數據庫操做,沒有增刪改,所以沒有對數據庫有任何影響)。

  2. 冪等還包括第一次請求的時候對資源產生了反作用,可是之後的屢次請求都不會再對資源產生反作用。

  3. 冪等關注的是之後的屢次請求是否對資源產生的反作用,而不關注結果。

  4. 網絡超時等問題,不是冪等的討論範圍。

冪等性是系統服務對外一種承諾(而不是實現),承諾只要調用接口成功,外部屢次調用對系統的影響是一致的。聲明爲冪等的服務會認爲外部調用失敗是常態,而且失敗以後必然會有重試。

什麼狀況下須要冪等

業務開發中,常常會遇到重複提交的狀況,不管是因爲網絡問題沒法收到請求結果而從新發起請求,或是前端的操做抖動而形成重複提交狀況。 在交易系統,支付系統這種重複提交形成的問題有尤爲明顯,好比:

  1. 用戶在APP上連續點擊了屢次提交訂單,後臺應該只產生一個訂單;

  2. 向支付寶發起支付請求,因爲網絡問題或系統BUG重發,支付寶應該只扣一次錢。 很顯然,聲明冪等的服務認爲,外部調用者會存在屢次調用的狀況,爲了防止外部屢次調用對系統數據狀態的發生屢次改變,將服務設計成冪等。

冪等VS防重

上面例子中小明遇到的問題,只是重複提交的狀況,和服務冪等的初衷是不一樣的。重複提交是在第一次請求已經成功的狀況下,人爲的進行屢次操做,致使不知足冪等要求的服務屢次改變狀態。而冪等更多使用的狀況是第一次請求不知道結果(好比超時)或者失敗的異常狀況下,發起屢次請求,目的是屢次確認第一次請求成功,卻不會因屢次請求而出現屢次的狀態變化。

什麼狀況下須要保證冪等性

以SQL爲例,有下面三種場景,只有第三種場景須要開發人員使用其餘策略保證冪等性:

  1. SELECT col1 FROM tab1 WHER col2=2,不管執行多少次都不會改變狀態,是自然的冪等。

  2. UPDATE tab1 SET col1=1 WHERE col2=2,不管執行成功多少次狀態都是一致的,所以也是冪等操做。

  3. UPDATE tab1 SET col1=col1+1 WHERE col2=2,每次執行的結果都會發生變化,這種不是冪等的。

爲何要設計冪等性的服務

冪等可使得客戶端邏輯處理變得簡單,可是卻以服務邏輯變得複雜爲代價。知足冪等服務的須要在邏輯中至少包含兩點:

  1. 首先去查詢上一次的執行狀態,若是沒有則認爲是第一次請求

  2. 在服務改變狀態的業務邏輯前,保證防重複提交的邏輯

冪等的不足

冪等是爲了簡化客戶端邏輯處理,卻增長了服務提供者的邏輯和成本,是否有必要,須要根據具體場景具體分析,所以除了業務上的特殊要求外,儘可能不提供冪等的接口。

  1. 增長了額外控制冪等的業務邏輯,複雜化了業務功能;

  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個操做中,尤其注意就是增長或者修改,

查詢對於結果是不會有改變的,

刪除只會進行一次,用戶屢次點擊產生的結果同樣

修改在大多場景下結果同樣

增長在重複提交的場景下會出現

 

那麼如何設計接口才能作到冪等呢?

方法1、單次支付請求,也就是直接支付了,不須要額外的數據庫操做了,這個時候發起異步請求建立一個惟一的ticketId,就是門票,這張門票只能使用一次就做廢,具體步驟以下:

  1. 異步請求獲取門票

  2. 調用支付,傳入門票

  3. 根據門票ID查詢這次操做是否存在,若是存在則表示該操做已經執行過,直接返回結果;若是不存在,支付扣款,保存結果

  4. 返回結果到客戶端

若是步驟4通訊失敗,用戶再次發起請求,那麼最終結果仍是同樣的

 

方法2、分佈式環境下各個服務相互調用

這邊就要舉例咱們的系統了,咱們支付的時候先要扣款,而後更新訂單,這個地方就涉及到了訂單服務以及支付服務了。

用戶調用支付,扣款成功後,更新對應訂單狀態,而後再保存流水。

而在這個地方就不必使用門票ticketId了,由於會比較閒的麻煩

(支付狀態:未支付,已支付)

步驟:

一、查詢訂單支付狀態

二、若是已經支付,直接返回結果

三、若是未支付,則支付扣款而且保存流水

四、返回支付結果

若是步驟4通訊失敗,用戶再次發起請求,那麼最終結果仍是同樣的

對於作過支付的朋友,冪等,也能夠稱之爲衝正,保證客戶端與服務端的交易一致性,避免屢次扣款。

 

最後來看一下咱們的訂單流程,雖然不是很複雜,可是最後在支付環境是必定要實現冪等性的

相關文章
相關標籤/搜索