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

出于系统分层的目的,售卖系统中订单可以设计成业务层订单和支付层订单,前者关注业务行为,后者更关注资金变更,二者通过唯一ID关联。

不分层的订单系统

前提

以下讨论的订单系统基于LNMP实现。
对于描述错误的地方,烦请告知。

订单的基本组成

说起订单,个人认为至少应该包含如下元素:
  • 商品信息
  • 购买者信息
  • 支付信息
  • 支付状态
  • 订单状态
商品信息指的是诸如商品的编号,名称这类商品的基础信息。
购买者信息是购买者的物流信息,用户身份信息等数据。
支付信息是支付金额,支付方式等数据。
支付状态是订单支付相关行为的操作依据。
订单状态是订单在业务系统中,对其他使用者给出的带有业务特征的操作依据。

不做分层的实现

最简单的一种方式,就是一条表记录,记录上述所有信息。
接触过的某网店框架的订单部分,订单的所有信息,甚至物流信息都记录在一张表中,好处当然是有的,就是操作起来相当方便,只要一次查询,就能拿出订单相关的所有信息。对于数据报表之类的需求来说,这样的订单表设计大大降低了数据获取部分的开发难度。
在业务系统规模较小时,这并不成为任何问题,因为一切运行正常,不需要过度设计。
一旦业务规模增大,需要使用这一系统的业务方增多,对接的外部服务越来越复杂,这个系统还能正常运转吗?
想象一下当需要修改新增订单的一个状态,然而其他需求的开发人员提到这个状态添加之后会给他们的功能带来流程上的影响;或者是需要通过多个字段才能确定出一个订单的实际状态,相信会想问当初设计时为什么杂糅了这么多的数据。
可能会引发的现象:
  • 表字段过多,难以添加有效索引
  • 新增业务行为需要新增字段,增加表字段数量
  • 对接上游支付的行为需要业务开发人员关注(耦合了实际支付操作)
  • 逻辑单元越来越臃肿,开发上难以细致的拆分工作
  • 错误的操作引起不正确的支付行为

分层方案

分层目标

之前提到说不分层在业务规模发展到一定程度后会给后续功能开发带来不便,分层的目标就是解决这些问题。
归结起来,核心的问题就是如何能提高业务的可扩展性开发效率安全性

设计方案

可以在订单系统内部实现两层。
一层在本文中称为业务层,关注的是业务行为,比如订单的商品个数,收件人,地址,电话等数据。
另一层在本文中会称作支付层,关注的是订单的金额,与上游支付系统的关联关系,支付的状态。
二者之间通过唯一ID进行关联,支付层通过业务层订单ID进行关联,而支付层则与上游的支付系统通过支付层订单ID进行关联。
所有操作通过 HTTP API 进行。

解决问题

乍一看这样的划分不过是相当于增加了表,通信还增加了成本,感觉像是引入的更多的问题。
首先从可扩展性说起。

可扩展性问题

单表超多字段的问题,通过拆表,其实就能解决。在表记录不多时,不需要分层,直接通过数据库事务对单次操作多张关联的数据表,可以完成业务功能。
这样强依赖事务的业务,数据库会对系统的吞吐产生较大的影响。
如果订单数量相当巨大(比如历史悠久的小额充值记录,订单记录多);或者并发增大,单机数据库逐渐无法支撑业务的发展。
订单如果存在多个影响因素(物流状态/退款/优惠券/返现)等信息时,如果采用多个数据库提高处理能力,就无法通过数据库事务完成二者的状态一致性。
所以,拆表不是目的,拆表的初衷是实现资源的横向可扩展性
分层之后,业务层专注做好业务层的新需求,业务数据与核心数据分离,任何改动对订单的核心数据支付信息支付状态可以降低到最小。需要关注的方向越少,就更容易的控制复杂度,增加功能。
支付层分层完成之后,支付层需要关心的数据只有操作者与操作金额数,以及操作的最终状态。简单来说,就是“谁付了多少钱给谁”。无论再新增任何类型的订单,最终都转化为同样的支付行为。
最后,从资源上来说,由于把数据库事务分拆成了多个系统之间的任务,通过 HTTP API 进行通信,在正常情况下,资源无论如何分布,由于约束已经确定,业务逻辑不会受到影响。
综上,更容易增加功能,资源可以拆分,无论代码执行者还是资源约束上,整个系统扩展起来会变得更加的容易。

开发效率

订单作为用户购买行为的记录,必定会收到购买物品的影响,购买商品的一些特定属性会影响到记录订单的方式。
但是,归根结底,订单终究是表示操作者和操作金额数的。这些产品业务形态上的复杂度,应该在有限的领域内进行处理。
此外,订单的关键步骤——支付还涉及到与支付上游的交互,一旦需要新增支付上游,比如近期苹果要求打赏等服务接入IAP这类事件时,和业务系统耦合程度越高,越会带来开发上的问题。
开发效率不仅和功能的复杂度,结构的清晰程度有关系,也和人力的合理安排有关系。当需要加速开发速度时,划分出合理的结构,可以让不同的人员能真正并行的开发,负责的功能点越小,能更容易高质量的实现,就像 Unix 的设计哲学提到的:
Write programs that do one thing and do it well

安全性

想象一下,直接操作数据库,和通过 API 操作数据库,哪一个更可能带来副作用?
API 约束了操作方式,检查了参数,实现得正确的话,可以保证至少操作不会出错。不出错,是订单系统的底线。

问题

唯一ID选用

订单分层中,各层数据之间进行关联需要通过唯一ID,订单号在一个系统中必定唯一的,所以可以考虑使用订单ID关联业务层支付层订单。
但是这两类订单都有自身的ID,到底选用谁关联谁成为了一个问题。或者说,能不能只用业务层的订单ID关联所有的操作?
考虑如下一种情况,支付可能会失败,由于各层之间应当实现的是幂等的接口,订单ID常作为请求标识,如果只使用业务层的订单ID,支付失败之后,再次使用这一ID是无法创建新的支付请求的,原因是这一个ID已经失败了。
所以,出于幂等操作的考虑,支付层通过业务层订单ID进行关联,而支付层则与上游的支付系统通过支付层订单ID进行关联。
如果非要使用同一个业务层ID进行关联,就需要引入其他的幂等操作标识。

状态一致

上面的篇幅描述了分层的意义和一个基本的分层方案,但是这个方案似乎没有提到拆分之后带来的一个巨大的问题——多层数据状态一致的解决方案。
这个问题就交给下一篇文章《订单系统设计的思考(状态一致篇)》来解释了。

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. 首先在业务层创建订单,通过发号器获取唯一的业务层订单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 扫描这类订单,主动向上游查询状态,有明确结果后,可以模拟上游回调,尝试触发所有的状态同步操作。

最后

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

https://anyof.me/articles/482
https://anyof.me/articles/484

评论

此博客中的热门博文

Tailscale 开源版中文部署指南(支持无限设备数、自定义多网段 、自建中继等高级特性)

iOS任意版本号APP下载(含itunes 12.6.5.3 最后带AppStore版本)

关于 N1 旁路由的设置

Mifare Classic card(M1卡)破解过程记录(准备+理论+获取扇区密钥+数据分析)

Blogger搭建国内可正常访问博客(超详细教程)

打造一个可国内访问的Blogger(Blogspot)方法

百度站长平台中接入Blogger博客

Mifare Classic card(M1)卡破解过程

重新学习并解锁emby4.6.7,4.7.2版本

一些免费的云资源