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称为对网络上下游服务的请求,该服务可以包括外部支付处理器和收单银行。 简而言之,这是每个阶段发生的事情:

  1. RPC之前:付款请求的详细信息记录在数据库中。
  2. RPC:通过网络使该请求对外部服务生效,并收到响应。 在这里可以进行一个或多个幂等计算或RPC(例如,如果尝试重试,则要先query这个交易的状态)。
  3. RPC后:来自外部服务的响应的详细信息记录在数据库中,包括其成功以及错误请求是否可重试。
点评:这里有2次落库,一次拿到响应。这中间任意点都可发生失败。

为了保持数据完整性,我们遵守两个简单的基本规则:

  1. 在RPC之前和之后的阶段中,没有网络上的服务交互
  2. 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表达式可用于将多个句子无缝地组合到单个数据库事务中,而不会影响可测试性和代码可读性。
下面是个例子,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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);
     });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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状态),我们希望同一请求的所有后续重试都将以相同的方式失败。 我们创建了一个自定义的通用异常类来处理这些情况,默认为“不可重试”,对于某些其他情况,分类为“可重试”。

至关重要的是,每个请求的请求有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?自该框架启动以来,我们的付款一致性达到了五个九,而我们的年度付款量同时翻了一番(如果您想了解有关我们如何大规模衡量数据完整性的信息,请阅读此信息)。

我的思考与启示

  1. 了解了实现最终一致性的3个手段,读修复,异步修复,写修复。异步修复可以做第二道防线。
  2. 低延迟的幂等LIBRARY设计,把幂等的框架统一化。
  3. 抽象出一个支付请求的模板,使得RPC前后的数据库操作以优雅的方式写进一个事务。
  4. 为了解决支付业务的复杂性和框架的简单性之间的GAP,运用STATEFUL J这个工具来实现。
  5. RETRY 和NOT RETRY的情况,和显式回传给客户端。
  6. 实体级幂等和请求级幂等
  7. 为了避免不一致采用只在MASTER上操作,而选取高基数的幂等KEY 和 做分片以缓解压力。
  8. 避免惊群效应,需要指数回退或者随机等待的重试策略
  9. 幂等KEY的租约和底层框架的最大重试窗口 来保护系统不会HANDLE很多无用的请求。