TL;DR
订单最后都要关联到上层支付系统(如微信/支付宝/Apple Pay)之上,使用 HTTP API 进行操作时会存在未知状态,可以通过实现各个操作之间的幂等接口,结合回调与状态查询,实现各层系统之间状态最终一致。
出现不一致的原因
状态一致性的引发的原因很多,从操作方式的角度来看,通过网络进行 API 操作,需要直面网络的不可靠性,100%成功的网络调用几乎是不可能的。可怕的不是操作直接返回失败,而是比如网络超时、网络设备异常等情况下引起的无回应的不明状态。
从分层的角度来说,分层能做到系统解耦,提升扩展性。但是上游支付系统,支付层,下游应用,各自都有状态。即便是网络请求正常,各自分层同步状态时,可以通过数据库事务等方式保证同层状态的一致性,但是本地状态更新也会存在异常的可能性,比如数据库因为硬件原因操作失败。
一旦上游进入不明状态,下游就无法进行状态迁移,而下游的下游自然也无法确认应当如何操作,状态不一致也可能会导致多个分层内的状态流转的停滞。
作为在线售卖系统,一个交易不可能无限制的进行等待。一是资源不允许,二是用户不允许。在一篇08年文章 The Psychology of Web Performance 中提到,一个无反馈的网页2s钟打开是最佳的,6-8s就让用户难以忍受。这仅仅是08年的数据。即便是对于涉及金钱交易,用户可能耐心很充足,然而想象一下每次购买支付需要等待近1min,那也是一个很难以接受的结果。但是,交易过程中,不是所有上下游都能迅速的返回结果,超时之后,也许上游状态已成功,而下游还在处理中。
从支付接口开始
如果不知道状态同步可以从何做起,不妨从支付宝接口文档与微信支付接口文档之中找找灵感,分析支付上游是如何与它的下游系统实现状态同步的。
阅读微信支付文档,提到:
支付完成后,微信会把相关支付结果和用户信息发送给商户,商户需要接收处理,并返回应答。
同样的,支付宝文档中也提到:
对于手机网站支付产生的交易,支付宝会根据原始支付API中传入的异步通知地址notify_url,通过POST请求的形式将支付结果作为参数通知到商户系统。
这些支付平台都相当依赖回调完成一个支付的状态通知和确认工作。回调分为同步和异步两种,同步回调实际上是调用接口时上游逻辑结束之后,根据调用方提供的url,进行的接口调用操作,告知状态;而异步回调则类似,但是可能为了防止调用失败,多次调用。
这里有一个大前提,对于用户资金的操作,必须是 All or never 的。用户的资金在一次支付中,只能支付一次,因为状态不明确而进行的贸然尝试引起的问题,在交易系统中是不可接受的。
作为下游,通过本地事务保证了本地状态的正确迁移,从订单创建状态转移到待支付,现在需要开始支付,需要获知实际管理资金的上游是否正确的完成了资金的迁移操作,与上游的通信一般通过 HTTP API 完成。
假定上游也通过一个本地事务完成扣款操作,那么 HTTP API 就能返回支付状态,下游就能获知成功与否。但是, HTTP API 超时的情况下,下游是不能得知上游的实际处理情况的,此时本地状态到底是从待支付迁移到何种状态呢?
再者,在上游的账户处于分布式的环境下,不能通过一个本地事务实现资金的迁移操作,或者是支付上游也需要等待它的上游返回明确状态时(如微博支付需要等待支付宝返回扣款结果,支付宝等待银行返回扣款结果),此时本地状态又该何去何从?
BASE
作为在线售卖系统的先驱之一,eBay 的工程师 Dan Pritchett 在08年的的一篇文章中,提出了大家耳熟能详的 BASE
概念,其中的 S
指代的是 Soft state
,即软状态
,即对于大规模的分布式系统,允许系统存在中间状态(未知状态)。
软状态,在实际的处理中,可以表示为处理中
,表示的是对上游状态的未知,处于这一状态的订单,不应向上游发起支付或者取消操作。
上述情况都是系统走入到中间状态的 cases,那么破解这个状态流转的困境的方法是什么呢?答案就是上游主动通知,也就是常见的回调。
通过回调,上游告知了明确的状态,驱动了本地状态的正常流转。
回调,是在现有业务模型之下,保持的上下游状态同步的有力工具。
如果上游能提供查询状态的接口,处于这个状态的的订单也可选择向上游主动查询自己的支付状态,然后通过本地事务修改状态,实现与上游状态的同步。主动查询可以提高状态同步的时效性。比如支付宝提供了状态查询接口alipay.trade.query
。
回调
与主动查询
,能够帮助订单在一定时间之后明确的了解自身应当设定为何种状态,做到了 BASE
中的 E
,即 Eventual consistency。
订单系统做到 BASE
是目前的一个更合理的选择。
实现订单成功支付需要做什么
正常的一个售卖行为,最终的目的是购买者下单并支付成功,服务方收到款项并交付商品到购买者。
这里先只讨论订单到支付成功的过程。
依赖关系
从依赖关系来看:
- 一个售卖系统,自己的业务系统,依赖上层支付系统,即类似微信/支付宝/Apple Pay这样的平台,
- 支付系统依赖的是各个银行(后续将会依赖网联,不再直连各个银行),即国有五大行和各个民营银行
- 银行依赖的则是央行,各个银行进行结算。
也就是说,用户的每一次支付操作,都会逐步上升,每一个阶段都有自身的状态,上下级之间需要进行状态的同步,保持一致。
此外,无论是依赖的外部资源,还是业务系统内部,都会可能存在资源独立部署,无法实现类似数据库的 ACID 操作的效果。内部分层也会出现状态同步问题。
综上,从依赖关系分析,每增加一个系统层级,就要新增一套异步回调通知的机制。
操作步骤
从操作步骤上来看,假定通信通过HTTP API,数据存储在 MySQL 中,转化为一个技术操作,可以是:
- 用户从客户端(Web/App)发出下单请求,选择商品,提供其他相关信息(地址,电话,收件人等等)
- 业务 Server 收到请求创建订单,订单创建,状态为
待支付
- 业务 Server 创建回调 url,驱使客户端跳转到上层支付系统支付页
- 用户在支付页支付,根据上一步提供的回调 url,跳转到回调 url
- 业务 Server 收到访问回调 url 的请求,根据回调的信息,修改对应订单的状态从
待支付
到已支付
或者支付失败
- 一旦任何步骤发生未响应/超时/异常情况,需要将订单设定为预留的软状态——处理中。处理中的订单,择机重试或者等待上游通知或者两种手段互相结合。
综上:
- 因为存在多层级,需要实现多套上行的状态同步逻辑与下行的回调通知逻辑
- 因为存在未知情况和长时操作,状态机需要设定软状态
- 因为会出现重试,需要实现幂等的接口
面向异常编程
看起来,需要实现的功能并不复杂,而且事实上,一个应用在绝大多数情况下,都能正常工作。日常中的支付,常常能在一次“同步”的操作过程中就能彻底完成。
一个应用,如果只处理正常的业务流程,是不完整的,因为在正常业务的各个阶段,都隐藏着失败的可能。
从不应该信任用户的输入这类问题说起的话,bad cases 就是在太多了。从这篇标题出发,引起状态不一致的情况,如果业务 Server 直连支付系统,光回调操作就可能会有如下问题:
- 用户第三方支付钱包余额充足,扣款成功,回调业务 Server 因网络原因失败,订单状态此时应为
已成功
,然而仍处于待支付
- 用户第三方支付钱包余额充足,扣款成功,回调业务 Server 请求成功到达,然而数据库操作连接失败,订单状态此时应为
已成功
,然而仍处于待支付
- 用户第三方支付钱包余额不足,支付平台向银行申请扣款,银行扣款成功,但是第一次回调支付平台失败,支付平台此时未回调业务 Server,订单状态此时应为
已成功
,然而仍处于待支付
支付的流程越长,中间节点越多,各个节点之间只要任何一环出现问题,未能将请求结果返回,就会让下层节点无法获知上层节点的状态,出现状态不一致的问题。
所以,在处理状态一致问题时,实际上就是面向各个环节的异常情况进行编程。
实现方案
按照前文提到的需要处理的三大问题,以下的篇幅会针对两级分层方案(业务层
与支付层
)尝试给出自己的理解。
上行逻辑与下行逻辑
一次支付的完整步骤示意图如下:
1 | +-----------+ New Order +-----------+ New Pay Order +-------------------+ |
上行状态同步逻辑
上行逻辑的核心在于通过本地事务实现层内的状态一致,本地先记录数据,然后再尝试驱动上层逻辑。目前只考虑支付一种业务场景,退款等场景的状态变迁后续文章再进行讨论。
业务层
订单的状态流转顺序为:
- 待支付(订单写入数据库)
- 处理中(
支付层
订单进入处理中
状态) - 支付成功(收到
支付层
的支付成功通知) - 支付失败(收到
支付层
的支付失败通知) - 关单(订单有效期已过且未支付)
支付层
订单的状态流转顺序为:
- 待支付(收到
业务层
创建支付层
订单请求,订单写入数据库) - 处理中(支付上游进入
处理中
状态) - 支付成功(收到支付上游的支付成功通知)
- 支付失败(收到支付上游的支付失败通知)
- 关单(订单有效期已过且未支付)
- 首先在
业务层
创建订单,通过发号器获取唯一的业务层
订单ID A,此处可以通过前后端设定一个请求编号防止重复下单,当两个相同的请求编号的请求到来,拒绝其中之一。将业务层
订单 A 写入数据库,本步骤失败则告知用户下单失败; - 步骤1成功之后,使用 A 作为幂等操作的外部ID,调用
支付层
订单创建接口,同样通过发号器获取唯一的支付层
订单ID B,将支付层
订单 B 写入数据库。 - 步骤2成功之后,使用 B 作为幂等操作的外部ID,调用支付上游支付系统的支付单创建接口,上游支付支付系统会驱动客户端完成授权、验证密码等逻辑,根据
支付层
调用接口时提供的同步回调url跳转
上行操作,通过用户操作的驱动,逐步调用上行幂等接口,完成状态的同步。
其中,步骤2中为了防止生成多条支付层
订单,可以增加一个临时订单表,临时订单表至少包含两列,一列是支付层
订单 ID(以pay_order_id
指代),一列是业务层
订单订单 ID(以app_order_id
指代)。业务层
订单订单 ID 作为幂等操作的外部 ID ,并且加上唯一索引,这样能保证同一个业务层
订单号只会对应一个支付层
订单号。临时订单表还可以作为确认订单这类业务操作的基础数据来源。
下行回调逻辑
同步 vs 异步
同步回调存在的意义是给调用方一个及时的反馈,尽量让调用方在有限的等待时间之内获知操作是成功还是失败,或者是需要等待(设置为软状态)。
异步回调则是作为可信的、确认的操作通知而存在的,目的则是告知调用方操作的最终结果。
异步通知的策略
异步通知首先不能只有一次,也不能是无限次。
一次异步通知很有可能不能完整的让下游触发状态同步操作,需要多次的回调,直到下游确认已经正确的处理了回调。
无限次异步回调给上游带来不必要的压力(下游很有可能已经宕机或者根本不关注这类回调操作)。
具体次数与实践策略,可以参考支付宝:
程序执行完后必须打印输出“success”(不包含引号)。如果商户反馈给支付宝的字符不是success这7个字符,支付宝服务器会不断重发通知,直到超过24小时22分钟。一般情况下,25小时以内完成8次通知(通知的间隔频率一般是:4m,10m,10m,1h,2h,6h,15h)
异步通知需要的组件
异步通知,几乎可以概括成一句话:访问指定的 url 驱动指定的记录变更到指定的状态。
这样的行为,可以抽象出一个异步回调的模块,或者组件,需要回调时,将需要的信息投递的 MQ (如 Redis)之中,可以通过消费 MQ 中的消息,完成异步回调,减少编码量。
使用 MQ 投递的好处在于,如果使用的是支持一对多订阅功能的 MQ,还能实现系统解耦的效果,如监控系统可以根据投递到 MQ 的单个订单的回调次数,及时发现异常订单,增加这一功能,无需其他模块配合。
实现了这样一个组件,甚至可以在新增层级时,无须针对这类逻辑做特定的编码工作,只需要增加消息投递的工作即可。
主动检查
如果上游存在状态检查接口,那是一件幸运的事情,意味着可以有更多的手段来进行状态同步。
如果对状态同步有迫切需求的,或者是不会再有回调的数据,通过 crontab 或者 daemon 扫描这类订单,主动向上游查询状态,有明确结果后,可以模拟上游回调,尝试触发所有的状态同步操作。
幂等接口
幂等接口通过唯一编号确定同一请求,同一请求操作不会重复。
如果使用数据库,可以考虑实现在《基于数据库实现幂等接口》中提到的方法。
最后
订单系统不仅仅只有支付一种功能,日常情况下,还有退款,折扣等功能,分层后如何处理这类业务,解决状态一致问题,这个问题就交给下一篇文章《订单系统设计的思考(附加功能篇)》来尝试解决了。