導(dǎo)讀:微服務(wù)架構(gòu)下的支付系統(tǒng),由于其需要在性能和一致性之間做很多權(quán)衡,帶來設(shè)計(jì)和實(shí)現(xiàn)的復(fù)雜性。Airbnb的支付系統(tǒng)需要對接全球很多個(gè)國家的支付系統(tǒng),因此帶來很大的復(fù)雜性。本文詳細(xì)論述了Airbnb如何使用分布式事務(wù)的相關(guān)技術(shù)來保證支付系統(tǒng)的數(shù)據(jù)一致性和性能,十分值得一讀。
過去幾年中,Airbnb一直在將其基礎(chǔ)架構(gòu)遷移到SOA。相比單體應(yīng)用,SOA提供了許多優(yōu)勢,例如支持開發(fā)人員專業(yè)化和加速迭代的能力。然而,這也對計(jì)費(fèi)和支付程序提出了挑戰(zhàn),因?yàn)镾OA使維護(hù)數(shù)據(jù)完整性變得更加困難。對服務(wù)API的調(diào)用,該服務(wù)對下游服務(wù)進(jìn)行進(jìn)一步的API調(diào)用,其中每個(gè)服務(wù)改變狀態(tài)都可能具有副作用,等同于執(zhí)行復(fù)雜的分布式事務(wù)。
為了確保所有服務(wù)之間的數(shù)據(jù)一致性,可以使用兩階段提交之類的協(xié)議。如果不用這樣的協(xié)議,數(shù)據(jù)一致性就難以保證。在分布式系統(tǒng)中請求不可避免地會失敗(連接會在某些時(shí)候丟失并超時(shí),尤其是對于包含多個(gè)網(wǎng)絡(luò)請求的事務(wù)。
分布式系統(tǒng)中使用三種不同的常用技術(shù)來實(shí)現(xiàn)最終的一致性:讀修復(fù),寫修復(fù)和異步修復(fù)。每種方法各有利弊。三種方式在我們的支付系統(tǒng)中都有使用。
異步修復(fù)通過服務(wù)器負(fù)責(zé)運(yùn)行數(shù)據(jù)一致性檢查來實(shí)現(xiàn),例如表掃描,lambda函數(shù)和cron job。此外,從服務(wù)器到客戶端的異步通知廣泛用于支付行業(yè),以保持客戶端的一致性。異步修復(fù)以及通知可以與讀寫修復(fù)技術(shù)結(jié)合使用,提供第二道防線,并在解決方案復(fù)雜性方面起到作用。
本文中描述的解決方案使用了寫修復(fù),其中從客戶端到服務(wù)器的每次寫入調(diào)用都嘗試修復(fù)不一致狀態(tài)。寫修復(fù)要求客戶端更加智能(稍后我們將對此進(jìn)行擴(kuò)展討論),并允許重復(fù)發(fā)出相同的請求,而不必維護(hù)狀態(tài)(除了重試)。因此,客戶端可以按自己的需求來達(dá)到最終的一致性,從而使他們能夠控制用戶體驗(yàn)。在實(shí)現(xiàn)寫修復(fù)時(shí),冪等性是一個(gè)非常重要的屬性。
什么是冪等?
API請求具有冪等性即客戶端可以重復(fù)進(jìn)行相同的調(diào)用,結(jié)果將是相同的。換句話說,發(fā)出多個(gè)相同的請求應(yīng)該與發(fā)出單個(gè)請求具有相同的效果。
這種技術(shù)通常用于涉及資金流動的計(jì)費(fèi)和支付系統(tǒng),即支付請求必須完全處理一次(也稱為“確切一次交付”)。重要的是,如果多次調(diào)用移動資金操作,系統(tǒng)最多只能移動一次資金。這對Airbnb Payments API至關(guān)重要,以避免多次支付。
冪等性允許來自客戶端的多個(gè)相同請求使用API的自動重試機(jī)制來達(dá)到最終一致性。這種方式在具有冪等性的客戶端 - 服務(wù)器中是常見的,并且在我們的系統(tǒng)中也是如此。
下圖說明了重復(fù)請求和理想冪等行為的簡單場景。無論收費(fèi)多少,客戶最多支付一次費(fèi)用。
問題描述
保證我們的支付系統(tǒng)最終的一致性至關(guān)重要。冪等性是在分布式系統(tǒng)中實(shí)現(xiàn)這一點(diǎn)的理想機(jī)制。在SOA世界中,我們將不可避免地遇到問題。例如,假如服務(wù)沒有響應(yīng),客戶端將如何恢復(fù)?如果Response丟失或客戶超時(shí)怎么辦?如果競爭條件導(dǎo)致用戶點(diǎn)擊“預(yù)訂”兩次呢?我們的需求包括:
-
我們需要一個(gè)通用但可配置的冪等解決方案,而不是實(shí)現(xiàn)針對特定用例的自定義解決方案,以便在Airbnb的各種支付服務(wù)中使用。
-
雖然正在迭代基于SOA的支付產(chǎn)品,但我們無法在數(shù)據(jù)一致性上妥協(xié)。
-
我們需要超低延遲,因此構(gòu)建單獨(dú)的冪等服務(wù)不能滿足延遲要求。最重要的是,該服務(wù)將遇到上述問題。
-
隨著Airbnb使用SOA擴(kuò)展其工程組織,讓每個(gè)開發(fā)人員專注于數(shù)據(jù)完整性和最終的一致性是非常低效的。我們希望業(yè)務(wù)開發(fā)免受這些麻煩,保證他們能夠?qū)W⒂诋a(chǎn)品開發(fā)并更快地進(jìn)行迭代。
此外,代碼可讀性,可測試性和故障排除能力的相當(dāng)大的權(quán)衡被認(rèn)為是非主導(dǎo)因素。
解決方案
我們希望能夠唯一地識別每個(gè)請求。此外,我們需要準(zhǔn)確跟蹤和管理特定請求在其生命周期中的位置。
我們在多種支付服務(wù)中實(shí)施并使用了“Orpheus”,這是一種通用的冪等庫。Orpheus是傳說中的希臘神話英雄。
我們選擇了實(shí)現(xiàn)冪等庫作為解決方案,因?yàn)樗峁┑脱舆t,同時(shí)仍然提供高速變更的產(chǎn)品代碼和低速變更的系統(tǒng)管理代碼之間的隔離。在高層次上,它包含以下:
-
冪等key被傳遞到框架中,表示單個(gè)冪等請求
-
始終從主數(shù)據(jù)庫讀取和寫入(為了一致性)冪等信息表
-
通過使用JAVA lambda組合數(shù)據(jù)庫事務(wù),確保原子性
-
錯(cuò)誤被分類為“可重試”或“不可重試”
接下來我們將詳細(xì)說明具有冪等性保證的復(fù)雜分布式系統(tǒng)如何能夠自我修復(fù)并達(dá)到最終一致。我們還將介紹一些該方案應(yīng)該注意的設(shè)計(jì)權(quán)衡和帶來的額外的復(fù)雜性。
最小化數(shù)據(jù)庫提交
冪等系統(tǒng)的關(guān)鍵要求之一是只產(chǎn)生兩個(gè)結(jié)果,即成功或失敗,具有一致性。否則,數(shù)據(jù)有偏差可能導(dǎo)致數(shù)小時(shí)排查錯(cuò)誤時(shí)間和付款出問題。由于數(shù)據(jù)庫提供ACID屬性,因此數(shù)據(jù)庫事務(wù)可以有效地用于原子寫入,確保一致性。一次數(shù)據(jù)庫提交可以保證其作為一個(gè)單元的一致性。
Orpheus假設(shè)每個(gè)標(biāo)準(zhǔn)API請求都分為三個(gè)不同的階段:Pre-RPC,RPC和Post-RPC。
“RPC”是指客戶端向遠(yuǎn)程服務(wù)器發(fā)出請求并等待該服務(wù)器響應(yīng)的過程。在支付API的上下文中,我們將RPC稱為對下游服務(wù)的請求,其可以包括外部支付服務(wù)和收單銀行等。簡而言之,如下是每個(gè)階段發(fā)生的事情:
-
Pre-RPC:付款請求的詳細(xì)信息記錄在數(shù)據(jù)庫中。
-
RPC:請求通過網(wǎng)絡(luò)對外部服務(wù)進(jìn)行實(shí)時(shí)處理,并收到響應(yīng)。這是一個(gè)執(zhí)行冪等計(jì)算或RPC的過程(例如,如果是重試嘗試,則首先查詢事務(wù)狀態(tài))。
-
Post-RPC:來自外部服務(wù)的響應(yīng)的詳細(xì)信息記錄在數(shù)據(jù)庫中,包括其是否成功以及錯(cuò)誤請求是否可重試。
為了保持?jǐn)?shù)據(jù)完整性,我們遵循兩個(gè)基本規(guī)則:
-
在Pre-RPC和Post-RPC階段,沒有遠(yuǎn)程服務(wù)交互
-
RPC階段中沒有數(shù)據(jù)庫交互
我們希望避免將網(wǎng)絡(luò)調(diào)用與數(shù)據(jù)庫操作混在一起。在pre-RPC和post-RPC階段網(wǎng)絡(luò)調(diào)用(RPC)易受攻擊的,并可能導(dǎo)致連接池快速耗盡和性能下降之類的不良后果。簡而言之,網(wǎng)絡(luò)調(diào)用本質(zhì)上是不可靠的。因此,我們將Pre和Post-RPC階段處理數(shù)據(jù)庫事務(wù)。
我們還想要說明單個(gè)API請求可能包含多個(gè)RPC。 Orpheus支持多RPC請求,但在這篇文章中,我們只想用簡單的單RPC案例來說明我們的思考過程。
如下面的示例圖所示,所有Pre-RPC和Post-RPC階段中的數(shù)據(jù)庫提交都合并為一個(gè)數(shù)據(jù)庫事務(wù),這確保了原子性 。動機(jī)是系統(tǒng)應(yīng)該以可恢復(fù)的方式出現(xiàn)故障。例如,如果在多次數(shù)據(jù)庫提交過程中有幾次失敗,那么系統(tǒng)地跟蹤每個(gè)失敗發(fā)生的位置將非常困難。請注意,所有網(wǎng)絡(luò)通信(RPC)都與數(shù)據(jù)庫事務(wù)明確分開。
這里的數(shù)據(jù)庫提交包括冪等庫的數(shù)據(jù)庫提交和應(yīng)用程序數(shù)據(jù)庫提交,所有這些提交都組合在同一個(gè)代碼塊中。 如果不小心組織,這里的代碼就會非常混亂。 我們認(rèn)為產(chǎn)品開發(fā)人員不應(yīng)該負(fù)責(zé)保證冪等庫的操作。
Java Lambdas 組合事務(wù)
值得慶幸的是,Java lambda表達(dá)式可以將多個(gè)提交無縫地組合成單個(gè)數(shù)據(jù)庫事務(wù),也不會影響可測試性和代碼可讀性。
下面是一個(gè)示例,簡化了Orpheus的使用,其中Java lambdas如下:
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 objectPaymentRequestResource 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 <R extends Object, S extends Object, A extends IdempotencyRequest> Response process(String idempotencyKey,UriInfo uriInfo,SetupExecutable<A> preRpcExecutable, // Pre-RPC lambdaProcessExecutable<R, A> rpcExecutable, // RPC lambdaPostProcessExecutable<R, S> postRpcExecutable) // Post-RPC lambdathrows YourCustomException {try {// Find previous request (for retries), otherwise createIdempotencyRequest idempotencyRequest = createOrFindRequest(idempotencyKey, apiUri);Optional<Response> responseoptional = findIdempotencyResponse(idempotencyRequest);// Return the response for any deterministic end-states, such as// non-retryable errors and previously successful responsesif (responseOptional.isPresent) {return responseOptional.get;}boolean isRetry = idempotencyRequest.isRetry;A requestObject = ;// STEP 1: Pre-RPC phase:// Typically used to create transaction and related sub-entities// Skipped if request is a retryif(!isRetry) {// Before a request is made to the external service, we record// the request and idempotency commit in a single DB transactionrequestObject =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 executableR 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 transactionS 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.}}
我們沒有實(shí)現(xiàn)嵌套數(shù)據(jù)庫事務(wù),而是將Orpheus和應(yīng)用程序中的數(shù)據(jù)庫指令組合成單個(gè)數(shù)據(jù)庫事務(wù),傳遞Java 閉包。
開發(fā)人員必須預(yù)先考慮好,才能保代碼的可讀性和可維護(hù)性。 他們還需要始終如一地評估適當(dāng)?shù)囊蕾囮P(guān)系和數(shù)據(jù)傳遞。 現(xiàn)在需要將API調(diào)用重構(gòu)為三個(gè)部分,這可能會限制開發(fā)人員編寫代碼的方式。 實(shí)際上,某些復(fù)雜的API調(diào)用實(shí)際上很難有效地分解為三步。 我們的服務(wù)實(shí)現(xiàn)了一個(gè)有限狀態(tài)機(jī),每次轉(zhuǎn)換都是使用StatefulJ的冪等步驟,可以在API調(diào)用中安全地復(fù)用冪等調(diào)用。
處理異常 - 重試還是不重試?
使用像Orpheus這樣的框架,服務(wù)器應(yīng)該知道何時(shí)可以重試請求,而何時(shí)不行。要做到這一點(diǎn),應(yīng)該以細(xì)致的處理異常,異常被分類為“可重試”或“不可重試”兩大類。這無疑為開發(fā)人員增加了一層復(fù)雜性,如果他們錯(cuò)誤使用,就會產(chǎn)生副作用。
例如,假設(shè)下游服務(wù)暫時(shí)宕機(jī),經(jīng)常被錯(cuò)誤地標(biāo)記為“不可重試”。這樣請求將無限期地“失敗”,并且后續(xù)重試請求將永遠(yuǎn)返回不可重試錯(cuò)誤。相反,如果異常被標(biāo)記為“可重試”(而實(shí)際應(yīng)該是“不可重試”且需要人工干預(yù)),則可能會發(fā)生雙重付款。
通常,我們認(rèn)為由網(wǎng)絡(luò)和基礎(chǔ)架構(gòu)問題(5XX HTTP狀態(tài))導(dǎo)致的意外運(yùn)行時(shí)異常是可重試的。我們希望這些錯(cuò)誤是暫時(shí)的,我們希望稍后重試相同的請求最終會成功。
我們將驗(yàn)證錯(cuò)誤(例如無效輸入和狀態(tài)(例如,您無法退還退款))分類為不可重試(4XX HTTP狀態(tài)) - 我們預(yù)計(jì)同一請求的所有后續(xù)重試都會以相同方式失敗。因此創(chuàng)建了一個(gè)自定義的通用異常類來處理這些情況,默認(rèn)為“不可重試”,對于其他情況,它們被歸類為“可重試”異常。
至關(guān)重要的是,每個(gè)請求的請求有效負(fù)載保持不變并且永遠(yuǎn)不會發(fā)生變化,否則會破壞冪等請求的定義。
當(dāng)然,需要謹(jǐn)慎處理更復(fù)雜的邊緣情況,例如在不同的上下文中適當(dāng)?shù)靥幚鞵ointerException。 例如,由于數(shù)據(jù)庫鏈接暫時(shí)出問題而返回的空值與來自客戶端或來自第三方請求中的錯(cuò)誤空字段不同。
客戶端
正如本文開頭所提到的,在寫修復(fù)系統(tǒng)中客戶端需要更加智能。 在與使用像Orpheus這樣的冪等性庫的服務(wù)進(jìn)行交互時(shí),它必須做到:
-
為每個(gè)新請求傳遞一個(gè)唯一的冪等鍵; 重試的時(shí)候重用相同的冪等鍵。
-
在調(diào)用服務(wù)之前將這些冪等鍵保留在數(shù)據(jù)庫中(以后用于重試)。
-
正確成功響應(yīng)后取消冪等鍵(或者置空)。
-
確保不允許在重試中改變請求有效負(fù)載。
-
根據(jù)業(yè)務(wù)需求仔細(xì)設(shè)計(jì)和配置自動重試策略(使用指數(shù)退避或隨機(jī)等待時(shí)間(“抖動”)以避免驚群問題)。
如何選擇冪等鍵?
選擇冪等鍵是至關(guān)重要的 - 客戶可以根據(jù)要選擇保證請求級冪等性或?qū)嶓w級冪等性。使用什么鍵將取決于業(yè)務(wù),但請求級冪等性是最直接和最常見的。
對于請求級冪等性,應(yīng)從客戶端選擇隨機(jī)且唯一的鍵,以確保整個(gè)實(shí)體集合級別的冪等性。例如,如果我們想要為預(yù)訂允許多種不同的付款方式,我們只需要確保冪等鍵是不同的。 UUID是一個(gè)很好的示例格式。
實(shí)體級冪等性比請求級冪等性更加嚴(yán)格。假設(shè)我們要確保ID為1234的10美元付款只能退還5美元,由于我們可以在技術(shù)上兩次提交5美元的退款申請,所以希望使用基于實(shí)體模型的冪等鍵來確保實(shí)體級的冪等性。示例格式為“payment-1234-refund”。因此,對于唯一付款的每個(gè)退款請求都將在實(shí)體級別保證冪等(付款1234)。
每個(gè)API請求都有到期租約
由于多次用戶點(diǎn)擊或客戶端激進(jìn)的重試策略,可能會觸發(fā)多個(gè)相同的請求。 由于競態(tài)條件,可能會導(dǎo)致多次支付。 為了避免這些情況,在框架的幫助下,每個(gè)API請求都需要獲取冪等鍵上的數(shù)據(jù)庫行級鎖。 這授予給定請求進(jìn)一步繼續(xù)的租約或許可。
租約帶有到期時(shí)間,以涵蓋服務(wù)器端存在超時(shí)的情況。 如果沒有響應(yīng),則在當(dāng)前租約到期后才重試API請求。 應(yīng)用程序可以根據(jù)需要配置租約到期和RPC超時(shí)時(shí)間。 經(jīng)驗(yàn)法則是具有比RPC超時(shí)更長的租約到期時(shí)間。
Orpheus還為冪等鍵提供了一個(gè)最大可重試窗口,以提供安全網(wǎng),以避免意外系統(tǒng)行為導(dǎo)致的惡意重試。
記錄到Response
我們還記錄Response,以維護(hù)和監(jiān)控冪等行為。 當(dāng)客戶端對已達(dá)到確定性最終狀態(tài)的事務(wù)(例如,不可重試的錯(cuò)誤(例如,驗(yàn)證錯(cuò)誤)或成功響應(yīng))發(fā)出相同的請求時(shí),Response將記錄在數(shù)據(jù)庫中。
持久化Response確實(shí)是個(gè)性能權(quán)衡,保證客戶端能夠在后續(xù)重試時(shí)獲得快速響應(yīng),但此表將隨應(yīng)用程序吞吐量增長而增長。 如果我們不小心,該表會變得很臃腫。 解決方案是定期刪除超過特定時(shí)間范圍的數(shù)據(jù),但過早刪除數(shù)據(jù)也會產(chǎn)生負(fù)面影響。 除此之外,開發(fā)人員應(yīng)該謹(jǐn)慎,不要對Response實(shí)體和結(jié)構(gòu)進(jìn)行向后不兼容的更改。
使用主庫
在使用Orpheus讀取和寫入冪等信息時(shí),我們選擇直接從主庫執(zhí)行操作。在分布式數(shù)據(jù)庫系統(tǒng)中,在一致性和延遲之間存在權(quán)衡。由于我們無法容忍高延遲或讀取未提交的數(shù)據(jù),因此使用主庫對我們來說是最有意義的。如果數(shù)據(jù)庫系統(tǒng)沒有配置為強(qiáng)一致性(我們的系統(tǒng)由MySQL支持),那么使用副本進(jìn)行這些操作實(shí)際上可能會對冪等性產(chǎn)生不利影響。
例如,假設(shè)支付服務(wù)將其冪等信息存儲在從庫中。客戶端向支付服務(wù)提交付款請求,該請求最終成功,但客戶端由于網(wǎng)絡(luò)問題而未收到響應(yīng)。雖然當(dāng)前存儲在服務(wù)主庫中的響應(yīng)最終將最終寫入從庫,但是,由于有同步延遲,客戶端可能會進(jìn)行重試,由于同步延遲,服務(wù)可能會錯(cuò)誤地再次執(zhí)行付款,從而導(dǎo)致重復(fù)付款。下面的例子說明了僅僅幾秒鐘的復(fù)制延遲可能會對Airbnb造成重大財(cái)務(wù)影響。
由于復(fù)制延遲導(dǎo)致重復(fù)支付
使用主庫避免重復(fù)支付
當(dāng)使用單個(gè)主數(shù)據(jù)庫保證冪等性時(shí),可伸縮性會成為主要問題。我們通過按照冪等鍵對數(shù)據(jù)庫進(jìn)行分片來緩解這個(gè)問題。我們使用的冪等鍵具有高基數(shù)和均勻分布,使分片更加高效。
最后的想法
許多解決方案都可以緩解分布式系統(tǒng)中的一致性挑戰(zhàn)。 Orpheus是適用于我們的幾種產(chǎn)品之一,因?yàn)樗哂衅毡樾院洼p量級這樣的特性。開發(fā)人員可以在使用新服務(wù)時(shí)簡單地導(dǎo)入類庫,并且將冪等邏輯保存在獨(dú)立于應(yīng)用程序之外的單獨(dú)的抽象層。
如果不引入一些復(fù)雜性,就不可能實(shí)現(xiàn)最終的一致性。客戶端需要存儲和處理冪等鍵并實(shí)現(xiàn)自動重試機(jī)制。開發(fā)人員需要額外的上下文,并且在實(shí)現(xiàn)Java lambda時(shí)必須如外科外科手術(shù)一樣精確。處理異常時(shí)必須慎重。此外,由于當(dāng)前版本的Orpheus經(jīng)過了實(shí)戰(zhàn)考驗(yàn),我們也在不斷尋找改進(jìn)之處:改進(jìn)請求負(fù)載匹配以便進(jìn)行重試,改進(jìn)對數(shù)據(jù)庫模式更改和嵌套遷移的支持,在RPC階段主動限制數(shù)據(jù)庫訪問等等。
原文地址:
https://medium.com/airbnb-engineering/avoiding-double-payments-in-a-distributed-payments-system-2981f6b070bb
本文由方圓翻譯。轉(zhuǎn)載本文請注明出處,歡迎更多小伙伴加入翻譯及投稿文章的行列,詳情請戳公眾號菜單「聯(lián)系我們」。






