基于订单号可重入的交易系统接口设计探讨
在交易系统的设计和实现中,接口的可重入性设计是及其关键的,可重入性也叫接口操作的冥等性保障,那么什么叫冥等性呢?在交易系统中,为什么需要这个特性?
冥等性,顾名思义,就是接口的多次操作和一次操作的效果是一样的,这里的效果指的是对后端系统的状态变更不存在副作用(side-effect)。举一个例子,比如查询类接口,都是具有冥等性的,对同一输入参数的多次查询操作,都没有对后端系统的状态变更产生副作用,或者说,后端系统的状态不会因为该查询接口的调用而发生变化。当然,我们这里所说的冥等性,不是指查询类接口,而是指写入类或者变更类接口,变更类接口一般都会对后端系统的状态进行变更操作(即写操作),那么,在交易系统中,为什么该特性及其重要?这是因为交易系统一般都是资金敏感型系统,每次的系统状态的变更对应了资金或者订单状态等关键业务变量的变更。若服务调用方调用服务方由于网络等原因导致前一次接口调用返回不明确的时候,此时服务调用方是无法确认此处的交易是否已经成功提交的。举个例子,该交易接口是一次往某个朋友转账1000元的接口,每调一次转账1000元,这个时候,调用方单独通过该接口返回(超时了,返回的是系统繁忙类似的信息)无法判断本次是否已经成功转账,那么这个时候,调用方可以怎么做呢?方法可能有三种:1,调用一下查询接口,查一下该笔转账订单的状态是否已经成功;2,继续用同样的参数调用一次该接口,这叫重试;3,等着隔天对账的时候,再确认改笔转账是否成功。而其中第2种方法存在较大的风险,假如前面那次转账其实是已经成功了,只是接口返回包的时候网络超时,使得接口调用方无法获知本次接口调用的成功结果,如果这个时候,接口调用方再次调用一次,服务提供方的接口如果没有做好冥等性的保障,则会出现第二次调用也成功,相当于转账了两笔,即重复支付导致的资金安全问题。
读者可能会问,既然这样,那接口调用方,只用方法1和3就可以了,不重试调用就是了,理论上好像也可以,但仔细琢磨还是有问题的:1,查询接口缺少事务型的保障,其实还是有一定的风险的,即单独依靠查询接口无法彻底解决这个问题,举个例子就明白了:还是上面的转账例子,调用方第一次调用转账接口返回网络超时,此时,考虑到该接口可能不具备冥等性,那么就不敢直接重试,先调用一次查询接口,查询一下状态,如果此时若订单已经支付成功则更新本地订单状态为转账成功后流程结束,若此时查询的是订单状态还是初始状态(即未转账成功),则再调用该转账接口重试一次,问题是这样调用是安全的么?答案是仍然存在重复转账的风险,即查询接口的状态判断只有终态才是可信的,其他状态都是不可信,不能作为资金交易判断的关键依据,因为,存在类似可能的实现:此时后端系统在主db可能是已经转账成功,但备db还没同步到,而基于读写分离,此时查询接口调用的是备db的数据。另外使用方法3,通过对账后再决定是否需要重新转账,这个是可行的,但存在用户体验的问题,一般对账都是隔天T+1进行,也就是说要等到隔天才能确认是否需要进行重新转账。
综上,为了更好的支持交易系统写接口的可重入性,在接口的设计和实现中,需要有一定的方案考虑,重入的关键变量集合很多,不同的业务可能有不同的考虑和设计,本文重点讲解基于订单号的重入设计,一方面是这是最常见的重入判断变量,另外一方面,该方法因为简单,通用性较强,具有很好的适配性。
在交易接口中,一般服务调用方调用某个交易系统的时候,都会传入调用方的唯一识别码,一般就是业务订单号,或者唯一序列号等,它是标明本次交易操作的唯一编码,该编码不同代表不同的交易,相同则表示是同一个交易操作,而服务提供方通过判断该编码进行判断该请求是否已经处理过,从而到达重入的效果。
方案一,基于外部业务订单号的可重入接口的设计
该方案的特点是,服务调用方每次调用交易系统的接口时候,必须自己确保生成一个唯一的订单号(或者在某个比较长的时间段内唯一也可以),然后作为交易系统接口调用的一个关键参数输入;交易系统收到该请求,为了保证可重入性,会以该外部订单号作为唯一健在db建立跟自身内部订单号的映射关系,大概如下:
图1:基于外部业务订单号的映射表
建立了这个关系后,每次调用该接口,会首先插入该映射关系,若前面已经调用过,则外部业务订单号会因为唯一健冲突导致插入失败,此时,重新检查本次请求和上次请求的关键变量是否一致,若一致则返回接口重入,否则提示接口错误。
图2:基于外部业务订单号的关键调用流程
该方法的关键点是:1,外部服务调用方只需要调用一次接口即可;2,调用方需要自己生成并保障业务订单号的唯一性;3,服务提供方,需要维护以外部业务订单号为唯一健的订单映射表,由于不同调用方的业务订单号规则不统一,使得该映射表的数据不够规范,且无法实现按日期的归档等操作。
总之,该方法的订单映射关系维护的责任在服务提供方,而不是服务的调用方。
方案二,基于内部订单号的可重入接口的设计
方案一虽能够解决接口重入的实现问题,但在实际操作中,仍然不够完美,主要在于随着时间的推进和不同调用方的订单规则不统一,使得订单映射db的维护变成一个问题,比如,累计到一定时段,订单映射表的数据是否可以删除归档?假如把2年前的删除归档,那么2年前的订单调用方不小心这个时候又调用过来,那么是认为是重入还新订单?此外,由于外部业务订单号不一定有时间的信息,因此也无法判断该订单属于什么时候,只能依靠存储的记忆,类似等等的弊端逐步的暴露出来。第2个问题是,这个订单映射的db有全局性的要求,随着云化和分布式的部署,需要对该db进行单独的处理。那么有没有更好的办法呢?方案一的一个问题是外部业务订单号的规则不可控,使得存储映射关系变成一个比较麻烦的事情,那么如果把判断重入的订单号从不可控的外部业务订单号变成自主可控的内部订单号,是不是就更简单呢?内部订单号的规则可以按照自己的规则进行制定,比如位数的统一,埋入时间信息和校验信息,埋入各种业务规则的信息等,然后基于内部订单号进行重入,其实可以去掉独立维护的订单映射关系表了,仅仅根据内部订单号的路由规则在订单表里面设定为UK即可保证,此外,由于埋入了时间信息,可以根据时间维度进行后续的订单db数据的归档,使得维护成本极大的降低。
为了实现这个能力,服务提供方需要单独提供一个内部订单号(交易单号)的申请接口,服务调用方在调用交易接口之前,先调用该订单号申请接口,申请到订单号后跟自己的业务订单号进行绑定(保障自己业务订单号和该交易订单号的一一映射),然后以该新申请的交易订单号作为重入的关键变量调用交易接口。
图3:基于内部订单号的关键调用流程
该方法的关键点是:1,外部服务调用方需要调用两次接口,但由于第一个单号申请接口及其轻量,所以成本比较低;2,调用方需要自己绑定业务单号和新申请的交易单号;3,服务提供方,需要提供两个接口,但可以去掉独立维护的订单映射表。
总之,该方法的订单映射关系维护的责任在服务的调用方,而不是服务的提供方。