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

date
May 10, 2024
slug
learn-from-airbnb-on-how-to-avoid-double-payments-in-a-distributed-system
status
Published
tags
编程开发
summary
在分布式支付系统中,一个常见的挑战是避免双重支付,即同一笔交易被错误地处理两次。简单来说,就是当你在app上点击支付后,因为各种原因使得app上没有收到支付是否成功的结果,此时用户完全可能重复点击支付按钮,如果我们的系统没有很好的处理这种情况,将很可能导致用户被重复扣款,这是非常严重的问题。
type
Post
在分布式支付系统中,一个常见的挑战是避免双重支付,即同一笔交易被错误地处理两次。简单来说,就是当你在app上点击支付后,因为各种原因使得app上没有收到支付是否成功的结果,此时用户完全可能重复点击支付按钮,如果我们的系统没有很好的处理这种情况,将很可能导致用户被重复扣款,这是非常严重的问题。
虽然我没做过支付系统,但是大体也知道如何处理这种情况,核心问题就是保证支付请求的幂等性。所谓的幂等性(Idempotence)是计算机科学和数学中的一个概念,它描述了某些操作的一个特性:这些操作无论执行多少次,其结果都是相同的。而通常保证请求幂等性最简单的方式就是在请求的时候加入幂等key,服务端在处理时从存储里查询这个幂等key是否存在,如果存在则说明已经执行过这个操作,不应该再被重复执行。但具体设计和实现的细节并没有过多思考过,今天看了airbnb的这篇blog(Avoiding Double Payments in a Distributed Payments System),有了更多的理解,因此便想着把自己的理解整理输出出来。
notion image
先简单画一个支付的流程图。用户在客户端(PC Web或者移动端)发起支付,请求会先经过支付系统,支付系统再向支付服务处理器发起请求,完成真正的资金转移。当然,这只是个非常简单的草图,我们刻意隐藏了支付系统以及支付服务处理器的内部细节,例如支付系统除了对外暴露API之外,还要对支付请求进行认证校验,还要对请求进行反欺诈和风控等等。但这些都不是本文的重点,所以我们暂且先忽略这些细节。
支付系统内部需要完成数据查询和写入,还要调用各种外部系统(如PSP),我们知道对于一个分布式系统来说,网络分区是无法避免的,为了让各个过程更加可靠,则可以将一次请求分为三个流程,Pre-RPC、RPC 和 Post-RPC。“RPC”(Remote Procedure Call,即远程过程调用)是一种通信协议,它允许一个程序(客户端)通过网络向另一个程序(服务器)发送请求。在支付请求中,涉及到数据库的读写操作,都应该只在Pre-RPC和Post-RPC这两个阶段来完成,而对外部系统发起的API请求(暂且先认为只会对PSP系统发起API请求),则只能在RPC阶段来实现。
基于这个流程下,我们再来看下客户端发起支付请求后的过程。
notion image
 
客户端会生成一个唯一ID(UUID是一个很好的选择)作为幂等key,这个key可以作为请求的参数之一放在body里,也可以放在请求头里。
支付系统接收到请求后,根据之前的流程设计,按照 Pre-RPC → RPC → Post-RPC 这个流程执行处理。这里我们看到一个很重要的操作,就是重试。整个支付的过程,会涉及到多个组件、服务之间的调用,任何一处都可能发生错误。有些错误是临时的,例如暂时性的网络波动导致的API调用异常。有些错误则是必然的,例如参数错误、请求认证失败。遇到临时性的错误,我们只需要发起重试即可,即重做一遍操作。但遇到的是必然的错误,那无论重试多少次都是徒劳的,只会浪费资源。所以我们的系统要能够去识别,发生错误后什么时候应该重试,什么时候不应该重试。这里举了一些关于哪些操作可以重试和哪些操作不可以重试的例子。
Retryable
Non-retryable
瞬时的错误 - 随后的重试可以产生不同的结果
随后的重试依旧会是相同的结果
内部服务错误
非法的输入和请求
数据库或者网络连接相关异常
未找到记录
5XX HTTP 状态码
不支持的操作
4XX 状态码
在Pre-RPC流程里,系统会根据幂等key去查询数据库中是否已经存在过这个请求,如果没有那就是一个全新的支付请求(上图2a),直接封装支付请求对象然后去进行后续的RPC调用流程即可。
如果已经存在这个请求,那说明之前客户端发起过支付请求,由于各种原因,客户端没有收到支付成功的响应进而发起的重试操作。我们的数据库必须记录请求的结果,根据这个错误的结果,我们可以判断是否是一个可进行重试的请求。若请求可以重试,那么也封装请求对象,进行后续的RPC调用流程(上图2b)。若错误是不可被重试的,那直接封装响应返回给客户端即可(上图2c)。有一种情况是之前的支付是成功了,但是没有把结果给到客户端,此时新发起的请求也是个不可重试的,要直接返回支付成功的结果给到客户端(上图2d)。
接下来就是RPC流程了,这一过程会采用API对外部系统发起调用,我们这里就是发起真正的资金操作请求。这里如果发生错误,也同样可以根据错误的类型进行重试。
关于重试,需要注意的是重试的规则,也可以说是重试策略,其实就是重试时间的间隔。通常会有几种策略:立即重试、固定间隔重试、递增间隔重试、指数退避。另外也需要对重试次数进行限制,避免恶意重试。
最后就是Post-RPC流程了,这个过程主要就是记录RPC流程的结果,支付是成功了还是失败了,失败的原因等等。然后把响应返回给客户端,如果是错误的响应,客户端根据情况判断是否需要发起重试。
这里图里提到了一个操作,释放幂等key的租期。客户端可能会同时发起多个幂等请求,例如用户疯狂的点击支付按钮,此时应该只能有一个请求能够被接受和处理。我们可以借助分布式锁等机制来处理这个问题,获取到锁的请求才允许被进行后续支付处理,锁的有效期就是幂等key的租期。请求处理结束后,我们则需要释放锁(释放幂等key的租期)。
 
在支付这样的分布式系统里,数据库如何设计也非常重要。通常我们使用MySQL,它支持完备的事务能力(ACID),还支持行级锁。假设我们是一个一主两从的数据库,通常会选择写主库,读从库。但是对于支付这种对实时性要求很高的场景,如果系统往主库写了一条支付成功的数据后,从库没有及时同步,导致查询的时候发现并未支付,则很可能会发起再次支付,后果很严重。Airbnb的原文里给出了一个导致主从不一致导致的双重支付的例子。
我们可以将这些实时性要求高的操作的都采用主库来进行读写,这样便不会有主从不一致的情况了。同时可以采用幂等键来进行分区,提高系统的可扩展性。此时幂等key的设计也就非常重要,需要能够均匀的被分配到各个分区上。
当然,也可以把数据库设计成一个强一致性的,例如写主库的时候,必须要求所有从库都同步完成后写操作才算成功,当然这势必影响系统的吞吐能力。
 
总结一下。
我觉得这样的流程,也可以借鉴和复用在其他的分布式系统里。不过我们这里隐藏了很多细节,真实的支付场景肯定没这么简单。还是举重试的操作为例子,通常在重试几次后还是无法成功,我们可以先存储起来(或者放入死信队列),后续我们再通过人工的方式去检查处理。当支付请求的量级很大时,我们也会采用消息队列来进行削峰和异步处理,此时消息队列的可靠性和幂等性也同样需要保证。这些都值得自己去深入思考。
最后再放下Airbnb这篇博客的链接,感兴趣的可以去看看,相信也会有收获。