java 支付重复问题_Airbnb支付系统如何在分布式环境下避免重复打款
原文鏈接:https://medium.com/airbnb-engineering/avoiding-double-payments-in-a-distributed-payments-system-2981f6b070bb
Airbnb一直在將其基礎架構遷移到面向服務的體系結構(SOA)。 SOA具有許多優勢,例如使開發人員能夠專業化并具有更快迭代的能力。 但是,這也給計費和支付應用程序帶來了挑戰,因為它使功能更加復雜。對服務的API調用,對下游服務進行進一步的API調用,其中每個服務都會更改狀態并可能產生副作用,這等效于執行復雜的分布式事務 。
知識卡片:SOA:把系統按照實際業務,拆分成剛剛好大小的、合適的、獨立部署的模塊,每個模塊之間相互獨立。
SOA 重要思想,避免業務代碼冗余。針對熱服務擴展集群緩解壓力。服務治理,了解調用關系。服務監控和跟蹤。rest vs rpc
點評:微服務的有狀態會使得問題變得復雜
為了確保所有服務之間的一致性,可以使用諸如兩階段提交之類的協議。 如果沒有這樣的協議,分布式事務將對維護數據完整性,適度降級以及實現一致性提出挑戰。 分布式系統中的請求也不可避免地會失敗,包括連接將在某個時刻斷開并超時,尤其是對于包含多個網絡請求的事務。
知識卡片: 二階段提交。一種基礎的分布式事務的實現方法。會有一個主來決定事務是否可以提交 ,其他隨從要提交事務的執行的結果給主。只要有一個節點掛了,或者主節點掛了,或者有一個從節點事務要回滾,整個分布式事務都會以回滾告終。第8章 二階段提交
分布式系統中使用三種不同的通用技術來實現最終的一致性:讀修復,寫修復和異步修復。 每種方法都有優點和缺點。 我們的付款系統在所有功能中都使用這三個功能。
知識卡片:讀修復
當用戶并行讀取多個節點時,它可以獲取到其他過期的值的響應。所以用戶會發現其中有些節點擁有過期的值,這時用戶可以主動將新值寫入該節點。這種方法稱之為讀修復。DDIA 4 復制
異步修復:服務器負責運行數據一致性檢查,例如表掃描,lambda函數和cron作業。 此外,從服務器到客戶端的異步通知在支付行業中被廣泛使用,以強制客戶端保持一致。 異步修復和通知可以與讀寫修復技術結合使用,從而提供了第二道防線,也要權衡解決方案復雜性。
我們在此特定帖子中的解決方案利用寫修復,其中從客戶端到服務器的每個寫調用都試圖修復不一致的狀態。 寫修復要求客戶端變得更聰明(我們將在稍后進行擴展),并允許客戶端重復觸發相同的請求,而無需維護狀態(重試除外)。 客戶因此可以按需請求最終的一致性,從而使他們可以控制用戶的體驗。 冪等性是實現寫入修復時的一個極其重要的屬性。
image.png
什么是冪等?
為了使API請求具有冪等性,客戶端可以重復進行相同的調用,并且結果將相同。 換句話說,發出多個相同的請求應具有與發出單個請求相同的效果。
這項技術通常用于涉及資金流動的記賬和支付系統
至關重要的一點是,付款請求必須完全準確地處理一次(也稱為“exactly once”)。 重要的是,如果多次調用一次轉移資金的操作,則基礎系統最多應轉移一次資金。 這對于Airbnb Payments API至關重要,這樣可以避免向主機多次付款,甚至更糟的是向客人收取多次費用。
根據設計,冪等性使用API的自動重試機制安全地允許來自客戶端的多個相同調用,以實現最終的一致性。 這種技術在具有冪等性的客戶-服務器關系中很常見,并且在今天的分布式系統中也使用了這種技術。
點評:也就是說如果狀態不一致,api可以自動重試,因為有冪等性的保護,我們可以安全的重試(不會產生多次付款之類的問題),最后達到最終一致的效果。
下圖從高層次上說明了一些簡單的示例場景,這些場景具有重復的請求和理想的冪等行為。 無論提出多少收費請求,客人始終最多只能被收取一次費用。
image.png
問題陳述
確保我們支付系統的最終一致性至關重要。 冪等是在分布式系統中實現此目的的理想機制。 在SOA世界中,我們不可避免地會遇到問題。 例如,客戶端如何恢復當他消費response失敗? 如果響應丟失或客戶端超時怎么辦? 導致用戶兩次單擊“預訂”的競爭條件(race-condition)如何? 我們的要求包括以下內容:
我們需要針對Airbnb的各種Payments SOA服務使用通用但可配置的冪等性解決方案,而不是針對特定用例實現單一的定制解決方案。
在迭代基于SOA的支付產品時,我們不能在數據一致性上妥協,因為這將直接影響我們的服務。
我們需要極低的延遲,因此僅建立一個獨立的冪等服務是不夠的。 最重要的是,該服務將遭受最初打算解決的相同問題。
當Airbnb使用SOA擴展其工程組織時,讓每個開發人員都專注于數據完整性和最終的一致性挑戰將是非常低效的。 我們希望避免產品開發人員受到這些干擾,從而使他們能夠專注于產品開發并加快迭代速度。
此外,在代碼可讀性,可測試性和故障排除能力方面的重大折衷都被認為是不可行的。
總結:實現一個冪等性解決方案,是通用和可配置。要保證數據的最終一致性。需要極低的延遲(所以不要走網絡)。對業務開發人員無感知。不犧牲代碼的可讀性,可測試性和容易排除故障能力。
解決方案說明
我們希望能夠唯一地識別每個傳入的請求。 此外,我們需要準確跟蹤和管理特定請求在其生命周期中的位置。
我們在多種支付服務中實施并利用了通用的冪等性庫“ Orpheus”。 奧菲斯(Orpheus)是傳說中的希臘神話英雄,能夠精心策劃和吸引所有生物。
我們選擇一個庫作為解決方案,是因為它提供了低延遲,同時仍將高速產品代碼與低速系統管理代碼之間完全分開。 從高層次上講,它包含以下簡單概念:
冪等鍵被傳遞到框架中,代表單個冪等請求
冪等性信息表,始終從分片主數據庫讀取和寫入(出于一致性考慮)
使用Java Lambda,數據庫事務在代碼庫的不同部分進行組合以確保原子性
錯誤響應分為“可重試”或“不可重試”
我們將詳細介紹具有冪等性保證的復雜分布式系統如何能夠自我修復并最終保持一致。 我們還將介紹解決方案中應注意的一些權衡和其他復雜性。
盡量減少數據庫提交
冪等系統的關鍵要求之一是僅產生兩個結果,即成功或失敗,并且保持一致。 否則,數據偏差可能導致數小時的調查和不正確的付款。 因為數據庫提供ACID屬性,所以數據庫事務可以有效地用于原子寫入數據,同時確保一致性。 可以保證數據庫提交作為一個單元成功或失敗。
Orpheus圍繞這樣一個假設,即幾乎每個標準API請求都可以分為三個不同的階段:RPC前,RPC和RPC后三個階段。
“ RPC”或“遠程過程調用”是指客戶端向遠程服務器發出請求,并等待該服務器完成所請求的過程,然后恢復其進程。 在支付API的上下文中,我們將RPC稱為對網絡上下游服務的請求,該服務可以包括外部支付處理器和收單銀行。 簡而言之,這是每個階段發生的事情:
RPC之前:付款請求的詳細信息記錄在數據庫中。
RPC:通過網絡使該請求對外部服務生效,并收到響應。 在這里可以進行一個或多個冪等計算或RPC(例如,如果嘗試重試,則要先query這個交易的狀態)。
RPC后:來自外部服務的響應的詳細信息記錄在數據庫中,包括其成功以及錯誤請求是否可重試。
點評:這里有2次落庫,一次拿到響應。這中間任意點都可發生失敗。
為了保持數據完整性,我們遵守兩個簡單的基本規則:
在RPC之前和之后的階段中,沒有網絡上的服務交互
RPC階段中沒有數據庫交互
我們本質上是想避免將網絡通信與數據庫工作混合在一起。 我們已經了解了在RPC之前和之后階段的網絡調用(RPC)是脆弱的并且可能導致不良后果(如快速耗盡連接池和降低性能)。
簡而言之,網絡呼叫本質上是不可靠的。 因此,我們將Pre-RPC和Post-RPC階段包裝在由庫本身啟動的數據庫事務中。
我們還想指出一個API請求可能包含多個RPC。 Orpheus確實支持多RPC請求,但是在這篇文章中,我們僅用簡單的單RPC情況來說明我們的思考過程。
如下面的示例圖所示,每個Pre-RPC和Post-RPC階段中的每個數據庫提交都組合到一個數據庫事務中。 這樣可以確保原子性
整個工作單元(此處為Pre-RPC之前和Post-RPC階段)可以始終失敗或成功。
這樣做的動機是系統應該以可以恢復的方式發生故障。 例如,如果幾個API請求在長時間的數據庫提交過程中失敗,那么系統地跟蹤每個失敗發生的位置將非常困難。 請注意,所有網絡通信(RPC)都與所有數據庫事務顯式分離。
image.png
這里的數據庫提交包括冪等庫提交和應用程序層數據庫提交,所有這些都合并在同一代碼塊中。 如果不加小心,在實際代碼中可能看起來真的很亂。產品開發人員也不應調用某些冪等例程。
救星: Java Lambdas
幸運的是,Java lambda表達式可用于將多個句子無縫地組合到單個數據庫事務中,而不會影響可測試性和代碼可讀性。
下面是個例子,
public Response processPayment(InitiatePaymentRequest request, UriInfo uriInfo)
throws YourCustomException {
return orpheusManager.process(
request.getIdempotencyKey(),
uriInfo,
// 1. Pre-RPC
() -> {
// Record payment request information from the request object
PaymentRequestResource paymentRequestResource = recordPaymentRequest(request);
return Optional.of(paymentRequestResource);
},
// 2. RPC
(isRetry, paymentRequest) -> {
return executePayment(paymentRequest, isRetry);
},
// 3. Post RPC - record response information to database
(isRetry, paymentResponse) -> {
return recordPaymentResponse(paymentResponse);
});
}
public Response process(
String idempotencyKey,
UriInfo uriInfo,
SetupExecutable preRpcExecutable, // Pre-RPC lambda
ProcessExecutable rpcExecutable, // RPC lambda
PostProcessExecutable postRpcExecutable) // Post-RPC lambda
throws YourCustomException {
try {
// Find previous request (for retries), otherwise create
IdempotencyRequest idempotencyRequest = createOrFindRequest(idempotencyKey, apiUri);
Optional responseOptional = findIdempotencyResponse(idempotencyRequest);
// Return the response for any deterministic end-states, such as
// non-retryable errors and previously successful responses
if (responseOptional.isPresent()) {
return responseOptional.get();
}
boolean isRetry = idempotencyRequest.isRetry();
A requestObject = null;
// STEP 1: Pre-RPC phase:
// Typically used to create transaction and related sub-entities
// Skipped if request is a retry
if(!isRetry) {
// Before a request is made to the external service, we record
// the request and idempotency commit in a single DB transaction
requestObject =
dbTransactionManager.execute(
tc -> {
final A preRpcResource = preRpcExecutable.execute();
updateIdempotencyResource(idempotencyKey, preRpcResource);
return preRpcResource;
});
} else {
requestObject = findRequestObject(idempotencyRequest);
}
// STEP 2: RPC phase:
// One or more network calls to the service. May include
// additional idempotency logic in the case of a retry
// Note: NO database transactions should exist in this executable
R rpcResponse = rpcExecutable.execute(isRetry, requestObject);
// STEP 3: Post-RPC phase:
// Response is recorded and idempotency information is updated,
// such as releasing the lease on the idempotency key. Again,
// all in one single DB transaction
S response = dbTransactionManager.execute(
tc -> {
final S postRpcResponse = postRpcExecutable.execute(isRetry, rpcResponse);
updateIdempotencyResource(idempotencyKey, postRpcResponse);
return postRpcResponse;
});
return serializeResponse(response);
} catch (Throwable exception) {
// If CustomException, return error code and response based on
// ‘retryable’ or ‘non-retryable’. Otherwise, classify as ‘retryable’
// and return a 500.
}
}
這些關注點的分離確實提供了一些權衡。 開發人員必須使用前瞻性來確保代碼的可讀性和可維護性,因為其他新的代碼會不斷做出貢獻。 他們還需要一致地評估適當的依賴關系和數據傳遞。 現在需要將API調用重構為三個較小的塊,這可能會限制開發人員編寫代碼的方式。 實際上,將某些復雜的API調用有效地分解為三步方法可能真的很困難。
我們的一項服務已使用StatefulJ在每次轉換中實現了有限狀態機,作為冪等步驟,您可以在其中安全地在API調用中復用冪等調用。
處理異常-重試還是不重試?
使用Orpheus這樣的框架,服務器應該知道何時可以重試請求,何時不可以重試。 為此,應謹慎處理異常,將異常歸類為“可重試”或“不可重試”。
毫無疑問,這給開發人員增加了一層復雜性,如果他們不明智和謹慎的話,可能會產生不良的副作用。
例如,假設下游服務暫時處于脫機狀態,但是當引發的異常本來應該是“可重試的”時,被錯誤地標記為“不可重試”。 該請求將無限期“失敗”,隨后的重試請求將永久返回不正確的不可重試錯誤。 相反,如果在異常本來應該是“不可重試”且需要人工干預的情況下,被標記為“可重試”,則可能會發生雙重支付。
通常,我們認為由于網絡和基礎結構問題(5XX HTTP狀態)而導致的意外運行時異常是可以重試的。 我們希望這些錯誤是暫時的,并且我們希望以后再次嘗試相同的請求最終可能會成功。
我們將驗證錯誤(例如無效的輸入和狀態(例如,您無法退回一筆退款))歸為不可重試(4XX HTTP狀態),我們希望同一請求的所有后續重試都將以相同的方式失敗。 我們創建了一個自定義的通用異常類來處理這些情況,默認為“不可重試”,對于某些其他情況,分類為“可重試”。
至關重要的是,每個請求的請求有PAYLOAD必須保持不變,否則將破壞冪等請求的定義。
image.png
當然,還有一些模糊的邊緣情況需要謹慎處理,例如在不同的上下文中適當地處理NullPointerException。 例如,由于連接異常而從數據庫返回的null與來自客戶端或第三方響應的請求中的錯誤null字段不同。
客戶扮演重要角色
正如本文開頭提到的那樣,客戶端必須在寫入修復系統中更精明。 與使用冪等庫(例如Orpheus)的服務進行交互時,它必須承擔幾個關鍵職責:
為每個新請求傳遞唯一的冪等密鑰; 重用相同的冪等密鑰進行重試。
在調用服務之前將這些冪等性鍵持久化到數據庫中(以后再用于重試)。
正確使用成功的響應,然后取消賦值冪等密鑰。
確保重試嘗試之間更改請求的payload不被允許。
根據業務需求仔細設計和配置自動重試策略(使用指數退避或隨機等待時間(“抖動”),以避免驚群問題。
知識卡片: 驚群問題是計算機科學中,當許多進程等待一個事件,事件發生后這些進程被喚醒,但只有一個進程能獲得CPU執行權,其他進程又得被阻塞,這造成了嚴重的系統上下文切換代價。
如何選擇冪等密鑰?
選擇一個冪等性密鑰至關重要。
客戶可以根據要使用的密鑰選擇具有請求級冪等性或實體級冪等性。 決定使用哪一個取決于不同的業務用例,但是請求級冪等性是最直接和最常見的。
點評: 請求級冪等表示這個KEY代表同一個請求。實體級冪等代表這個KEY代表同一個實體。比如我們有一個支付指令,這個指令在發RPC時會被賦予一個KEY,這個KEY是請求級冪等。這是支付指令本身有一個ID 與一次RPC無關,代表它本身的狀態是實體級冪等。
對于請求級別的冪等性,應從客戶端選擇一個隨機且唯一的密鑰,以確保整個實體集合級別的冪等性。 例如,如果我們想為預訂房間允許多種不同的付款方式(例如“先付少付”),我們只需要確保冪等性鍵不同即可。 UUID是一個很好的示例格式。
實體級的冪等性比請求級的冪等性要嚴格得多。 假設我們要確保給定的ID為1234的10美元付款僅能退款5美元,因為從技術上講,我們可以兩次提出5美元的退款請求。 然后,我們希望使用基于實體模型的確定性冪等性關鍵字,以確保實體級別的冪等性。 示例格式為“ payment-1234-refund”。 因此,每筆要求退款對唯一PAYMENT在實體級別(payment id1234)都是冪等的。
每個API請求都有一個到期的租約
由于多次用戶單擊或客戶端具有積極的重試策略,可能會觸發多個相同的請求。
這可能會在服務器上造成競爭狀況,或者為我們的產品加倍付款。
為了避免這些情況,API調用在框架的幫助下每個都需要獲得對冪等鍵的數據庫行級鎖。這將授予給定請求以進一步進行其他事情的租約。
租約附帶一個到期時間以涵蓋服務器端超時的情況。如果沒有響應,則僅在當前租約到期后才能重試API請求。
應用程序可以根據需要配置租約到期和RPC超時。一個好的經驗法則是,其租約到期時間要比RPC超時時間長。
點評: RPC超時之后,租約沒超時,可以有效防止客戶端的積極重試策略帶來的競爭情況。
Orpheus還為冪等KEY提供了一個最大的可重試窗口,以提供一個安全網,從而避免了由于系統意外行為而導致的惡意重試
點評:限制一個時間窗口內的最多重試次數。
記錄Response
我們還記錄響應,以維護和監視冪等行為。 當客戶對已達到確定性最終狀態的事務提出相同的請求時,例如不可重試的錯誤(例如,驗證錯誤)或成功的響應,該響應將記錄在數據庫中。
持久化response確實需要在性能上進行權衡
客戶能夠在后續重試中獲得快速回復,因為冪等結果已經落庫。
但是存響應的表的增長與應用程序吞吐量的增長成比例。 如果我們不小心的話,這張表很快會變得臃腫。 一種可能的解決方案是定期刪除早于特定時間范圍的行,但是過早刪除冪等響應也會產生負面影響。 開發人員還應該警惕不要對響應實體和結構進行向后不兼容的更改。
避免副本數據庫-堅守master
使用Orpheus讀寫等冪信息時,我們選擇直接從master數據庫中進行。
在分布式數據庫系統中,要在一致性和延遲之間進行權衡。由于我們無法忍受高延遲或讀取未提交的數據,因此對這些表使用master是最有意義的。
這樣做無需使用緩存或數據庫副本。如果未將數據庫系統配置為具有強讀取一致性(我們的系統由MySQL支持),則從等冪角度來看,對這些操作使用副本實際上會產生不利影響。
例如,假設支付服務將其冪等信息存儲在副本數據庫中。客戶向該服務提交了付款請求,該請求最終在下游成功完成,但是由于網絡問題,該客戶未收到響應。當前存儲在服務的主數據庫中的響應最終將被寫入副本。但是,在副本滯后的情況下,客戶端可以正確地對服務發起冪等重試,并且響應尚未記錄到副本中。因為響應“不存在”(在副本上),所以該服務可能會錯誤地再次執行付款,從而導致重復付款。下面的示例說明了復制延遲僅幾秒鐘如何對Airbnb社區造成重大財務影響。
image.png
點評:網絡超時+副本同步超時,造成重試的那次沒有被冪等,造成重復打款。
只存在MASTER解決了這個問題
image.png
當使用單個主數據庫進行冪等時,很明顯,毫無疑問,擴展無疑會成為一個問題。 我們通過使用冪等鍵對數據庫進行分片來緩解這種情況。 我們使用的冪等密鑰具有高基數和均勻分布,使其成為有效的分片密鑰。
最后的想法
有許多不同的解決方案可以緩解分布式系統中的一致性挑戰。 Orpheus是適用于我們的幾個組件之一,因為它具有通用性和輕巧性。開發人員在使用新服務時可以簡單地導入該庫,并且冪等邏輯保存在特定于應用程序的概念和模型之上的單獨的抽象層中。
但是,要實現最終的一致性就必須引入一些復雜性。
客戶需要存儲和處理冪等密鑰并實現自動重試機制。開發人員需要更多的上下文,并且在實施Java lambda并對其進行故障排除時必須具有外科手術的精確性。在處理異常時,它們必須是深思熟慮的。此外,由于Orpheus的當前版本經過了實戰測試,我們不斷發現需要改進的地方:重試請求payload匹配,對模式更改和嵌套遷移的改進支持,在RPC階段積極限制數據庫訪問等等。
雖然這些是最重要的考慮因素,但到目前為止,Orpheus在哪里獲得了Airbnb Payments?自該框架啟動以來,我們的付款一致性達到了五個九,而我們的年度付款量同時翻了一番(如果您想了解有關我們如何大規模衡量數據完整性的信息,請閱讀此信息)。
我的思考與啟示
了解了實現最終一致性的3個手段,讀修復,異步修復,寫修復。異步修復可以做第二道防線。
低延遲的冪等LIBRARY設計,把冪等的框架統一化。
抽象出一個支付請求的模板,使得RPC前后的數據庫操作以優雅的方式寫進一個事務。
為了解決支付業務的復雜性和框架的簡單性之間的GAP,運用STATEFUL J這個工具來實現。
RETRY 和NOT RETRY的情況,和顯式回傳給客戶端。
實體級冪等和請求級冪等
為了避免不一致采用只在MASTER上操作,而選取高基數的冪等KEY 和 做分片以緩解壓力。
避免驚群效應,需要指數回退或者隨機等待的重試策略
冪等KEY的租約和底層框架的最大重試窗口 來保護系統不會HANDLE很多無用的請求。
總結
以上是生活随笔為你收集整理的java 支付重复问题_Airbnb支付系统如何在分布式环境下避免重复打款的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: java枚举怎么编译不行的_java枚举
- 下一篇: myeclipse配置java8_MyE
