订单系统设计的思考(状态一致篇)

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 是目前的一个更合理的选择。

实现订单成功支付需要做什么

正常的一个售卖行为,最终的目的是购买者下单并支付成功,服务方收到款项并交付商品到购买者。

这里先只讨论订单到支付成功的过程。

依赖关系

从依赖关系来看:

  1. 一个售卖系统,自己的业务系统,依赖上层支付系统,即类似微信/支付宝/Apple Pay这样的平台,
  2. 支付系统依赖的是各个银行(后续将会依赖网联,不再直连各个银行),即国有五大行和各个民营银行
  3. 银行依赖的则是央行,各个银行进行结算。

也就是说,用户的每一次支付操作,都会逐步上升,每一个阶段都有自身的状态,上下级之间需要进行状态的同步,保持一致。

此外,无论是依赖的外部资源,还是业务系统内部,都会可能存在资源独立部署,无法实现类似数据库的 ACID 操作的效果。内部分层也会出现状态同步问题。

综上,从依赖关系分析,每增加一个系统层级,就要新增一套异步回调通知的机制。

操作步骤

从操作步骤上来看,假定通信通过HTTP API,数据存储在 MySQL 中,转化为一个技术操作,可以是:

  1. 用户从客户端(Web/App)发出下单请求,选择商品,提供其他相关信息(地址,电话,收件人等等)
  2. 业务 Server 收到请求创建订单,订单创建,状态为待支付
  3. 业务 Server 创建回调 url,驱使客户端跳转到上层支付系统支付页
  4. 用户在支付页支付,根据上一步提供的回调 url,跳转到回调 url
  5. 业务 Server 收到访问回调 url 的请求,根据回调的信息,修改对应订单的状态从待支付已支付或者支付失败
  6. 一旦任何步骤发生未响应/超时/异常情况,需要将订单设定为预留的软状态——处理中。处理中的订单,择机重试或者等待上游通知或者两种手段互相结合。

综上:

  • 因为存在多层级,需要实现多套上行的状态同步逻辑与下行的回调通知逻辑
  • 因为存在未知情况和长时操作,状态机需要设定软状态
  • 因为会出现重试,需要实现幂等的接口

面向异常编程

看起来,需要实现的功能并不复杂,而且事实上,一个应用在绝大多数情况下,都能正常工作。日常中的支付,常常能在一次“同步”的操作过程中就能彻底完成。

一个应用,如果只处理正常的业务流程,是不完整的,因为在正常业务的各个阶段,都隐藏着失败的可能。

从不应该信任用户的输入这类问题说起的话,bad cases 就是在太多了。从这篇标题出发,引起状态不一致的情况,如果业务 Server 直连支付系统,光回调操作就可能会有如下问题:

  • 用户第三方支付钱包余额充足,扣款成功,回调业务 Server 因网络原因失败,订单状态此时应为已成功,然而仍处于待支付
  • 用户第三方支付钱包余额充足,扣款成功,回调业务 Server 请求成功到达,然而数据库操作连接失败,订单状态此时应为已成功,然而仍处于待支付
  • 用户第三方支付钱包余额不足,支付平台向银行申请扣款,银行扣款成功,但是第一次回调支付平台失败,支付平台此时未回调业务 Server,订单状态此时应为已成功,然而仍处于待支付

支付的流程越长,中间节点越多,各个节点之间只要任何一环出现问题,未能将请求结果返回,就会让下层节点无法获知上层节点的状态,出现状态不一致的问题。

所以,在处理状态一致问题时,实际上就是面向各个环节的异常情况进行编程。

实现方案

按照前文提到的需要处理的三大问题,以下的篇幅会针对两级分层方案(业务层支付层)尝试给出自己的理解。

上行逻辑与下行逻辑

一次支付的完整步骤示意图如下:

1
2
3
4
5
6
7
8
9
+-----------+  New Order         +-----------+  New Pay Order     +-------------------+
| | -----------------> | | -----------------> | |
| | | | | |
| | Callback | | Callback | |
| App Layer | <----------------- | Pay Layer | <----------------- | AliPay/WeChat/... |
| | | | | |
| | Async Callbacks | | Async Callbacks | |
| | <----------------- | | <----------------- | |
+-----------+ +-----------+ +-------------------+

上行状态同步逻辑

上行逻辑的核心在于通过本地事务实现层内的状态一致,本地先记录数据,然后再尝试驱动上层逻辑。目前只考虑支付一种业务场景,退款等场景的状态变迁后续文章再进行讨论。

业务层订单的状态流转顺序为:

  • 待支付(订单写入数据库)
  • 处理中(支付层订单进入处理中状态)
  • 支付成功(收到支付层的支付成功通知)
  • 支付失败(收到支付层的支付失败通知)
  • 关单(订单有效期已过且未支付)

支付层订单的状态流转顺序为:

  • 待支付(收到业务层创建支付层订单请求,订单写入数据库)
  • 处理中(支付上游进入处理中状态)
  • 支付成功(收到支付上游的支付成功通知)
  • 支付失败(收到支付上游的支付失败通知)
  • 关单(订单有效期已过且未支付)
  1. 首先在业务层创建订单,通过发号器获取唯一的业务层订单ID A,此处可以通过前后端设定一个请求编号防止重复下单,当两个相同的请求编号的请求到来,拒绝其中之一。将业务层订单 A 写入数据库,本步骤失败则告知用户下单失败;
  2. 步骤1成功之后,使用 A 作为幂等操作的外部ID,调用支付层订单创建接口,同样通过发号器获取唯一的支付层订单ID B,将支付层订单 B 写入数据库。
  3. 步骤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 扫描这类订单,主动向上游查询状态,有明确结果后,可以模拟上游回调,尝试触发所有的状态同步操作。

幂等接口

幂等接口通过唯一编号确定同一请求,同一请求操作不会重复。

如果使用数据库,可以考虑实现在《基于数据库实现幂等接口》中提到的方法。

最后

订单系统不仅仅只有支付一种功能,日常情况下,还有退款,折扣等功能,分层后如何处理这类业务,解决状态一致问题,这个问题就交给下一篇文章《订单系统设计的思考(附加功能篇)》来尝试解决了。