Airbnb在分布式支付系统中如何避免双重支付?


Airbnb一直在将其基础架构迁移到面向服务的架构(“SOA”)。SOA 提供了许多优点,例如支持开发人员专业化和更快迭代的能力。但是,它也给计费和支付应用程序带来了挑战,因为它使维护数据完整性变得更加困难。对服务的 API 调用对下游服务进行进一步的 API 调用,其中每个服务更改状态并可能产生副作用,相当于执行复杂的分布式事务。
为了确保所有服务之间的一致性,可能会使用诸如两阶段提交之类的协议。如果没有这样的协议,分布式事务对维护数据完整性、允许优雅降级和实现一致性提出了挑战。请求在分布式系统中也不可避免地失败——连接会在某些时候断开和超时,特别是对于包含多个网络请求的事务。
三种不同的常用技术在分布式系统中使用,以实现最终的一致性读repair,repair写入和异步repair。每种方法各有利弊。我们的支付系统在各种功能中使用所有这三种。
异步修复涉及服务器负责运行数据一致性检查,例如表扫描、lambda 函数和 cron 作业。此外,从服务器到客户端的异步通知在支付行业中被广泛使用,以强制客户端的一致性。异步修复和通知可以与读写修复技术结合使用,提供第二道防线,并在解决方案复杂性方面进行权衡。
我们在这篇特定帖子中的解决方案利用了写入修复,其中从客户端到服务器的每次写入调用都试图修复不一致的、损坏的状态。写修复要求客户端更智能(我们稍后将对此进行扩展),并允许它们重复触发相同的请求并且永远不必维护状态(重试除外)。因此,客户可以按需请求最终一致性,从而使他们能够控制用户体验。在实现写修复时,幂等性是一个极其重要的属性。
 
什么是幂等性?
对于幂等的 API 请求,客户端可以重复进行相同的调用,并且结果将相同。换句话说,发出多个相同的请求应该与发出单个请求具有相同的效果。
这种技术通常用于涉及资金流动的计费和支付系统 - 支付请求被完全处理一次(也称为“一次性交付”)至关重要。重要的是,如果多次调用单个转移资金的操作,则底层系统最多应转移资金一次。这对于 Airbnb Payments API 至关重要,以避免向房东多次付款,甚至更糟的是,向客人收取多次费用。
根据设计,幂等性安全地允许来自客户端的多个相同调用使用API的自动重试机制来实现最终一致性。这种技术在具有幂等性的客户端 - 服务器关系中很常见,并且我们今天在分布式系统中使用的东西。
 
问题陈述
保证我们支付系统的最终一致性至关重要。幂等性是在分布式系统中实现这一目标的理想机制。在 SOA 世界中,我们将不可避免地遇到问题。例如,如果客户端未能使用响应,客户端将如何恢复?如果响应丢失或客户端超时怎么办?导致用户点击“Book”两次的竞态条件呢?我们的要求包括以下内容:

  • 我们需要一个通用但可配置的幂等性解决方案,以便在 Airbnb 的各种支付 SOA 服务中使用,而不是实施特定于给定用例的单个自定义解决方案。
  • 在迭代基于 SOA 的支付产品时,我们不能在数据一致性上妥协,因为这会直接影响我们的社区。
  • 我们需要超低延迟,因此构建单独的、独立的幂等服务是不够的。最重要的是,该服务会遇到最初打算解决的相同问题。
  • 由于 Airbnb 正在使用 SOA 扩展其工程组织,让每个开发人员都专注于数据完整性和最终的一致性挑战将是非常低效的。我们希望让产品开发人员免受这些麻烦,让他们能够专注于产品开发并加快迭代速度。

此外,在代码可读性、可测试性和故障排除能力方面的大量权衡都被认为是不可能的。
 
解决方案说明
我们希望能够唯一地识别每个传入的请求。此外,我们需要准确跟踪和管理特定请求在其生命周期中的位置。
我们实施和使用的“Orpheus奥菲斯”,一个通用幂等库, 跨多个支付服务。Orpheus是传说中的希腊神话英雄,Orpheus能够协调和迷惑所有生物。
我们选择了一个库作为解决方案,因为它提供了低延迟,同时仍然在高速产品代码和低速系统管理代码之间提供了清晰的分离。在高层次上,它由以下简单概念组成:
  • 一个幂等键被传递到框架中,代表一个单一的幂等请求
  • 幂等信息表,始终从分片主数据库读取和写入(为了一致性
  • 使用Java lambdas 将数据库事务组合在代码库的不同部分以确保原子性
  • 错误响应被分类为“可重试“ 或者 ”不可重试”

我们将详细介绍具有幂等性保证的复杂分布式系统如何能够自我修复并最终保持一致。我们还将介绍我们的解决方案中应该注意的一些权衡和额外的复杂性。
 
将数据库提交保持在最低限度
幂等系统的关键要求之一是只产生两种结果,成功或失败,并具有一致性。否则,数据的偏差可能导致数小时的调查和错误的付款。由于数据库提供ACID属性,因此可以有效地使用数据库事务以原子方式写入数据,同时确保一致性。可以保证数据库提交作为一个单元成功或失败。
Orpheus 的核心假设是几乎每个标准 API 请求都可以分为三个不同的阶段:Pre-RPC、RPC 和 Post-RPC。
“RPC”或远程过程调用是指客户端向远程服务器发出请求并等待该服务器在恢复其进程之前完成请求的过程。在支付 API 的上下文中,我们将 RPC 称为对网络下游服务的请求,其中可以包括外部支付处理器和收单银行。简而言之,这是每个阶段发生的事情:
  1. Pre-RPC:支付请求的详细信息记录在数据库中。
  2. RPC:通过网络向外部服务发出请求并接收响应。这是一个进行一个或多个幂等计算或 RPC 的地方(例如,如果是重试尝试,则首先查询事务状态的服务)。
  3. Post-RPC:来自外部服务的响应的详细信息记录在数据库中,包括其成功以及错误请求是否可重试。

为了保持数据完整性,我们遵守两个简单的基本规则:
  1. 在 Pre 和 Post-RPC 阶段没有网络上的服务交互
  2. RPC 阶段没有数据库交互

我们本质上希望避免将网络通信与数据库工作混合在一起。我们已经了解到,在 Pre 和 Post-RPC 阶段的网络调用 (RPC) 很容易受到攻击,并且可能导致诸如快速连接池耗尽和性能下降之类的坏事。简单地说,网络呼叫本质上是不可靠的。因此,我们将 Pre 和 Post-RPC 阶段包装在封闭由库本身启动的数据库事务中。
我们还想指出单个 API 请求可能包含多个 RPC。Orpheus 确实支持多 RPC 请求,但在这篇文章中,我们只想用简单的单 RPC 案例来说明我们的思考过程。
如下图示例所示, 每个 Pre-RPC 和 Post-RPC 阶段中的每个数据库提交都合并为一个数据库事务。这确保了原子性——整个工作单元(这里是 Pre-RPC 和 Post-RPC 阶段)可以作为一个单元一致地失败或成功。动机是系统应该以一种可以恢复的方式失败。例如,如果多个 API 请求在一系列数据库提交过程中失败,那么系统地跟踪每个失败发生的位置将是极其困难的。请注意,所有网络通信,即 RPC,都与所有数据库事务显式分开。

这里的数据库提交包括幂等库提交和应用层数据库提交,所有这些都组合在同一个代码块中。如果不小心,这实际上可能会在实际代码中看起来非常混乱(意大利面条,有人吗?)。我们还认为,调用某些幂等例程不应该是产品开发人员的责任。
 
拯救 Java 的 Lambda
值得庆幸的是,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.
 }
}

这些关注点的分离确实提供了一些权衡。随着其他新代码的不断贡献,开发人员必须使用深谋远虑来确保代码的可读性和可维护性。他们还需要始终如一地评估正确的依赖关系和数据是否被传递。API 调用现在需要重构为三个较小的块,这可能会限制开发人员编写代码的方式。将一些复杂的 API 调用有效地分解为三步方法实际上可能真的很困难。我们的一项服务使用StatefulJ实现了一个有限状态机,每次转换都作为幂等步骤,您可以在其中安全地在 API 调用中复用幂等调用。
 
处理异常——重试还是不重试?
使用像 Orpheus 这样的框架,服务器应该知道什么时候可以安全地重试请求,什么时候不可以。为此,应谨慎处理异常——它们应归类为“可重试”或“不可重试”。这无疑给开发人员增加了一层复杂性,如果他们不明智和谨慎,可能会产生不良的副作用。
例如,假设下游服务暂时离线,但引发的异常被错误地标记为“不可重试”,而实际上它应该是“可重试的”。请求将无限期地“失败”,随后的重试请求将永远返回不正确的不可重试错误。相反,如果异常被标记为“可重试”,而实际上应该“不可重试”并且需要人工干预,则可能会发生双重支付。
一般来说,我们认为由于网络和基础设施问题(5XX HTTP 状态)导致的意外运行时异常是可以重试的。我们希望这些错误是暂时的,我们希望稍后重试同一请求最终可能会成功。
我们将验证错误(例如无效的输入和状态(例如,您无法退款)归类为不可重试(4XX HTTP 状态)——我们希望同一请求的所有后续重试都以相同的方式失败。我们创建了一个自定义的通用异常类来处理这些情况,默认为“不可重试”,对于某些其他情况,归类为“可重试”。
每个请求的请求负载必须保持不变并且永远不会发生变化,否则会破坏幂等请求的定义。
 
客户端起着至关重要的作用
正如本文开头所提到的,客户端在写修复系统中必须更加智能。在与使用 Orpheus 等幂等库的服务交互时,它必须承担几个关键职责:

  • 为每个新请求传入唯一的幂等密钥;重试时重用相同的幂等密钥。
  • 在调用服务之前将这些幂等键保留到数据库中(以供以后重试使用)。
  • 正确使用成功的响应并随后取消分配(或取消)幂等键。
  • 确保不允许在重试尝试之间更改请求有效负载。
  • 根据业务需求仔细设计和配置自动重试策略(使用指数退避随机等待时间(“抖动”)以避免雷鸣般的羊群问题)。

  
如何选择幂等键?
选择幂等性key至关重要——客户端可以根据使用的密钥选择具有请求级幂等性或实体级幂等性。使用一种或另一种的决定取决于不同的业务用例,但请求级幂等性是最直接和最常见的。
对于请求级别的幂等性,应该从客户端中选择一个随机且唯一的密钥,以确保整个实体集合级别的幂等性。例如,如果我们希望允许对预订进行多次不同的支付(例如Pay Less Upfront),我们只需要确保幂等键不同。UUID 是用于此目的的一个很好的示例格式。
实体级幂等性比请求级幂等性严格得多。假设我们要确保给定的 10 美元带有 ID 的付款1234只会被退还一次 5 美元,因为我们在技术上可以两次提出 5 美元的退款请求。然后,我们希望使用基于实体模型的确定性幂等键来确保实体级幂等性。一个示例格式是“payment-1234-refund”. 因此,针对唯一付款的每个退款请求在实体级别 ( Payment 1234)都是幂等的。
 

每个 API 请求都有一个即将到期的租约
由于多次用户点击或客户端具有积极的重试策略,可能会触发多个相同的请求。这可能会在服务器上造成竞争条件或为我们的社区造成双重支付。为了避免这些,在框架的帮助下,每个 API 调用都需要在幂等键上获取数据库行级锁。这为给定的请求授予进一步处理的租约或许可。
租约带有到期时间,以涵盖服务器端超时的情况。如果没有响应,则只有在当前租用到期后才能重试 API 请求。应用程序可以根据自己的需要配置租约到期和 RPC 超时。一个好的经验法则是拥有比 RPC 超时更高的租约到期时间。
Orpheus 还为幂等键提供了最大可重试窗口,以提供安全网,以避免意外系统行为引起的恶意重试。
  
记录响应
我们还记录响应,以维护和监控幂等行为。当客户端对已达到确定性结束状态的事务发出相同请求时,例如不可重试错误(例如验证错误)或成功响应,该响应将记录在数据库中。
持久响应确实有一个性能权衡——客户端能够在后续重试时收到快速响应,但该表的增长将与应用程序吞吐量的增长成正比。如果我们不小心,这张桌子很快就会变得臃肿。一种可能的解决方案是定期删除早于特定时间范围的行,但过早删除幂等响应也会产生负面影响。开发人员还应注意不要对响应实体和结构进行向后不兼容的更改。
 
避免副本数据库——坚持主数据库
在用 Orpheus 读写幂等信息时,我们选择直接从 主数据库中进行。在分布式数据库系统中,需要在一致性和延迟之间进行权衡。由于我们无法容忍高延迟或读取未提交的数据,因此对这些表使用主数据库对我们来说最有意义。这样做时,不需要使用缓存或数据库副本。如果数据库系统没有配置为强读取一致性(我们的系统由 MySQL 支持),从幂等性的角度来看,使用副本进行这些操作实际上可能会产生不利影响。
例如,假设支付服务将其幂等性信息存储在副本数据库中。客户端向服务提交付款请求,最终在下游成功,但由于网络问题,客户端没有收到响应。当前存储在服务的主数据库中的响应最终将写入副本。但是,在副本滞后的情况下,客户端可以正确地向服务发起幂等重试,并且响应还不会记录到副本中。因为响应“不存在”(在副本上),服务可能会错误地再次执行支付,导致重复支付。下面的例子说明了仅仅几秒钟的复制延迟可能会对 Airbnb 社区造成重大的财务影响。
当使用单个主数据库实现幂等性时,很明显,扩展无疑会迅速成为一个问题。我们通过幂等键对数据库进行分片来缓解这种情况。我们使用的幂等键具有高基数和均匀分布,使其成为有效的分片键。
 
最后的想法
有无数不同的解决方案可以缓解分布式系统中的一致性挑战。Orpheus 是对我们来说效果很好的几个之一,因为它具有通用性和轻量级。开发人员可以在处理新服务时简单地导入库,幂等性逻辑保存在应用程序特定概念和模型之上的一个单独的抽象层中。
然而,要实现最终的一致性,就必须引入一些复杂性。客户端需要存储和处理幂等密钥并实现自动重试机制。开发人员需要额外的上下文,并且在实现和排除 Java lambdas 故障时必须非常精确。他们在处理异常时必须慎重。此外,由于当前版本的 Orpheus 已经过实战测试,我们一直在寻找需要改进的地方:重试的请求负载匹配、改进对架构更改和嵌套迁移的支持、在 RPC 阶段主动限制数据库访问等。
虽然这些是最重要的考虑因素,但到目前为止,Orpheus 从哪里获得了 Airbnb 付款?自从推出框架,我们已经取得一致性五个九对我们的付款,而我们每年支付量已同时增加了一倍。