1. 前言
所有支付公司都对资损(资金损失)看得很重,轻则钱没了,重则舆论风波,要是引起监管介入,更是吃不了兜着走。
常在河边走的支付人,如果想少湿鞋,一定要了解资损防控体系建设。
资损防控是一个很庞大的体系,本文尝试从实用性着手,化繁为简,论述资损防控的本质,如何防,如何控,以及一些典型场景及应对手段。
2. 资损本质
3. 资损防控本质
4. 资损防控全生命周期
5. 资损风险分类
6. 常见场景及应对
6.1. 金额放大缩小
背景:
世界各国的币种最小单位是不一样的,比如人民币最小单位是分,日元最小单位是元,同一个币种在不同的渠道接口中使用的单位也不一样,有些使用元,有些使用币种最小单位。
问题:
没有经验的工程师喜欢long或double存储金额,手动加减乘除,特别容易发生金额放大或缩小100倍。比如上游传的是元,以为传的是分,直接乘以100处理,资损发生。
解决方案:
制定适用于公司业务的Money类来统一处理金额。
在入口网关接收到请求后,就转换为Money类。
所有内部应用的金额处理,强制全部使用Money类运算、传输,禁止自己手动加减乘除、单位换算(比如元到分)。
数据库使用DECIMAL类型保存,保存单位为元。
在出口网关外发时,再根据外部接口文档要求,转换成使用指定的单位。有些是元,有些是分(最小货币单位)。
6.2. 幂等击穿
背景:
幂等性是一个数学和计算机科学术语,用于描述无论操作执行多少次,都产生相同结果的属性。在软件行业,应用极其广泛,当我们说一个接口支持幂等时,无论调用多少次,对系统造成的业务结果是一致的。(注意这里说的是业务结果)
问题:
如果一个支付系统不能保证幂等性,一笔交易可能变成多笔交易。比如向渠道发起部分退款50元,如果系统做了重试,且渠道不支持幂等,会被做为2笔交易处理,那么就会多退用户50元,资损发生。
幂等字段被变更,导致原有的幂等逻辑失败。无论对商户的收单系统,支付内部应用,外部渠道都有可能发生。
解决方案:
服务提供方在接口契约中一定要明确哪个是幂等字段。
内部应用的幂等字段变更,一定要跨域拉通对齐。
银行渠道的幂等字段及幂等条件一定要问清楚,不要想当然。
使用数据库唯一索引保存幂等字段。不要使用所谓的分布式锁来做幂等,只能用来辅助。
有能力的技术团队考虑建设幂等组件供全域使用。
特别注意在架构升级时,是否有变更幂等逻辑。
6.3. 流水号及短号重复
背景:
有些银行的订单号或交易请求号只有6位(所谓短号),用完后重新从1开始循环。
有些收单系统使用8位流水号。
系统升级可能导致流水重复。
问题:
支付时渠道返回“重复交易”,支付平台通常会推进到“支付中”或“未知”,然后调渠道查询接口,这时查到的是上一笔支付的结果,平台推进状态为“成功”,资损发生。
内部应用或商户请求支付平台的收单系统也有类似问题。
解决方案:
流水号过短场景下,需要拼接交易日期,如果1天之内有可能用完,让渠道升级接口。
短号场景下,查询回来的流水号,需要拼接渠道返回的交易日期及金额。
禁止网关生成流水号,全部使用上游业务系统发下来的流水号做为外部渠道的请求号。
系统升级时需要重点评估流水号变更逻辑,避免重复。
6.4. 返回码映射
背景:
每个渠道都有自己的返回码标准,内部各应用也有自己的返回码标准,返回给商户的也有一套收单定义的返回码标准。
有些渠道的返回码分为两级,有些只有一级。有些还分为“操作成功”和“业务成功”。所谓“操作成功”就是接口导致受理成功,但是不代表业务是否成功,只有“业务成功”才代表交易的成功。
问题:
调用渠道支付返回超时,平台推进到“失败”,但渠道最终处理成功,用户资损发生。如果是退款场景,平台资损发生。
渠道接口区分了“操作成功”和“业务成功”两个字段,但是平台只判断了“操作成功”就推进了单据到成功,资损发生。
商户调支付平台,内部各应用互相调用,支付平台调外部渠道,都有可能因为返回码映射导致资损发生。
解决方案:
需要明确哪些返回码是“业务成功”,哪些是“业务失败”,剩下的全部是“未知”。
只有明确是“业务成功”才能推进成功,明确是“业务失败”才能推进到失败。禁止把“超时”,“系统异常”,“交易重复”,“订单不存在”等返回码映射为失败。禁止把“操作成功”映射为业务成功。
流入类(扣用户钱)慎重推进到成功,流出类(给用户打款)慎重推进到失败。
6.5. 乱序
背景:
渠道异步通知回来可能比同步接口返回更快,内部应用之间消息、任务调度都无法保证顺序性。
问题:
如果渠道异步回调通知先回来,且在响应中返回“业务处理成功”,平台推进到“成功”,同步接口在异步回调之后才返回,且在响应中返回“处理中”,平台又推进到“处理中”。
渠道同步接口还没有响应回来,平台向渠道发起了查询,查询结果为“订单不存在”或“成功”,之后同步接口响应回来,但是响应结果和查询的结果不一致,如果处理不当,容易发生资损。
除了渠道交易,内部应用之间的交互也可能有类似情况。
解决方案:
状态机设计务必要保证“终态不可变更”。也就不管同步接口先返回,还是异步通知先到,或者查询补单先查到结果,只要是推进到了终态(成功,或失败),那就不能再变更单据状态。
默认所有的返回都有可能是乱序的,系统设计要防重,不要依赖顺序性。
6.6. 越权/环境
背景:
支付平台所有的操作都是需要授权的,比如商户需要使用哪种支付方式,费率是多少,适用于哪种业务,都有明确规定。对接的外部渠道,通常都有线下环境(也称为沙箱环境)、生产环境。
问题:
如果对商户请求的参数校验不完善,商户有可能越权使用没有签约的支付方式。或者把线下场景(通常手续费低)的产品用于线上比如游戏交易(通常手续费高),平台少收手续费。
支付平台线下测试环境配置了渠道生产环境的参数,在做提现测试时,把平台真实的资金提取到了个人账户。
解决方案:
商户请求参数需要严格做校验,不但要校验基本的签名验签,还需要检查业务权限,避免商户越权操作。
禁止在线下环境配置渠道的生产环境参数。对渠道生产环境参数做黑白名单管控,通过系统能力来杜绝人为误配。
对用户的操作行为也需要严格校验权限和数据,避免A用户做支付时使用了B用户绑定的卡信息。
6.7. 数据库操作
背景:
所有的交易都会与数据库进行交互,存在所谓的乐观锁和悲观锁的争论。
问题:
数据库操作往往需要同时操作多张表,也就是要求多表之间的事务性,但是乐观锁是做不到事务性的(真要做到的技术成本也很高)。一些研发工程师引入所谓的乐观锁,经常出现事务问题。
解决方案:
严格执行“一锁二判三更新”的原则。不要使用所谓乐观锁。
根据业务形态综合决定是否引入分布式事务。对大部分业务场景来说,都不需要分布式事务。通过“最终一致性”已经能解决绝大部分业务场景。
如果一定要引入分布式事务,一定要对运行的机制非常清楚,尤其是事务悬挂。
6.8. 状态机
背景:
所有的交易单据都是有状态的,比如支付中,成功,失败等。
问题:
经验不足的工程师,经常犯下面的错:
没有使用状态机设计思想,只是简单定义几个字符串表示“PAYING”, “SUCCESS”,“FAIL”等状态,然后使用if else 或switch case等写状态的流转。
状态机没有设计终态,或者终态仍然可以变更。典型的在乱序环境下,异步通知线程更新为“成功”,同步接口后返回,然后更新为“支付中”。
解决方案:
引入严格的状态机,明确某个状态在什么事件驱动下可以迁移到哪个目标状态。
要有终态概念,一旦到达终态,就不能再变更。如果数据异常,那就人工订正数据库的数据。
6.9. 多线程与资源共享
背景:
多线程无处不在,且还可能为解决一些特殊场景,引入线程变量或线程池操作。比如一些变量不想通过接口或函数显式传递,或者为了提高性能引入线程池等。
问题:
一些service(服务)使用了类成员变量,而且是可写的,导致不同线程写入不同数据,引起资损。
引入了Threadlocal变量,但在入口和出口没有做重置和清理,导致不同线程误用另一个线程的数据。
为提高性能引入缓存,但是混用了特定用户的缓存数据。比如设计是只是缓存了公共数据,但是在开发中把单个请求的特定数据也放入了缓存中,导致后续线程误用数据。
解决方案:
严格禁止在service(服务)使用可写的类成员变量。
Threadlocal变量在入口和出口一定要做重置和清理。
所有缓存数据,需要明确是所有线程共享,还是特定用户的数据。全局缓存数据和个人缓存数据不要混在一起。如果要有个人数据的缓存,一定要有与个人强相关的明确的KEY。
6.10. 兼容与灰度
背景:
所有的业务系统都面临架构升级,必然会出现所谓【新老系统 + 新老数据】的兼容问题。
问题:
在互联网应用中,所有发布都是灰度发布,数据库的变更也是一样。如果考虑不周全,大概率会出现线上问题,甚至资损。
比如:在灰度发布过程中,出去有可能是新代码,也可能是老代码,渠道异步回调通知时,有可能回调到新代码,也有可能回调到老代码。
数据库结构变更或数据变更也是如此,内部各应用之间的调用也是如此。
架构升级后,模型如果也做了升级,对历史数据的处理,到底是由老系统处理,还是新系统兼容处理?
解决方案:
最基本的要保证发布阶段的兼容:请求出去时:新代码+老数据。外部回调通知回来时:老代码+新数据。
所有DB变更,无论是结构变更,还是数据内容变更,全部都需要考虑兼容性。
所有架构升级,如果模型也做了升级,需要提供适配器,通过适配器同时兼容处理老数据,避免老系统长期无法下线。
6.11. 运营操作
背景:运营操作在支付平台的日常运营工作中无处不在,比如配置营销券,手动发起打款等。
问题:只要是人操作,一定无法保证百分之一百可靠。比如配置给拉新的营销,忘记设置使用条件,打款多打了一笔等,全部是资损。
解决方案:
使用技术手段进行预警。尤其是一些不符合常规的操作,要有一个黑名单规则集合。比如给每人发100元无门槛券,可无限领多次。
加入必要的审批流。为兼顾效率,可以按不同金额设置不同的审批权限。
严格校对领取权限,避免通过URL地址拼接参数,可以越权领取营销券。
实时监控券的领取和使用,比如对同一用户要有领取次数等监控。
7. 监控与对账
7.1. 监控
在发布或有变更时,重点监控成功率和提交量变化,如果成功率有明显下降,一定要及时回滚。
此外,成功率不仅仅影响信息流,还有可能隐藏着资损。比如在支付场景下,用户扣款成功,但是因为内部的BUG,导致支付单推进到了失败,从表象上看就是支付成功率跌了,但实际用户已经有了资损。
7.2. 对账
资损的发现更多的仍然依赖对账来解决。
对账需要对什么?前面有说过,资损风险有四大类:状态不符合预期,金额不符合预期,交易笔数不符合预期,越权交易。
对应的,对账主要对三个:状态,金额,交易笔数。
7.3. 实时对账与离线对账
一般的支付平台都会有内部系统之间的两两对账,这种对账主要是信息流层面的对账,主要勾兑状态、金额、笔数等数据的一致性。
再细分,还可以拆成实时对账和离线对账。
实时对账一般就是监听数据库的binlog,当数据有变动时,延时几秒后请求双方系统的查询接口,查到数据后进行对账。
离线对账一般就是把生产数据库的数据定时清洗到离线库(一般还可以分为天表和小时表),然后进行对账。
7.4. 三层对账
第一层是信息流对账。我方流水和银行清算文件的流水逐一勾兑。可能会存在长短款情况。
第二层是账单对账。就是把我方流水汇总生成我方账单,然后把银行流水汇总生成银行账单,进行对账。可能会存在银行账单和我方账单不一致的情况,比如共支付100万,渠道分2次打款,一笔98万,一笔2万。
第三层是账实对账。就是我方内部记录的银行头寸和银行真实的余额是否一致。可能存在我方记录的头寸是220万,但是银行实际余额只有200万的情况。
8. 应急与复盘
8.1. 应急
每个公司内容的应急流程都不一样,但都有一个共识:资损发生后,需要立即启动应急,也就是线上故障的优先级高于手头的项目研发。因为在资损发生的前几个小时,追回损失的概率是很高的。
如果是资损故障已经有预案,就根据预案执行。如果是新的资损故障,需要及时上报主管,以便及时处置。
需要特别注意的是,一线工程师在发现资损故障后,一定要第一时间上报给自己的主管。原因有2个:
主管的经验通常更丰富,所做的决策考虑到的因子更多,知道什么样的故障应该怎么处理,如果需要协调其它部门,也更为高效。
一线工程师看到故障往往第一直觉就是先找问题根因,或者害怕主管责备,容易出现因处置不及时导致故障影响面扩大。
8.2. 复盘
资损故障都需要复盘,但是复盘的目的不只是为了追责,更是要看以后如何做得更好,以堵住类似所有的漏洞。
复盘内容至少包括以下几个内容:
故障定级:一般根据故障影响面来定等级。
时间线:故障从引入、触发、发现、止损、恢复等全部重要时间节点。
影响面:影响多少用户,多少金额等。
根因:因为什么引起,必要时需要贴上代码或技术方案。
反思:整个处置过程哪些做得好,哪些还需要改进。
待改进项:后续需要做什么,以杜绝类似问题,或至少发生类似问题时能处置得更高效。
责任人或责任团队。
在制定待改进项时,需要注意以下几点:
尽量考虑通过技术手段来解决后续可能发生的类似问题。
尽量减少增加审批流程。
尽量不依赖个人的素质。
核心思路:每个人的能力是不一样的,同一个人在不同时间的状态也是不一样的。所以需要技术手段来确保不管什么人在什么状态下,都能防住资损或能高效处置资损。
9. 预案与演练
9.1. 预案
“预则立,不预则废”,这句古老的格言放到资损防控语境下尤其贴切。
预案需要针对可能发生的资损场景,制定详细的对策及处置流程,以便在故障发生时,可以高效地处置。
预案通常需要包含以下内容:
场景:明确预案处置的范围。
角色:一共有哪几个参与方,这几个参与方分别承担什么职责。
流程:什么角色在什么情况下执行什么样的操作。
9.2. 演练
预案做得再漂亮,如果没有提前演练,真出现问题时,往往是一团糟。这就要求我们不定期做一些演练。
演练通常分为两种:有损演练,和无损演练。需要根据实际情况选择。
常用的无损演练一般就是注入日志,或者修改对账脚本,对生产环境的数据没有影响。一方面检查是否能及时告警,以及告警出来后,值班的工程师是否及时接手和处置,处置过程是否得当。
有损演练直接触发线上交易。比如通过线下测试环境调用银行生产环境提现一笔小金额,就是一种典型的有损演练,因为这笔钱最后可能因为各种原因无法转账到备付金账户里去。这种有损演练,只要控制在小金额范围内,风险是可控的,且能更真实反映各方处置资损时的真实能力水平。
10. 产品设计要点
支付系统的产品设计,除了会员实名认证这种纯信息流外,其它大部分情况下都需要同时考虑以下情况:
信息流与资金流匹配。尤其是跨境场景下,更是如此。
正向与逆向都需要考虑。
异常场景需要提前考虑,尤其是逆向。比如退款,如果出现因为超过渠道退款有效期或因为其它各种原因退不出去,怎么办?
11. 全局通盘设计
资损防控不是点或线就能搞定的事,需要组合多种能力,才能建设出完善的资损防控体系。每个场景都可以考虑使用多种手段来解决,比如乱序的场景,就要使用幂等,状态机,终态设计,数据库一锁二判三更新等多种手段。
12. 结束语
中学时读到扁鹊的故事,很有趣:“魏文侯问扁鹊曰:‘子昆弟三人,孰最善为医?’对曰:‘长兄病视神,未有形而除之,故名不出于家。仲兄治病,其在毫毛,故名不出于闾。若扁鹊者,镵血脉,投Du药,副肌肤,故名闻于诸侯。’”。
资损防控也是如此,做得最好的,恰如同扁鹊的长兄,往往大家都不知道。希望本文能为各位在资损防控建设方面有所帮助,早日修成扁鹊长兄的才能:“未有形而除之”。
我刚毕业时进入的是传统行业,后来转战互联网支付,在我负责的第一个互联网支付项目要上线时,被当时的总监连问了几个问题:“发布手册有吗?灰度方案是什么?可能存在哪些异常场景,对应的预案有吗?发布出问题能否回滚?验证方式是什么?... ...”我哑口无言,因为以前在传统公司做的项目都是停机发布,且只管写代码,不管发布,哪有这些考虑。
再后来亲自处置了很多次资损事件,比如:
某个银行使用6位短号,循环使用,在支付未成功时做了查询,查到老的订单是成功,推进到支付成功,给用户发货。
银行异步通知比同步返回更快,先推进成功,同步请求回来又推进到支付中,引起用户投诉。
金额传的是元,对接渠道的研发以为是分,发出前乘了100,导致金额放大100倍。
一个域修改了一个字段的取值,导致下游域的幂等失败,重复出款。
案例数不胜数,于是只好安慰自己说:“那些还没有经历过资损的支付人,只是因为在支付这个行业呆的时间还不够久罢!”。
产品读者可以分享本文给研发工程师,少点资损,少点压力。
这是《图解支付系统设计与实现》专栏系列文章中的第(47)篇。欢迎和我一起深入解码支付系统的方方面面。