Java架构-支付设计之路

2,099 阅读16分钟

前言

本文主要聊支付领域中的这三个异常场景,分享支付系统中针对下列异常的一些处理方式:

  • 用户明明付款成功,银行卡都扣款了,但是订单却还显示待付款。即掉单的场景。
  • 重复付款的异常,即同一笔订单,扣了用户两笔钱的场景。
  • 用户扣款成功,但是订单却支付失败的场景。

其实这些处理方式并不只是局限于支付系统,也可以适用于其他系统,大家可以借鉴,应用到自己系统中,提高自己系统的健壮性。

异常是系统运行不可避免会发生的问题,如果一切都正常,我们的系统设计将会相当简单。

但是可惜没有人能做到这一点,所以为了处理异常可能导致的问题,我们不得不需要加上很多额外的设计,用来应对这些异常。

可以说系统设计中,异常处理需要我们着重思考,它将会占据我们大部分的精力。

掉单异常

一个最常见的支付平台架构关系如下所示:

zf1.jpg

上图我们是站在第三方支付公司支付角度,如果是自己公司的内部支付系统,那么外部商户这一块其实就是公司内部一些系统,比如说订单系统,而外部支付渠道其实就是第三方支付公司

我们以携程为例,在其上面发起一笔订单支付,将会经过三个系统:

  • 1.携程创建订单,向第三方支付公司发起支付请求
  • 2.第三方支付公司创建订单,并向工行发起支付请求
  • 3.工行完成扣款操作,返回第三方支付公司
  • 4.第三方支付完成订单更新并返回携程
  • 5.携程变更订单状态

上面的流程,简单如下图所示:

zf2.jpg

在这个过程就可能会碰到,用户工行卡已经扣款,但是携程订单却还是待支付,我们通常将这种情况称为「掉单 」。

上述掉单的场景,多数是因为「③、⑤」环节信息丢失导致,这种掉单我们将其称为「外部掉单 」。

还有一种极少数的情况,收到 「③、⑤」环节返回信息,但是在「④、⑥」环节内部系统更新订单状态失败,从而导致丢失支付成功的信息,这类掉单由于是内部问题,我们通常将其称之为「内部掉单 」。

外部掉单

外部掉单是因为没有收到对端返回信息。

这种情况极有可能是网络问题,也有可能对端处理逻辑太慢,导致我方请求超时,直接断开了网络请求。

方案一:增加超时时间

对于这种情况,第一个最简单的解决办法,「适当的增加超时时间 」。

不过这里需要注意了,在我们增加网络超时时间之后,我们可能还需要调整整个链路的超时时间,不然有可能导致整个链路内部差事从而引起内部掉单。

画外音:对接外部渠道,一定要「设置网络连接超时时间与读取超时时间」。

方案二:接收异步通知

第二个办法,接收渠道异步回执通知信息。

一般来说,现在支付渠道接口我们都可以上送一个异步回调地址,当渠道端处理成功,将会把成功信息通知到这个回调地址上。

这种情况下,我们只需要接收通知信息,然后解析,再更新内部订单状态。

zf3.jpg

这种情况下,我们需要注意下面两点:

  • 对于异步请求信息,一定需要对通知内容进行签名验证,并校验返回的订单金额是否与商户侧的订单金额一致,防止数据泄漏导致出现“假通知”,造成资金损失。
  • 异步通知将会发送多次,所以异步通知处理需要幂等。
方案三:掉单查询

有的渠道可能没有提供异步通知的功能,只提供了订单查询的接口,这种情况下,我们只能使用第三种解决办法,定时掉单查询。

我们可以将这类超时未知的订单的单独保存到掉单表,然后定时向渠道端查询订单的状态。

若查询成功或者明确失败(比如订单不存在等),可以更新订单状态,并且删除掉单表记录。

一定一定一定要特别特别特别注意这个订单不存在的情况。有可能你去查询的时候,查询请求在对方数据还未落库之前就到了,导致对方查不到数据返回订单不存在,但是其实订单是存在的。所以需要意识到“订单不存在”有可能是一个很危险的场景。

若查询依旧未知,这时我们需要等待下次查询的结果。

zf4.jpg

这里我们需要注意了,有些情况下,有可能无法查询返回订单的状态,所以我们需要设置订单查询的最大次数,防止无限查询浪费性能。

方案四:对账

最后,极少数的情况下,订单查询与异步通知都无法获取的支付结果,这就还剩下最后一种兜底的解决办法,对账。

如果第二天渠道端给的对账文件有这一笔支付结果,那么我们可以根据这个记录更新直接更新我们内部支付记录。

为了稳妥一点,可以先发起查询,然后根据查询结果更新订单记录。

不过有些极端情况,查询无法获取结果,那么直接更新内部记录即可。

那如果第二天也没有这笔记录的结果,这种情况下,我们可以认为这笔是失败的。

如果用户被扣款,渠道端内部将会发起退款,将支付金额返回给用户。所以这种情况可以无需处理。

内部掉单异常

支付公司内部订单关系

接下来我们讲下内部掉单异常,首先我们来看下为什么会发生内部掉单的异常,这其实跟我们系统架构有关。

zf5.jpg

如上图所示,第三方支付公司内部表通常为支付订单与渠道订单这样一种 1 比 N 的关系。

支付订单保存着外部商户系统的订单号,代表第三方支付公司内部订单与外部商户的订单的关系。

而渠道订单代表着第三方支付公司与外部渠道的关系,其实对于外部渠道系统来讲,第三方支付公司就是一个外部商户。

为什么需要设计这种关系那?而不是使用下面这种 1 对 1 关系的呢?

zf6.jpg

如果我们使用上图 1 对1 的订单关系,如果第一次支付支付失败,外部商户可能会再次使用相同订单号对第三方支付公司发起支付。

这时如果第三方支付公司也拿相同的内部订单去请求外部渠道系统,有可能外部渠道系统并不支持同一订单号再次请求。

那其实我们也有其他办法,生成一个新的内部单号,更新原有支付订单上内部记录,然后去请求外部渠道系统。

但是这样的话就会丢失上次支付失败记录,这就不利于我们做一些事后统计了。

那其实第三方支付公司也可以不支持相同的订单号再次发起请求,但是这样的话,就需要外部商户重新生成的新的订单号。

这样的话,第三方支付公司是系统是简单了,全部复杂度都交给了外部商户。

但是现实的情况,很多外部商户并不是那么容易更换生成新的订单号,所以一般第三方支付公司都需要支持同一外部商户订单号在未成功的情况下,支持重复支付。

在这种情况下,就需要我们上面的 1:N 的订单关系图了。

内部掉单异常的原因

当我们收到外部渠道系统的成功的返回信息,成功更新了渠道订单表的记录。但是由于渠道订单表与支付订单表可能不是同一个数据库,也有可能两者并不在同一个应用中,这就有可能导致更新支付订单表的更新失败。

zf7.jpg

由于支付订单表保存着外部商户订单与内部订单关系,支付订单未成功,所以外部商户也无法查询得到成功的支付结果。

此时渠道订单表已经成功,所以上面外部掉单的方法并不适用内部掉单。

方案一:分布式事务

内部掉单异常,说白就是因为支付订单表与渠道订单表无法使用数据库事务保证两者同时更新成功或失败。

方案二:异步补偿更新

当发生内部掉单的情况,即更新支付订单失败等情况,可以将这里支付订单保存到一张内部掉单表。

但是这里可能会有一个问题,我们无法保证保存到内部掉单表这一步骤也一定成功。

所以说,我们还需要定时查询,查询一段时间内支付订单未成功,而渠道订单表已成功的支付订单记录,然后也将其插入到内部掉单表。

另一个系统应用,只需要定时扫描内部掉单表,将支付订单成功,然后再删除内部掉单记录即可。

这里需要注意了,当支付订单表数据量很大之后,定时查询可能会慢,为了防止影响主库,所以这类查询可以在备库进行。

掉单小结

前面主要介绍了支付系统中的掉单异常,这类异常往往会导致用户实际已经被扣钱,但是商户订单还是等待支付的情况。

这个异常如果没有很好处理,将会导致客户用户体验很不好,还有可能收到客户的投诉。

掉单的异常,通常可以外部系统与内部系统。

而大部分的掉单都是因为外部系统导致,我们可以增加超时时间,掉单查询,以及接受异步通知解决 99% 的问题,剩下 1% 的掉单只能通过次日的对账来兜底。

内部系统导致掉单异常是典型的分布式环境数据一致性的问题,这类问题我们可以不需要追求强一致性,只要保证最终一致性即可。

我们可以使用分布式事务解决这类问题,也可以定时扫描状态不一致的订单,然后在做批量更新。

接着,我们聊另外一类支付场景下的异常。

重复付款异常

重复付款异常一般常见于网银支付,微信支付,支付宝等这类需要跳转到一个支付网关页(网银支付),或者跳转到钱包 APP(支付宝、微信),从而异步完成扣款的支付场景。

zf8.jpg

这种支付场景下,只能通过接受异步通知才能知道支付结果,我们一般将其称为异步支付。

另外,多说一句:有了异步支付,那么同步支付是什么?

其实同步支付指的就是调用支付接口之后,就可以立刻返回支付结果的,比如银行卡类快捷/代扣等支付就是同步支付。

当然也有一些奇葩的银行卡支付渠道,同步支付结果为受理成功,只能接受异步通知或者查询返回支付结果。

由于银行卡支付需要返回明确支付结果,对于这类渠道只能内部设计将异步转为同步。

好了,说回来。

后台支付流程如下:

zf9.jpg

为什么会发生重复付款?

主要原因其实跟上次内部掉单异常一样,跟业务表设计有关。

上次我们提到,支付系统主要表结构如下:

zf10.jpg

在这个表结构下,只要支付订单未成功,商户就可以重复使用其内部同一订单号调用支付接口。

假设这样一个场景,用户在收银台支付时选择招行进行网银支付,当他点击支付之后,商户系统将会调用支付公司的网银接口。

这时支付系统内部将会创建一笔支付单以及关联的渠道订单,并且调用招行系统的接口。

然后用户的浏览器页面将会打开一个新页面,然后跳转到招行网站。

这时如果此时用户再次在收银台点击支付,将会再次调用支付系统接口。

这时候由于支付单已存在,所以仅仅会再创建一条渠道订单记录,并且调用招行系统的接口。这时用户的浏览器将会再次打开一个招行的网站。

如果用户在两个招行网银页都完成支付,这时就发生了重复付款。

上面这种场景看起来有点傻,但是真实用户操作真的会发生。

除了这种,博客园上的小伙伴还提到这么下面这种情况:

zf1.png

重复付款-解决办法

重复付款异常的主要的解决办法有两种,分为事前与事后。

事前主要的目是尽可能防止用户重复付款,主要解决办法为优化付款页面,尽可能做好提示。

第一种优化方式,付款页面直接跳转到第三方/银行的网银页面,不要打开新的页面去跳转。

zf1.gif

这种方式可以防止用户误打开两个网银付款的页面,从而导致重复付款。

但是这里会有一个问题,银行网银页面付款成功之后,用户如何知道其在商户侧订单状态也成功了?

其实很简单,现在网银支付接口,一般都会有一个参数:

return_url:同步跳转地址。

比如,支付宝开发文档就有这个字段:

zf11jpg.jpg

只要在接口传入这个地址,当支付成功之后,页面最终就会跳转到这个传入的地址,商户侧就可以在地址显示订单是否支付成功。

zf12.jpg

上面我们提到,用户有可能会使用浏览器回退功能,跳转到支付页,从而导致重复付款。

对于这种情况,我们可以在其回退支付页时,首先向后台查询这笔订单支付结果,如果已支付成功,那就直接显示成功页面。

第二种优化方式,对于这种重新打开一个页面跳转到银行网站,我们可以在页面加入弹窗提示,询问用户是否已支付完成。

zf2.png

比如上面这种处理方式,当用户点击确认完成充值,可以马上向后台发起查询订单状态。

下面来聊聊事后的解决办法。

其实解决办法很简单,发起内部退款,将多余支付的一笔反向退款回去。

支付系统内部可以有个定时任务,定时扫描支付单下有多条成功渠道订单的记录,然后选择将重复支付渠道订单发起退款。

这种方式是支付公司系统内部的操作,不需要商户侧发起指令。

订单失效异常

这种场景一般常见于电商购物,秒杀等购物场景。当用户下单之后,页面将会开始倒计时,用户需要在有效期内支付成功。

zf13.jpg

假设用户点击跳转到支付宝,但是其没有立刻支付,而是停留了很久,在订单最后一秒时间内完成了支付,但是这个时候订单早已因为时间到期而被自动取消。

这样就发生用户扣款已经成功,但是订单却是失败或关闭的场景的。

另外还有一种情况,用户在有效期内支付成功,但是因为网络、内部应用等问题,支付结果的异步通知过了很久才收到,这时内部订单早因为时间到期而被取消。

订单失效异常-解决办法

第一种解决办法,上送有效期给支付渠道。

一般支付接口都会有一个支付有效期的字段,表明这笔支付最晚可以支付的时间。如果超时未支付,这笔支付将会被关闭。

比如,支付宝开发文档就有这个字段:

zf14.jpg

当然一般情况下,如果未上送,这个字段内部一般会有个默认的有效期,比如 3 天,这个时间就比较长了。

所以当调用支付接口时,可以将订单剩余有效期传入支付接口。这样用户如果在超时时间内未完成支付,支付将会失败。

第二种解决办法,内部发起退款。

这个解决办法依然事后托底的解决办法,对于支付订单已关闭,但是支付却成功的情况,发起内部退款,将钱退给用户。

内部可以有个定时任务,定时扫描支付订单已关闭但是支付却成功的情况,然后发起退款指令。

最后

最后用思维导图方式帮大家总结一下支付系统可能会碰到的异常。

zf15.jpg