查看原文
其他

支付对账系统怎么设计?

无敌码农 无敌码农 2020-10-17

支付对账系统是整个支付清结算体系中具体基础性意义的一个环节,是确保支付平台与各类第三方支付渠道数据一致性的关键系统,是商户资金结算、资金划拨、资金报表等逻辑准确运行的重要前提。


支付对账涉及账单下载处理、核心对账、差错处理等诸多细节逻辑,同时根据交易量大小的不同,需要处理的数据量规模也不尽相同,需要在数据处理时进行一些比较细致地思考。在本文中,作者以单渠道日成功交易订单量300W左右规模为背景,以较少的系统资源占用为目标,给大家介绍下系统的实现细节。同时,对于数据不断增长的情况下,支付对账系统该如何演进,作者也会结合自身的实践与思考与大家一起交流探讨!



账单下载&处理


对于公司自建支付系统来说,一般会根据业务的复杂程度不同,对接多个支付渠道。对于互联网公司而言,常见的渠道会对接支付宝、微信、ApplePay等;而金融类的公司则更多会对接银联、易宝、快钱这类银行卡代收付通道,也会有直接对接银行渠道的;海外则是如Adyen、Stripe等这类国际支付公司。


各个渠道的账单接口下载形式,账单数据格式等会存在不同的差异,而如果完全按照第三方的原始账单格式存储,对于后续的账单数据处理逻辑会比较复杂,并且每增加一个新的支付渠道,账单存储表都需要根据渠道账单结构新建,增加开发工作量,所以并不合理。


为了实现账单数据的统一格式化,我们需要将各渠道原始的账单文件进行统一标准化转化,同时也需要设计一张相对通用的渠道账单数据表来存储不同渠道账单格式化后的数据,具体结构如下:


另外,在进行账单数据存储时为了提高效率,需要将标准账单文件格式设计得与表结构一致,这样在完成数据转换后可以直接将文件load/copy到数据库中,这样速度会快很多;而考虑数据规模会增长得超级大,这张表也可以存储在Hive上,只是对于大部分公司的交易量来说,这么做会有一些技术实现上的成本,目前作者采用的是Postgresql数据库(版本为9.5.4)作为账单存储库,数据规模大概已在10亿条左右,表示暂无压力。


此外,对于账单的下载逻辑也需要考虑防重逻辑的,即同一个渠道账号的同一天的账单数据不能重复下载和入库,所以除了存储具体的账单数据外,也需要设计一张账单下载记录表,用于存储那个渠道账号哪一天的下载情况,并在账单下载任务启动起根据该表进行防重复下载逻辑判断。这里还需要说明下,在下载原始账单和转化标准账单时由于账单文件读写都是本地磁盘,为了统一集中管理这些账单文件、也为了数据安全需要采用统一文件存储服务,如可以采用腾讯云的CFS文件存储,或者自己搭建一个文件夹共享服务。


账单下载记录中也需要存储原始账单文件及标准账单文件的下载位置,平时基本上不会用到,只是为方便日后用于数据问题排查,需要检索原始文件时,方便查找及数据重新加载。


核心对账逻辑


完成相应渠道的账单下载任务后,系统就可以根据各个渠道的特点及对账平台任务系统的排班逻辑,启动相应渠道的对账任务了,例如微信账单在10点左右开始下载,预计完成时间为10分钟以内,那么就可以将微信的对账任务安排在11点开始执行,以此类推,各个渠道根据自身实际情况确定对账时点。


从逻辑上看对账的形式是为了完成A表与B表的集合,如下图所示:


一般情况下,与第三方支付渠道进行对账时,会以平台订单号作为关联条件,将账单表中的数据与支付平台订单表的数据进行full join得到一个集合全量,得到的集合会是一个交集、两个补集。处于交集部分的数据集说明根据订单号是可以对应上的,但是我们还需要进行订单金额的比对,如果一致则说明无差错,对平的数据集按照结算数据要求取账单数据+平台订单数据业务字段全集,直接生成对账明细表,而不一致的则需要生成差错类型为“金额不一致”的差错数据,并记入对账差错数据表


处于账单数据这一侧的补集属于长款差错,即这部分数据在第三方账单中存在,而在支付平台订单成功数据集中未找到,导致这部分差错数据的原因,可能有跨天交易的情况、也可能是线上测试数据导致、或者属于系统层面的订单掉单,在后面差错处理中会详解介绍。


而处于支付平台订单这一侧的补集则属于短款差错,即这部分数据在支付平台成功订单中存在,而在渠道对账时点的账单中不存在,造成这部分差错的原因有可能是跨天交易情况导致,也有可能是第三方结算错误,具体原因需要在设计差错处理逻辑是根据不同情况进行处理。


在了解到对账的逻辑后,那么在具体进行系统编码时应该如何处理呢?


按照上述逻辑,我们需要将账单数据表与支付平台订单表进行full join,但是由于账单表我们是存储在Postgresql上的,而支付系统所采用的数据库可能是Mysql或Oracle,总之,从系统拆分的角度看,一般是不会将对账处理与在线支付订单放在一个库中的,即便在一个库直接关联账单表与支付订单表也是不明智的,一方面这样可能会影响实时支付系统的稳定性,另外这些表的数据都是不断增长的,随着数据的积累会也会导致对账数据查询变慢。


所以,在进行某个渠道对账时需要根据条件将账单数据、支付平台订单数据分别清洗到两张中间表中,分别叫做账单待对账中间表(A表)订单待对账中间表(B表),然后通过这两张表进行full join操作,这样可以确保对账逻辑不影响别的业务,同时这部分数据可以在日终完成对账任务后定期清理掉,确保中间表数据规模处于可控状态。


在代码层面通过A表 full join B表后,会得到一个结果集,如果这个结果集数据比较大,系统没有采用Spark+Hive这种方式话,通过传统编程方式则需要对查询进行分页,考虑到数据逐条对账处理速度较慢,可以一页获取数据条数稍多一些,例如一次取5W条,然后在系统内部采用多线程方式对数据集分割后并行处理,每个线程按照特定的对账逻辑执行,得到对账明细结果集或差错结果集后,批量存入对账数据库。


核心参考代码如下:


public boolean execute(ShardingContext shardingContext) {
    boolean exeResult = true;
    long startTime = System.currentTimeMillis();
    String paramValue = shardingContext.getJobParameter();
    CheckRequest checkRequest = (CheckRequest) JsonUtils.json2Object(paramValue, MbkCheckRequest.class);//对账批次请求信息
    Map<String, Object> paramMap = new HashMap<String, Object>();
    paramMap.put("batchNo", checkRequest.getBatchNo());//批次号
    paramMap.put("channel", checkRequest.getChannel());//渠道
    paramMap.put("tradeType", checkRequest.getTradeType());//交易类型
    //关闭PG执行计划解释(PG9问题)
    unionCheckOrderMapper.enableNestloopToOff();
    int totalCount = unionCheckOrderMapper.countByMap(paramMap);//总数
    int pageNum = 50000;//pageSize,每页5W条数据
    int fistPage = 1;
    int offset = 2;
    PostgreSQLPageModel pm = PostgreSQLPageModel.newPageModel(pageNum, fistPage, totalCount);
    int page = pm.getTotalPage();
    BlockPoolExecutor exec = new BlockPoolExecutor();
    exec.init(); //初始化线程池
    ExecutorService pool = exec.getMbkBlockPoolExecutor();
    List<Future<?>> results = Collections.synchronizedList(new ArrayList<Future<?>>());// 并行计算结果数据类型定义
    for (int i = 1; i <= page; i++) {//分页进行数据Fetch
        long startTime2 = System.currentTimeMillis();
        pm.setCurrentPage(i); //设置当前页码
        offset = pm.getOffset();
        paramMap.put("pageSize", pageNum);
        paramMap.put("offset", offset);
        List<UnionCheckOrder> outReconList = unionCheckOrderMapper.selectByMap(paramMap);
        Map<String, List<?>> entityMap = groupListByAvg(outReconList, 1000);
        CountDownLatch latch = new CountDownLatch(entityMap.size());
        OutReconProcessTask[] outReconProcessTask = new OutReconProcessTask[entityMap.size()];
        Iterator<Map.Entry<String, List<?>>> it = entityMap.entrySet().iterator();
        try {
            int j = 0;
            while (it.hasNext()) {
                List<UnionCheckOrder> uList = (List<UnionCheckOrder>) it.next().getValue();
                outReconProcessTask[j] = new OutReconProcessTask(latch, uList);
                results.add(pool.submit(outReconProcessTask[j]));
                j++;
            }
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long endTime2 = System.currentTimeMillis();
    }
    unionCheckOrderMapper.enableNestloopToOn();//开启PG执行计划解释
    exec.destory();
    long endTime = System.currentTimeMillis();
    return exeResult;
}


考虑完成对账、以及后面会涉及的处理差错,其结果都是产生对账明细数据,而对账明细数据是渠道vs平台后最为准确的资金数据,对于后续的商户清分、资金清算、结算都具有重大意义,所以复杂查询的频率会比较高,并且数据的使用时间范围也比较大,对于交易量比较大的公司一年的支付数据量可能达到数十亿规模,在数据存储方面,可以考虑采用TIDB这类分布式关系型数据库来存储对账结果相关的数据,这样后续的数据处理逻辑效率会提高很多。可能有人会问为什么不直接使用Hive进行查询,这是因为Hive的单条查询和批量查询的效率是一样的,所以并不太适合实时查询,而如果需要将对账、结算等数据通过管理系统进行管理,涉及的查询场景比较多,所以综合考虑使用分布式关系型数据库会更合适一些。


差错处理逻辑


对账逻辑执行完成后,会产生一部分对账逻辑执行过程中,系统无法匹配的对账差错数据,这部分数据会在对账完成后记录在对账差错信息表中,差错信息表根据差错类型记录该笔差错的详细信息,除包括渠道类型、金额、交易时间等关键信息外,还会对差错进行分类、定义特定的差错类型编码。


根据根据不同情况差错大概可以分为三类:长款、短款、金额错误。其中长款根据对账处理方式的不同可以分为“渠道成功,平台订单不存在”、“渠道成功、平台状态非成功”两种情况,从生产实践上看,因为支付系统中会存在比较多的支付失败订单,而国内支付渠道的账单多数情况下只会提供用户支付成功的账单数据,所以在实际进行对账时,在A中间表、B中间表清洗的都是双方认为成功的订单数据,在这种情况下产生的长款类型也就只有“渠道成功,平台订单不存在”这种类型了。


而对于短款来说,就是在当日的账单数据中没有匹配到,差错类型也就是“平台成功,账单数据不存在”。而“金额不一致”的情况相对少见,主要出现在如微信、支付宝进行营销活动时,造成的支付平台订单金额与第三方不一致的情况;另外,存在订单部分退款时,如果支付系统订单模型没有很好地满足这类情况的话,也会导致对账金额不一致的情况发生。


针对不同的差错类型情况,需要根据根据系统实际情况,设计相应地处理流程。例如,对于长款差错需要识别其产生的原因,如果是因为交易跨天导致的,例如交易在平台时间为某日的23:59:59秒,那么发送到第三方渠道的时间可能已经是第二天的00:00:01秒这样,那么在第一天对账是会产生一笔短款差错,此时只需要消除这笔差错、同时生成对应的对账明细即可。而如果是因为支付平台状态未处理成功,则是系统掉单问题导致,除了正常消除这笔差错、产生对应的对账明细数据外,还需要通知支付系统进行状态更新操作,其涉及的业务逻辑,还需要根据整个支付平台的流程设计,触发商户回调、或者还需要通知账务系统进行补记账操作。


总之,需要根据具体的差错类型及原因,结合整个支付系统的流程来保证系统间数据的一致性,以下是作者根据通用场景设计、根据不同差错类型设计的处理流程,供大家参考:


(一)、长款差错通用处理流程




在以上长款处理流程中,关于跨天交易情况的区分,这里有一个细节的设计:在判断完支付订单状态为成功后,之所以在判断是否在T-1天或者T-N天是否存在同一笔匹配的短款差错之前,判断是否存在对账明细的情况,是因为在系统设计时考虑订单结算的实时性,允许对于短款差错采取T+1日的处理方式,具体方式会在短款处理流程中说明。


(二)、短款差错通用处理流程




在以上短款差错处理流程中,大部分公司为了解决跨天交易的问题,会要求对短款差错挂账一天,也就是说要求在T+2日对短款进行处理,因为在T+2日时账单和对账出现的长款差错可以正常抵消处理。在这里的设计中,允许在T+1日处理,即在没有第三方账单信息的情况下,通过订单查询接口进行对账,并默认将这笔交易的渠道结算时间设置为T+2,对于支付订单,国内大部分渠道这么设置是正好可以匹配的,而对于退款可能渠道的结算时间为T+3甚至更长,这种情况会导致对账明细中的渠道结算时间与实际渠道结算时间存在一定的偏差,而在后面T+3或更长的账单日时产生的长款差错处理,可以采取更新策略,即根据实际的渠道结算时间更新对账明细表的结算时间,这样就确保了数据渠道结算时间的准确性。


上面的情况如果考虑商户结算逻辑,可能需要对这种情况做点特殊标记,如设置渠道结算时间、商户结算时间、或渠道实际结算时间来处理,当然如果为了确保资金结算的稳妥性,也可以采取挂账T+2/T+N处理的方式,这一点由流程和规则决定,系统只是额外提供了一种逻辑处理方式而已。


(三)、金额不一致差错处理


正常情况下金额不一致一般以第三方渠道账单为准,从账务一致性的角度考虑,可能也需要在流程中加入调账逻辑,具体的流程可根据具体的产品规则设计。


系统演进化方向


对于对账系统的演变主要需要从考虑数据的增长、任务资源的合理配置以及系统监控这几个方向去考虑。如果数据量持续增长到传统方式已无法处理,可以采用Spark Streming+Hive+Tidb等组合技术方案进行改进。


此外对账系统是一个以定时任务为主的系统,对于定时任务处理框架的选择可以采用分布式任务框架(推荐elasticjob/saturn)+自定义任务逻辑的方式综合处理(如有些任务存在先后顺序,如果框架本身不提供这类处理功能,则需要通过业务规则限制)。


而从系统监控角度,由于任务系统不同于实时交易流程,具有执行时间长、数据操作范围广泛的特点,除了进行正常的进程级别的监控外,对于各个任务的执行情况,也需要进行比较细致的监控,这部分可以通过监控打点等方式综合解决;而对于业务异常日志的监控则可以通过Sentry等日志监控工具进行监控。


由于作者水平有限,不足之处还请多多包涵!谢谢你们的关注~



如果对往期相关文章感兴趣可以👇这里

互联网账户系统如何设计(下篇)?

互联网账户系统如何设计(上篇)?

移动端支付系统如何设计有效地防重失效机制?

支付渠道参数如何设计成路由化配置?

业务日志监控工具Sentry介绍


—————END—————

如果觉得小哥在认真写文章,可以关注下公众号,支持下哦

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存