记一次支付系统的设计体验
作者 | 文刀
来源 | 技术汇
支付系统是一个老生常谈的话题,我也相信每个公司开发的支付系统不尽相同,因为业务形态并不太一样。在此,我并不想讲一个大而全的支付系统,个人也没有能力去阐述。
在我看来,一个支付系统应提供支付渠道管理,支付网关,基本支付/退款/转账能力,支付记录/明细,及其相关的监控运维系统。
至于所谓的账务清算,对账功能,账户体系,风控体系,现金流量管理,应该纳入到「财务系统」,大概是大佬们谈论的都是广义的「支付系统」吧!
而我今天只谈狭义的「支付系统」。
目前,支付的流程包含了三大部分:发起支付,发起退款,接收回调。
考虑到吞吐量的影响,将原先同步的编程方式改为异步的编程方式,不出意外的话,将会使用到 Java8 的 ExecutorService 和 CompletableFuture。
此外,还用到了公司其他的现成的东西:RabbitMQ,Redis,MongoDB。
我是打算将这套支付系统设计成与具体业务无关,可以纳入到公司的公共平台系统中。
具体是如何做到的,请接着往下读。
1.发起支付
这一部分讲述的是客户端和服务端如何配合完成一次支付请求。服务端必须要有一个意识,最终发起支付的还是客户端,服务端提供一些必要的参数配置信息。
发起支付的架构图如下所示:
image跟着标注的序号,可以跟踪到一个支付请求是如何发起的(Sequence Diagram就免了),流程描述如下:
01/12 Submit a pay task,当客户端需要发起支付的时候,起始是向支付任务队列里面加入了一个新的支付任务,这个过程是异步实现的。先根据客户端提交的参数,构造好一个新的支付任务;
02/12 Offer a task,开启一个异步任务,做的事情就是向MQ中添加一个新的支付任务,等待被消费;
03/12 Pay task description,一旦异步任务被成功创建,将会把第一步构造好的支付任务信息直接return给客户端;
04/12 Poll a task,与此同时,支付任务的消费者将新的支付任务poll下来进行执行;
05/12 Send a pay request,这一步需要根据实际情况而定。并不是所有的支付请求都要先经过第三方支付平台,比如支付宝;而对于微信,则还需要凭支付参数申请一个 prepay_id,再经由客户端发起支付;
06/12 Response,没什么好说的,第三方渠道返回的支付必要参数;
07/12 Cache result,至此,一个支付任务可以算是完成了,可以将任务的执行结果(无论成功与否)缓存在 Redis 中,随时等待客户端的回访;
08/12 Query result,客户端在提交支付任务后,间隔一定时间后(建议 2~3s),发起一个结果查询的请求;
09/12 Query,直接进 Redis 查找结果;
10/12 Synchronize,这是一个异步的操作,将支付任务的执行结果“顺便”同步到 MongoDB 中,并删除 Redis 中缓存的任务执行结果。持久化到 MongoDB 主要是为后续的容错,重试,数据分析等提供落地的数据源;
11/12 Return,由 Redis 返回给应用服务器;
12/12 Return payment,应用服务器再将最终的支付对象返回给客户端。
让我们更深入一点,我们来看三张 Class Diagram:
01/04 先说说支付任务 (PayTask) 部分。PayTask 和 Payment 两个都是 MongoDB 中的 Document 对象,但在任务执行期间,PayTask 是用 Redis 进行缓存的,方便客户端随时发起 Query,任务执行成功后,会生成 Payment 对象,最终 PayTask 和 Payment 都会持久化到 MongoDB 中。在 PayService 中,有对支付任务的一些基本操作,包括任务提交,取消,重试,构建等等。
image02/04 再说说任务的执行 (runner)。这部分和 RabbitMQ 紧密相关,一旦一个支付任务形成了,就会放入任务执行队列中,由消费者取出执行。在 TaskRunner 中,有两个基本的接口方法:run(task)、retry(task),分别是执行任务和重试任务。在 AbstractPayTaskRunner 中已经封装好了这两个方法,继承 AbstractPayTaskRunner 需要实现 doTask 方法,从返回值可以看出,这个过程是异步化的。关于 Retry 机制,用户可以设置重试与否,一旦设置了 TaskInfo.needRetry=true(不出意外,默认就是允许重试),就启用了 Retry 机制。还可以设置重试的次数 (TaskInfo.retryTimes),默认三次,分别间隔 1s,2s,3s,间隔时间以公差为 1 的等差数列组成。当然不会让用户无限重试,系统内置有一个最大重试次数,最大重试次数内置为 5 次。
为什么是 5 次?
你感受一下,1s,2s,3s,4s,5s,整个请求链条就被拉长到了 15s,这对客户端简直就是灾难了!!
image03/04 接着说一下支付渠道 (PayChannel)。这部分设计与具体的支付渠道对接联系比较紧密了,包括支付参数配置,支付参数处理,签名/验签等等。
image04/04 最后解释一下支付参数 (PayParams)。
image网络异常[取消](javascript:void(0))[重新上传](javascript:void(0))
大部分还是能看懂的,我解释几个关键的 property:
01/04 appId,这是为了区分不同的产品所设置的。现实中,很有可能一个产品会申请与之对应的支付渠道,然后在支付平台中创建应用,设置好对应的支付参数,系统将会分配一个 appId,凭此值就可以直接定位到各个支付参数。如果想再更完善一点,可以再区分一下测试环境和正式环境;
02/04 amount,这里代表的是支付金额的意思,但是这套支付系统的金额单位统一设置成 人民币【分】;
03/04 metadata,理论上,元数据这个字段没啥限制,要是非要说有限制,那么就是字段长度了——5000 个字符。这个字段的想象空间还是很大的:用于填写丰富的交易相关信息,用于在增长智能系统产品中进行深入商业分析。包括交易行为多维分析、人群分析、产品转化路径、个性化推荐、智能补贴、定向推送等。看产品经理要怎么玩了;
04/04 credential,这个字段非常非常重要,其中装载的就是客户端最终发起支付请求的凭证,会作为 Payment 对象的一部分返回给客户端;
MongoDB 的 document 字段设计
解释一下为什么要用 MongoDB:
个人觉得,如果这个通用服务要得到较好的推广(甚至是开源),用 MySQL 等关系型数据库是不二之选,因为一个完整实用的系统,必然是少不了数据库的,如果一旦用了一些非传统的东西,必然会提高一部分人的对接成本。有的人一看不符合团队的技术栈,直接就不考虑了。
为什么我还是要用 MongoDB 呢?
01/03 团队的技术栈里面有这么个东西,不用白不用;
02/03 MongoDB 普及程度实在是不要太高,还不用上点 NoSQL 的东西,感觉自己分分钟被 OUT 掉了;
03/03 要存储的数据结构需要支持动态扩展的特性,我就看中 MongoDB 的灵活性,如下是要存储的数据结构:
document_name = “Payment”
{
"payId": "pay_Oyvrf9e9S1",
"method": "yoogurt.taxi.pay",
"version": "v1.0",
"timestamp": 1473044885,
"created": 1473042835,
"paid": false,
"appId": "app_iPGa98ab9ev",
"channel": "wx",
"orderNo": "20161899798416",
"clientIp": "192.168.18.189",
"amount": 10000,
"subject": "充值订单-¥100.0",
"body": "充值订单-¥100.0",
"paidTime": null,
"transactionNo": "",
"metadata": {
"user_id": "170204469176",
"phone_number": "13811234567"
},
"credential": {
"appId": "wx4932d1311e",
"partnerId": "1269774001",
"prepayId": "wx2016099",
"nonceStr": "1e99d8fe92ba",
"timeStamp": "1473042837",
"packageValue": "Sign=WXPay",
"sign": "1CECCEDEBE"
},
"extra": {},
"statusCode": "",
"message": "",
"description": ""}
其中,metadata,credential,extra 这类字段,并没有一个特别固定的规范,用 MySQL 要冗余一下字段才行,或者针对每个渠道去分表,想想都觉得烦!
MySQL
因为这套支付系统被设计成为支持多应用,多渠道,所以此处用到 MySQL 存放一些应用配置。 E-R 图免了,直接上数据库表结构:
01/05 pay_channel:可供接入的支付渠道
image02/05 app_settings:支付应用信息
image03/05 app_channel:应用已接入的支付渠道
image04/05 alipay_settings:支付宝参数设置
image网络异常[取消](javascript:void(0))[重新上传](javascript:void(0))
05/05 wx_settings:微信app支付参数设置
image如果想要增加支付渠道,只需要添加一张对应的支付参数设置表。
2.发起退款
不出意外,客户在平台的每笔订单都可以发起退款,而且还能分批退,也就是同一个订单,可以多次发起退款申请,只要保证退款总额不超出实付总额。 架构图如下所示:
image跟发起支付请求的流程有很多相似之处,不再一一解释了,两个关键的地方说明一下:
01/02 客户端发起退款请求的时候,需要携带 payId,就是支付对象的 id。这就意味着,支付系统的调用方需要维护 payId 与 orderNo 的对应关系,务必在客户端发起退款请求之前,获取到正确的 payId;
02/02 承接上一步,这才有了图中的第 5、6 个步骤,从 MongoDB 中查询之前的支付对象。第三方渠道通常会要求在退款的时候指定一个退款单号,因为一笔订单可以分多次退款,所以不建议将订单号作为退款单号使用。这里的退款单号由支付系统生成并维护。
这部分的执行流程和之前类似,客户端发起退款请求,形成一个退款任务 (RefundTask),放入任务队列中,消费者取出并执行各自的业务逻辑,退款成功会生成 Refund 对象,并持久化到 MongoDB 中。
MongoDB
document_name = "Refund"
{
"payId": "pay_vfvS0m1",
"method": "yoogurt.taxi.pay",
"version": "v1.0",
"timestamp": 1473044885,
"created": 1473042835,
"refundId": "refund_kmrf9wSr1em",
"appId": "app_iGa8abLe9ev",
"orderNo": "20161899798416",
"clientIp": "192.168.18.189",
"amount": 10000,
"succeedTime": 1473150835,
"transactionNo": "64059968740554",
"refundStatus": "success",
"message": "",
"metadata": {
"user_id": "170204469176",
"phone_number": "13811234567"
},
"description": ""}
3.接受回调
这部分功能被设计成了事件驱动类型,所以 webhooks 当仁不让。
因为各个渠道的回调内容都不尽相同,所以这部分设计会按支付渠道切分。
架构图如下:
image用户在支付完毕后,第三方支付渠道通过发起支付时指定的回调地址对商户进行支付成功的异步通知。
这部分的执行流程和之前类似,在各自的 PayChannel 中解析好回调参数,形成一个回调事件 (Event),并持久化到 MongoDB 中,然后再生成一个回调任务 (EventTask),放入任务队列中,消费者取出并执行各自的业务逻辑,这里的消费者就是上游的业务服务系统。
MongoDB
document_name = “Event”
{
"eventId": "evt_la06Co7wq",
"created": 1427555016,
"eventType": "pay.succeeded",
"data": {
"payId": "pay_OvP88CSm1",
"method": "yoogurt.taxi.pay",
"version": "v1.0",
"timestamp": 1473044885,
"created": 1473042835,
"paid": false,
"appId": "app_iGa9aLe9ev",
"channel": "wx",
"orderNo": "20161899798416",
"clientIp": "192.168.18.189",
"amount": 10000,
"subject": "用户充值-¥100.0",
"body": "充值订单-¥100.0",
"paidTime": null,
"transactionNo": "",
"statusCode": "",
"message": "",
"metadata": {
"user_id": "170204469176",
"phone_number": "13811234567"
},
"credential": {
"appId": "wx4932b511e",
"partnerId": "1269774001",
"prepayId": "wx201609051039",
"nonceStr": "1e9d8fddad",
"timeStamp": "1473042837",
"packageValue": "Sign=WXPay",
"sign": "1C0K3C95AKB"
},
"extra": {
},
"description": ""
},
"retryTimes": 0}
特别说明一下 data 字段:
如果是支付成功事件,则返回对应的 Payment 对象;
如果是退款成功时间,则返回对应的 Refund 对象。
总结
可能有的读者通篇看下来,觉得这并不是什么支付系统,仅仅是对接了一下第三方支付渠道,勉强算是支付渠道网关吧!
如果你有这种感受,我也是非常认同的。个人认为这篇文章还是比较接地气的,没有太多理论的东西,看到的更多是实现层面的内容,就差贴代码了!坦白地讲,第三方支付渠道对接了不少次,却并没有像现在这样系统地去设计,去总结。
我用过几次 Ping++的产品,在企业级聚合支付领域,Ping++ 算是业界领先者了,所以,我的一些数据结构设计还是与其有几分相似的,Ping++ 以后也会是我模仿和比较的对象。这次也是我的支付系统实现所迈出的第一步,今后也会不断丰富,完善我自己的支付系统。
希望对你有所帮助!THANKS!
注:本文转载自微信公众号技术汇 (微信号:jishuhui_2015),作者文刀。关于支付、创业或者 Ping++,如果你还有任何想要探讨的议题,欢迎随时在微信后台留言或投稿。