支付核心系统设计:Airbnb的分布式事务方案简介
导读:微服务架构下的支付系统,由于其需要在性能和一致性之间做很多权衡,带来设计和实现的复杂性。Airbnb的支付系统需要对接全球很多个国家的支付系统,因此带来很大的复杂性。本文详细论述了Airbnb如何使用分布式事务的相关技术来保证支付系统的数据一致性和性能,十分值得一读。
过去几年中,Airbnb一直在将其基础架构迁移到SOA。相比单体应用,SOA提供了许多优势,例如支持开发人员专业化和加速迭代的能力。然而,这也对计费和支付程序提出了挑战,因为SOA使维护数据完整性变得更加困难。对服务API的调用,该服务对下游服务进行进一步的API调用,其中每个服务改变状态都可能具有副作用,等同于执行复杂的分布式事务。
为了确保所有服务之间的数据一致性,可以使用两阶段提交之类的协议。如果不用这样的协议,数据一致性就难以保证。在分布式系统中请求不可避免地会失败(连接会在某些时候丢失并超时,尤其是对于包含多个网络请求的事务。
分布式系统中使用三种不同的常用技术来实现最终的一致性:读修复,写修复和异步修复。每种方法各有利弊。三种方式在我们的支付系统中都有使用。
异步修复通过服务器负责运行数据一致性检查来实现,例如表扫描,lambda函数和cron job。此外,从服务器到客户端的异步通知广泛用于支付行业,以保持客户端的一致性。异步修复以及通知可以与读写修复技术结合使用,提供第二道防线,并在解决方案复杂性方面起到作用。
本文中描述的解决方案使用了写修复,其中从客户端到服务器的每次写入调用都尝试修复不一致状态。写修复要求客户端更加智能(稍后我们将对此进行扩展讨论),并允许重复发出相同的请求,而不必维护状态(除了重试)。因此,客户端可以按自己的需求来达到最终的一致性,从而使他们能够控制用户体验。在实现写修复时,幂等性是一个非常重要的属性。
什么是幂等?
API请求具有幂等性即客户端可以重复进行相同的调用,结果将是相同的。换句话说,发出多个相同的请求应该与发出单个请求具有相同的效果。
这种技术通常用于涉及资金流动的计费和支付系统,即支付请求必须完全处理一次(也称为“确切一次交付”)。重要的是,如果多次调用移动资金操作,系统最多只能移动一次资金。这对Airbnb Payments API至关重要,以避免多次支付。
幂等性允许来自客户端的多个相同请求使用API的自动重试机制来达到最终一致性。这种方式在具有幂等性的客户端 - 服务器中是常见的,并且在我们的系统中也是如此。
下图说明了重复请求和理想幂等行为的简单场景。无论收费多少,客户最多支付一次费用。
问题描述
保证我们的支付系统最终的一致性至关重要。幂等性是在分布式系统中实现这一点的理想机制。在SOA世界中,我们将不可避免地遇到问题。例如,假如服务没有响应,客户端将如何恢复?如果Response丢失或客户超时怎么办?如果竞争条件导致用户点击“预订”两次呢?我们的需求包括:
我们需要一个通用但可配置的幂等解决方案,而不是实现针对特定用例的自定义解决方案,以便在Airbnb的各种支付服务中使用。
虽然正在迭代基于SOA的支付产品,但我们无法在数据一致性上妥协。
我们需要超低延迟,因此构建单独的幂等服务不能满足延迟要求。最重要的是,该服务将遇到上述问题。
随着Airbnb使用SOA扩展其工程组织,让每个开发人员专注于数据完整性和最终的一致性是非常低效的。我们希望业务开发免受这些麻烦,保证他们能够专注于产品开发并更快地进行迭代。
此外,代码可读性,可测试性和故障排除能力的相当大的权衡被认为是非主导因素。
解决方案
我们希望能够唯一地识别每个请求。此外,我们需要准确跟踪和管理特定请求在其生命周期中的位置。
我们在多种支付服务中实施并使用了“Orpheus”,这是一种通用的幂等库。Orpheus是传说中的希腊神话英雄。
我们选择了实现幂等库作为解决方案,因为它提供低延迟,同时仍然提供高速变更的产品代码和低速变更的系统管理代码之间的隔离。在高层次上,它包含以下:
幂等key被传递到框架中,表示单个幂等请求
始终从主数据库读取和写入(为了一致性)幂等信息表
通过使用Java lambda组合数据库事务,确保原子性
错误被分类为“可重试”或“不可重试”
接下来我们将详细说明具有幂等性保证的复杂分布式系统如何能够自我修复并达到最终一致。我们还将介绍一些该方案应该注意的设计权衡和带来的额外的复杂性。
最小化数据库提交
幂等系统的关键要求之一是只产生两个结果,即成功或失败,具有一致性。否则,数据有偏差可能导致数小时排查错误时间和付款出问题。由于数据库提供ACID属性,因此数据库事务可以有效地用于原子写入,确保一致性。一次数据库提交可以保证其作为一个单元的一致性。
Orpheus假设每个标准API请求都分为三个不同的阶段:Pre-RPC,RPC和Post-RPC。
“RPC”是指客户端向远程服务器发出请求并等待该服务器响应的过程。在支付API的上下文中,我们将RPC称为对下游服务的请求,其可以包括外部支付服务和收单银行等。简而言之,如下是每个阶段发生的事情:
Pre-RPC:付款请求的详细信息记录在数据库中。
RPC:请求通过网络对外部服务进行实时处理,并收到响应。这是一个执行幂等计算或RPC的过程(例如,如果是重试尝试,则首先查询事务状态)。
Post-RPC:来自外部服务的响应的详细信息记录在数据库中,包括其是否成功以及错误请求是否可重试。
为了保持数据完整性,我们遵循两个基本规则:
在Pre-RPC和Post-RPC阶段,没有远程服务交互
RPC阶段中没有数据库交互
我们希望避免将网络调用与数据库操作混在一起。在pre-RPC和post-RPC阶段网络调用(RPC)易受攻击的,并可能导致连接池快速耗尽和性能下降之类的不良后果。简而言之,网络调用本质上是不可靠的。因此,我们将Pre和Post-RPC阶段处理数据库事务。
我们还想要说明单个API请求可能包含多个RPC。 Orpheus支持多RPC请求,但在这篇文章中,我们只想用简单的单RPC案例来说明我们的思考过程。
如下面的示例图所示,所有Pre-RPC和Post-RPC阶段中的数据库提交都合并为一个数据库事务,这确保了原子性 。动机是系统应该以可恢复的方式出现故障。例如,如果在多次数据库提交过程中有几次失败,那么系统地跟踪每个失败发生的位置将非常困难。请注意,所有网络通信(RPC)都与数据库事务明确分开。
这里的数据库提交包括幂等库的数据库提交和应用程序数据库提交,所有这些提交都组合在同一个代码块中。 如果不小心组织,这里的代码就会非常混乱。 我们认为产品开发人员不应该负责保证幂等库的操作。
Java Lambdas 组合事务
值得庆幸的是,Java lambda表达式可以将多个提交无缝地组合成单个数据库事务,也不会影响可测试性和代码可读性。
下面是一个示例,简化了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 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 <R extends Object, S extends Object, A extends IdempotencyRequest> Response process(
String idempotencyKey,
UriInfo uriInfo,
SetupExecutable<A> preRpcExecutable, // Pre-RPC lambda
ProcessExecutable<R, A> rpcExecutable, // RPC lambda
PostProcessExecutable<R, S> postRpcExecutable) // Post-RPC lambda
throws YourCustomException {
try {
// Find previous request (for retries), otherwise create
IdempotencyRequest 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 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.
}
}
我们没有实现嵌套数据库事务,而是将Orpheus和应用程序中的数据库指令组合成单个数据库事务,传递Java 闭包。
开发人员必须预先考虑好,才能保代码的可读性和可维护性。 他们还需要始终如一地评估适当的依赖关系和数据传递。 现在需要将API调用重构为三个部分,这可能会限制开发人员编写代码的方式。 实际上,某些复杂的API调用实际上很难有效地分解为三步。 我们的服务实现了一个有限状态机,每次转换都是使用StatefulJ的幂等步骤,可以在API调用中安全地复用幂等调用。
处理异常 - 重试还是不重试?
使用像Orpheus这样的框架,服务器应该知道何时可以重试请求,而何时不行。要做到这一点,应该以细致的处理异常,异常被分类为“可重试”或“不可重试”两大类。这无疑为开发人员增加了一层复杂性,如果他们错误使用,就会产生副作用。
例如,假设下游服务暂时宕机,经常被错误地标记为“不可重试”。这样请求将无限期地“失败”,并且后续重试请求将永远返回不可重试错误。相反,如果异常被标记为“可重试”(而实际应该是“不可重试”且需要人工干预),则可能会发生双重付款。
通常,我们认为由网络和基础架构问题(5XX HTTP状态)导致的意外运行时异常是可重试的。我们希望这些错误是暂时的,我们希望稍后重试相同的请求最终会成功。
我们将验证错误(例如无效输入和状态(例如,您无法退还退款))分类为不可重试(4XX HTTP状态) - 我们预计同一请求的所有后续重试都会以相同方式失败。因此创建了一个自定义的通用异常类来处理这些情况,默认为“不可重试”,对于其他情况,它们被归类为“可重试”异常。
至关重要的是,每个请求的请求有效负载保持不变并且永远不会发生变化,否则会破坏幂等请求的定义。
当然,需要谨慎处理更复杂的边缘情况,例如在不同的上下文中适当地处理NullPointerException。 例如,由于数据库链接暂时出问题而返回的空值与来自客户端或来自第三方请求中的错误空字段不同。
客户端
正如本文开头所提到的,在写修复系统中客户端需要更加智能。 在与使用像Orpheus这样的幂等性库的服务进行交互时,它必须做到:
为每个新请求传递一个唯一的幂等键; 重试的时候重用相同的幂等键。
在调用服务之前将这些幂等键保留在数据库中(以后用于重试)。
正确成功响应后取消幂等键(或者置空)。
确保不允许在重试中改变请求有效负载。
根据业务需求仔细设计和配置自动重试策略(使用指数退避或随机等待时间(“抖动”)以避免惊群问题)。
如何选择幂等键?
选择幂等键是至关重要的 - 客户可以根据要选择保证请求级幂等性或实体级幂等性。使用什么键将取决于业务,但请求级幂等性是最直接和最常见的。
对于请求级幂等性,应从客户端选择随机且唯一的键,以确保整个实体集合级别的幂等性。例如,如果我们想要为预订允许多种不同的付款方式,我们只需要确保幂等键是不同的。 UUID是一个很好的示例格式。
实体级幂等性比请求级幂等性更加严格。假设我们要确保ID为1234的10美元付款只能退还5美元,由于我们可以在技术上两次提交5美元的退款申请,所以希望使用基于实体模型的幂等键来确保实体级的幂等性。示例格式为“payment-1234-refund”。因此,对于唯一付款的每个退款请求都将在实体级别保证幂等(付款1234)。
每个API请求都有到期租约
由于多次用户点击或客户端激进的重试策略,可能会触发多个相同的请求。 由于竞态条件,可能会导致多次支付。 为了避免这些情况,在框架的帮助下,每个API请求都需要获取幂等键上的数据库行级锁。 这授予给定请求进一步继续的租约或许可。
租约带有到期时间,以涵盖服务器端存在超时的情况。 如果没有响应,则在当前租约到期后才重试API请求。 应用程序可以根据需要配置租约到期和RPC超时时间。 经验法则是具有比RPC超时更长的租约到期时间。
Orpheus还为幂等键提供了一个最大可重试窗口,以提供安全网,以避免意外系统行为导致的恶意重试。
记录到Response
我们还记录Response,以维护和监控幂等行为。 当客户端对已达到确定性最终状态的事务(例如,不可重试的错误(例如,验证错误)或成功响应)发出相同的请求时,Response将记录在数据库中。
持久化Response确实是个性能权衡,保证客户端能够在后续重试时获得快速响应,但此表将随应用程序吞吐量增长而增长。 如果我们不小心,该表会变得很臃肿。 解决方案是定期删除超过特定时间范围的数据,但过早删除数据也会产生负面影响。 除此之外,开发人员应该谨慎,不要对Response实体和结构进行向后不兼容的更改。
使用主库
在使用Orpheus读取和写入幂等信息时,我们选择直接从主库执行操作。在分布式数据库系统中,在一致性和延迟之间存在权衡。由于我们无法容忍高延迟或读取未提交的数据,因此使用主库对我们来说是最有意义的。如果数据库系统没有配置为强一致性(我们的系统由MySQL支持),那么使用副本进行这些操作实际上可能会对幂等性产生不利影响。
例如,假设支付服务将其幂等信息存储在从库中。客户端向支付服务提交付款请求,该请求最终成功,但客户端由于网络问题而未收到响应。虽然当前存储在服务主库中的响应最终将最终写入从库,但是,由于有同步延迟,客户端可能会进行重试,由于同步延迟,服务可能会错误地再次执行付款,从而导致重复付款。下面的例子说明了仅仅几秒钟的复制延迟可能会对Airbnb造成重大财务影响。
由于复制延迟导致重复支付
使用主库避免重复支付
当使用单个主数据库保证幂等性时,可伸缩性会成为主要问题。我们通过按照幂等键对数据库进行分片来缓解这个问题。我们使用的幂等键具有高基数和均匀分布,使分片更加高效。
最后的想法
许多解决方案都可以缓解分布式系统中的一致性挑战。 Orpheus是适用于我们的几种产品之一,因为它具有普遍性和轻量级这样的特性。开发人员可以在使用新服务时简单地导入类库,并且将幂等逻辑保存在独立于应用程序之外的单独的抽象层。
如果不引入一些复杂性,就不可能实现最终的一致性。客户端需要存储和处理幂等键并实现自动重试机制。开发人员需要额外的上下文,并且在实现Java lambda时必须如外科外科手术一样精确。处理异常时必须慎重。此外,由于当前版本的Orpheus经过了实战考验,我们也在不断寻找改进之处:改进请求负载匹配以便进行重试,改进对数据库模式更改和嵌套迁移的支持,在RPC阶段主动限制数据库访问等等。
原文地址:
https://medium.com/airbnb-engineering/avoiding-double-payments-in-a-distributed-payments-system-2981f6b070bb
本文由方圆翻译。转载本文请注明出处,欢迎更多小伙伴加入翻译及投稿文章的行列,详情请戳公众号菜单「联系我们」。
参考阅读:
技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。转载请注明来自高可用架构「ArchNotes」微信公众号及包含以下二维码。
高可用架构
改变互联网的构建方式
长按二维码 关注「高可用架构」公众号