图解支付系统的关键设计

1,221 阅读29分钟

大家好,我是隐墨星辰,专注境内/跨境支付架构设计十余年。

在前面介绍过支付系统的整体设计,有兴趣的读者可点击链接查看:图解支付系统整体设计,今天聊聊支付系统的一些关键设计细节。

内容主要包括一些支付系统常用的设计,比如领域建模,状态机,幂等,日志规范,业务ID生成规范,监控,资损防控,支付安全等。这些技术在互联网其它领域比如电商也是通用的。

这里只摘录了部分精华内容出来,但已经能表达最核心的设计理念。

1. 领域建模

领域驱动设计(DDD)思想在支付系统的设计中应用非常广泛,但需要对行业有非常深刻的理解,否则构建出来的模型稳定性极差,动不动就需要修改核心模型。

收单、结算、支付引擎、会员、商户服务、渠道网关等各关键域都有自己独特的业务,模型也是不一样的。给两个小示例:

比如会员的三户模型。

说明:

  1. 客户是一种社会属性,代表真实社会的一个实体。比如张三分别使用1388888888和1399999999两个手机号在微信支付进行注册,然后使用同一个身份证做了实名认证,那么这两个用户仍然被归属于同一个客户。
  2. 用户是一种业务属性,就是使用产品的身份。 比如张三分别使用1388888888和1399999999两个手机号在微信支付进行注册,那么就会存在2个用户,这2个用户的业务是独立的。
  3. 账户是一种金融属性,代表使用资金的身份。 比如张三使用1388888888开通了微信支付,经过实名认证后,可以开通余额,也可以开通基金账户。

如果没有金融属性,一般就没有账户,比如注册博客园后,就只有用户没有账户。

下图是会员的模型:

说明:

  1. 用户、客户、账户的关系,详见上面的三户模型说明。会员注册后,就会有一个用户(UserId),完成实名认证,就会有一个客户(CustomeId),开通余额,就会有账户(AccountNo)。
  2. 其它的关系,图中已经很清楚。

支付渠道的模型:

说明:

渠道:对外部渠道做一个抽象,比如国内微信、支付宝、银联等,国外的WPG、MGPS等。

业务能力:支付、退款、撤销、请款等能力详细描述。比如退款有效期、最小限额等。

接口能力:描述接口本身的能力,比如渠道的请求号生成规则。特殊情况下可能有短号问题。

2. 状态机

状态机,也称为有限状态机(FSM, Finite State Machine),是一种行为模型,由一组定义良好的状态、状态之间的转换规则和一个初始状态组成。它根据当前的状态和输入的事件,从一个状态转移到另一个状态。

下图就是收单子域设计中交易单的状态机设计。

从图中可以看到,一共4个状态,每个状态之间的转换由指定的事件触发。因为有组合支付的情况,所以全额支付成功才会推进到成功。


常见代码实现误区

  1. 经常看到工作多年的研发工程师实现状态机时,仍然使用if else或switch case来写。这是不对的,会让实现变得复杂,且容易出现问题。
  2. 直接在订单的领域模型里面使用String来定义,而不是把状态模式封装单独的类。
  3. 直接调用领域模型更新状态,而不是通过事件来驱动。
  4. 业务不做拆分,一个状态机包含了所有的业务状态,比如支付单把退款和撤销状态也耦合进去,导致状态机巨复杂。

下面是一个比较差的状态机设计:

限于篇幅,这里无法给出完整示例代码。有兴趣的可以去网上看看,良好的示例有很多。

/**
 * 支付状态机
 */
public enum PaymentStatus implements BaseStatus {

    INIT("INIT", "初始化"),
    PAYING("PAYING", "支付中"),
    PAID("PAID", "支付成功"),
    FAILED("FAILED", "支付失败"),
    ;

    // 支付状态机内容
    private static final StateMachine<PaymentStatus, PaymentEvent> STATE_MACHINE = new StateMachine<>();
    static {
        // 初始状态
        STATE_MACHINE.accept(null, PaymentEvent.PAY_CREATE, INIT);
        // 支付中
        STATE_MACHINE.accept(INIT, PaymentEvent.PAY_PROCESS, PAYING);
        // 支付成功
        STATE_MACHINE.accept(PAYING, PaymentEvent.PAY_SUCCESS, PAID);
        // 支付失败
        STATE_MACHINE.accept(PAYING, PaymentEvent.PAY_FAIL, FAILED);
    }

    // 状态
    private final String status;
    // 描述
    private final String description;

    PaymentStatus(String status, String description) {
        this.status = status;
        this.description = description;
    }

    /**
     * 通过源状态和事件类型获取目标状态
     */
    public static PaymentStatus getTargetStatus(PaymentStatus sourceStatus, PaymentEvent event) {
        return STATE_MACHINE.getTargetStatus(sourceStatus, event);
    }
}

3. 幂等

幂等是针对重复请求的,支付系统一般会面临以下几个重复请求的场景:

  1. 用户多次点击支付按钮:在网络较差或系统过载情况下,用户由于不确定交易是否完成而重复点击。
  2. 自动重试机制:系统在超时或失败时重试请求,可能导致同一支付多次尝试。
  3. 网络数据包重复:数据包在网络传输过程中,复制出了多份,导致支付平台收到多次一模一样的请求。
  4. 异常恢复:在系统升级或崩溃后,未决事务需要根据已有记录恢复和完成。内部系统重发操作。

幂等解决方案

所谓业务幂等,就是由各域自己把唯一性的交易ID作为数据库唯一索引,这样可以保证不会重复处理。

在数据库前面可以加一层缓存来提高性能,但是缓存只用于查询,查到数据认为就返回幂等成功,但是查不到,需要尝试插入数据库,插入成功后再刷新数据到缓存。

为什么要使用数据库的唯一索引做为兜底,是因为缓存是可能失效的。

在面临时经常有候选人只回答到“使用redis分布式锁来实现幂等”,这是不对的。因为缓存有可能失效分布式锁只是用于防并发操作的一种手段,无法根本性解决幂等问题,幂等一定是依赖数据库的唯一索引解决。

大部分简单的支付系统只要有业务幂等基本也够用了。

更复杂的多机房容灾情况下,幂等也会非常复杂。比如在A机房处理了一笔业务,这时A机房挂了,流量切到了B机房,B机房如果没有相关的幂等数据,那就会幂等失败。

4. 日志规范

只要在公司写过代码,就一定打印过日志,但经常发现一些工作多年的工程师打印的日志也是乱七八糟的。我曾经在一家头部互联网公司接手过一个上线一年多的业务,相关日志一开始就没有设计好,导致很多监控无法实现,出了线上问题也不知道,最后只能安排工程师返工改造相关的日志。

我们要明白日志是用来做什么的。只是先弄明白做事的目的,我们才能更好把事情做对。在我看来,日志有两个核心的作用:1)监控,诊断系统或业务是否存在问题;2)排查问题

对于监控而言,我们需要知道几个核心的数据:业务/接口的请求量、成功量、成功率、耗时,系统返回码、业务返回码,异常信息等。对于排查问题而言,我们需要有出入参、中间处理数据的上下文、报错的上下文等。

接下来,基于上面的分析,我们就清楚我们应该有几种日志:

  1. 接口摘要日志。监控接口的请求量、成功量、耗时、返回码等。使用固定格式,需要打印:时间、接口名称、结果(成功/失败)、返回码、耗时等基本信息就足够。
  2. 业务摘要日志。监控业务的请求量、成功量、核心业务信息、返回码等。使用固定格式,需要打印:时间、业务类型、上一步状态、当前状态、返回码、核心业务信息(不同业务有不同的核心业务信息,比如流入,就有支付金额/退款金额,卡品牌,卡BIN等)。
  3. 详细日志。用于排查问题,不用于监控。格式不固定。主要包括时间、接口、入参、出参、中间处理数据输入、异常的堆栈信息等。
  4. 系统异常日志。同时用于监控。格式固定。需要打印:时间、错误码、错误信息、堆栈信息等。

5. 金额处理规范

对于研发经验不足的团队而言,经常会犯以下几种错误:

  1. 没有定义统一的Money类,各系统间使用BigDecimal、double、long等数据类型进行金额处理及存储。
  2. 定义了统一的Money类,但是写代码时不严格遵守,仍然有些代码使用BigDecimal、double、long等数据类型进行金额处理。
  3. 手动对金额进行加、减、乘、除运算,单位(元与分)换算

带来的后果,通常就是资金损失,再细化一下,最常见的情况有下面3种:

  1. 手动做单位换算导致金额被放大或缩小100倍
    1. 比如大家规定传的是元,但是其中有位同学忘记了,以为传的是分,外部渠道要求传元,就手动乘以100。或者反过来。
    2. 还有一种情况,部分币种比如日元最小单元就是元,假如系统约定传的是分,外部渠道要求传元,就可能在网关处理时手动乘以100。
  1. 1分钱归属问题。比如结算给商家,或计算手续费时,碰到除不尽时,使用四舍五入,还是向零舍入,还是银行家舍入?这取决于财务策略。
  2. 精度丢失。在大金额时,double有可能会有精度丢失问题。

最佳实践:

  1. 制定适用于公司业务的Money类来统一处理金额。
  2. 入口网关接收到请求后,就转换为Money类
  3. 所有内部应用的金额处理,强制全部使用Money类运算、传输,禁止自己手动加减乘除、单位换算(比如元到分)。
  4. 数据库使用DECIMAL类型保存,保存单位为元。
  5. 在出口网关外发时,再根据外部接口文档要求,转换成使用指定的单位。有些是元,有些是分(最小货币单位)

6. 业务ID生成规则

数据库一般都会设计一个自增ID作为主键,同时还会设计一个能唯一标识一笔业务的ID,这就是所谓的业务ID(也称业务键)。比如收单域的收单单号。

也有人采用所谓雪花算法,但其实不适用于支付场景。

下面以32位的支付系统业务ID生成为例说明。实际应用时可灵活调整。

第1-8位:日期。通过单号一眼能看出是哪天的交易。

第9位:数据版本。用于单据号的升级。

第10位:系统版本。用于内部系统版本升级,尤其是不兼容升级的时候,老业务使用老的系统处理,新业务使用新系统处理。

第11-13位:系统标识码。支付系统内部每个域分配一段,由各域自行再分配给内部系统。比如010是收单核心,012是结算核心。

第14-15位:业务标识位。由各域内部定,比如00-15代表支付类业务,01支付,02预授权,03请款等。

第16-17位:机房位。用于全球化部署。

第18-19位:用户分库位。支持百库。

第20-21位:用户分表位。支持百表。

第22位:预发生产标识位。比如0代表预发环境,1代表生产环境。

第23-24位:预留。各域根据实际情况扩展使用。

第24-32位:序列号空间。一亿规模,循环使用。一个机房一天一亿笔是很大的规模了。如果不够用,可以扩展到第24位,到十亿规模。

7. 自动化渠道开关

外部渠道接多了,时不时有个渠道半夜宕机或非预期维护,人工运维成本高,搞个自动化开关。

说明:

  1. 渠道初始为完全打开。
  2. 当指定时间内成功率低于阈值或指定时间内连续失败次数大于阈值,就关闭渠道。
  3. 关闭渠道后,捞取最近成功的一笔发起查询探测,如果查询失败,仍然关闭。
  4. 如果查询成功,说明和渠道的通路是通的,且渠道能提供基本的服务,打开灰度25%。
  5. 如果灰度25%情况下,成功率不达标,仍然关闭。
  6. 如果灰度25%情况下,成功率达标,继续加大灰度比例,直到100%。

注:上述灰度打开算法还可以优化为:N*2算法,其中N初始为1。举个例子:先打开1%,符合要求后,依次打开:2%,4%,8%,16%,32%,64%,100%。通过7次操作后,100%打开。这个算法适合一些体量大的渠道,直接开25%如果仍然失败会影响很大批量的用户。对于小流量渠道,灰度1%可能很久也没有量进来,不如直接25%见效快。

8. 返回码设计与映射

外部商户对接支付平台,支付平台内部有自己的业务处理,同时还对接了外部的很多渠道,所以需要管理三套返回码

  • 提供给商户OpenAPI使用的返回码:这块可以直接参考微信支付、支付宝等机构的门户网站。
  • 内部各应用使用的标准返回码:用于内部业务的处理。
  • 渠道返回码:外部渠道提供的返回码,每个渠道都不一样,需要映射到内部标准返回码。

基本原则:

  • 制定统一返回码规范:在团队或公司层面制定统一的返回码规范,明确各个返回码的含义,确保各模块一致性。
  • 严格遵守返回码定义:研发人员在编码时,应严格按照规范返回对应的返回码,确保返回码与实际状态匹配。明确成功才推进成功,明确失败才推进失败,其它全部按“未知”处理
  • 区分接口/通信成功与业务成功
  • 流入到平台的(支付、充值等),谨慎映射到成功。从平台流出的(提现,代发等),谨慎映射到失败。

支付平台内部也分了不同域,建议使用一个共同的规范,比如:RS+子系统编号+错误级别+具体返回码。具体如下图所示:

说明:

1-2位:固定值RS,Result缩写。

3-5位:子系统编号。比如001:收银支付,002:会员等。可方便定位哪个系统出的问题。

6位:错误类或等级。比如:0:正常,1:业务级异常,2:系统级异常。

7-9位:各业务线自己定。比如:1xx:参数相关,2xx:数据库相关,3xx:账户状态/Token状态相关等。

这样的好处在于,每个子域或子系统既有全局的规范,又有自己的灵活性,减少沟通成本。

注意:上面只是写了resultCode,还需要有message,用于描述这个码代表什么语义。

踩过的坑很多,大致可以归为以下几类:

  1. 对客映射不准确,导致用户持续重试失败,影响用户体验。比如“余额不足”或“风控不过”,返回给用户“系统异常,请重试”,有些用户就疯狂地重试。
  2. 外部渠道没有明确成功或失败,内部映射成明确成功或失败造成资损。比如:
    1. 支付同步请求渠道响应还没有回来,发起了查询,查询返回“订单不存在”,直接推进失败,但最后银行扣款成功。
    2. 退款同步请求渠道响应返回“系统异常”,直接推进到失败,但最后银行退款成功。
  1. 外部渠道有双层返回码,没有做完整判断。比如第1层只表示接口是否成功(通信层面),第2层才是表示业务是否成功,但是只判断了接口层面,就推进了内部订单的业务状态。
  2. 返回码制定过于笼统或太细

9. 退款自动重试与支付自动重试

在支付系统日常运营工作中,处理退款重发的运营工作量会随着交易量的增长而线性增长。达到一定程度就需要考虑建设退款自动重试的能力。

下面两种情况是最常见的需要做退款重发的场景:

  1. 支付平台发给外部渠道超时。
  2. 外部渠道内部出现BUG或临时系统异常。

当交易量足够大时,完全人工重发,工作量也是很可观的。但是我们不能简单地做系统自动重发,因为退款有可能是会有资损发生的。比如:用户支付100元,退款50元,渠道不支持幂等,渠道已经内部退款成功,但是我们系统因为逻辑有缺陷又做了重发,那就是退款2次成功,资损50元。

有些外部渠道的设计不太好,比如只能通过支付请求号去退款,又支持多次部分退,那就相当于没有幂等能力。

下面是一个简化版的重发逻辑。

10. 使用多线程并发获取支付方式

每个用户在支付系统中绑定了很多支付方式,比如不同的银行卡,还有内部的余额,红包等。每次渲染收银台之前,都需要去获取这些支付系统,汇总后展示给用户。

一种方式就是串行去获取,但这明显会影响用户体验。最优的方案当然是使用多线程并行获取。

11. 同步受理异步处理

用户提交支付请求后,如果是外部银行通道,通常耗时都需要几百到几千毫秒,如果全链路都是同步接口,那么整个系统的线程很快就被消耗完,且一旦外部银行出现响应慢的情况,极其容易出现雪崩现象

所以我们在调用外部银行扣款时,通常都使用“同步受理异步处理”的方案。简单地说,就是先受理用户的请求,做基础校验,校验通过后,保存到数据库,发起一个异步线程请求到外部银行,然后马上返回给用户,前端再发起定时轮询结果。

当然还可以优化为在渠道网关做异步化,因为这里对接渠道,Hold住的线程影响面最小。

常见误区

  1. IO密集型任务线程池大小设置为CPU核数的2倍

哪怕是IO密集型服务,我们也不能简单设置为CPU核数的2倍,我们仍然要考虑任务执行耗时,系统设计的最大并发数是多少等因数。

建议为:系统预期最大并发任务数 * 单任务平均耗时。

注意,这个耗时是指等待外部资源的耗时,不是CPU运算耗时。比如外发银行后,等待外部银行返回的过程,就是等待时间,基本不消耗CPU资源。

  1. 为什么设置了最大线程数不生效

曾经碰过一个线上问题:使用ThreadPoolExecutor,设置了核心线程数,最大线程数,但是线上出现很多超时未处理的任务,但是请求数没有超过最大线程数。排查很久才发现虽然设置了最大线程数,但是没有设置队列大小(LinkedBlockingQueue) ,那么它会默认为Integer.MAX_VALUE,这基本上可以认为是无界队列,也就是请求全部放到了队列中。

所以读者如果使用ThreadPoolExecutor来配置线程池,最好是根据自己的诉求,把参数设置完整,包括核心线程数,最大线程数,队列大小,拒绝策略等。比如有些业务超时后已经没有意义,那就把队列放小点,拒绝策略为直接拒绝。

具体的请参考JAVA官方文档。

  1. 直接new线程

因为简单,有些同学喜欢直接new线程。的确,这种方式在简单场景下是没有问题的,但是复杂场景下是很容易出问题,且不好排查,建议不要养成这样的习惯。如果场景真的非常简单,也建议使用创建固定大小线程池来做,比如ExecutorService executor = Executors.newFixedThreadPool(n)。

12. Spring事务模板

为什么不使用@Transaction注解

以前写管理平台的代码时,经常使用@Transaction注解,也就是所谓的声明式事务,简单而实用,但是在做支付后,基本上没有使用@Transaction,全部使用事务模板来做。主要有两个考虑:

1)事务的粒度控制不够灵活,容易出现长事务

@Transactional注解通常应用于方法级别,这意味着被注解的方法将作为一个整体运行在事务上下文中。在复杂的支付流程中,需要做各种运算处理,很多前置处理是不需要放在事务里面的。

而使用事务模板的话,就可以更精细的控制事务的开始和结束,以及更细粒度的错误处理逻辑。

@Transactional
public PayOrder process(PayRequest request) {
    validate(request);
    PayOrder payOrder = buildOrder(request);
    save(payOrder);
    // 其它处理
    otherProcess(payOrder);
}

比如上面的校验,构建订单,其它处理都不需要放在事务中。

如果把@Transactional从process()中拿走,放到save()方法,也会面临另外的问题:otherProcess()依赖数据库保存成功后才能执行,如果保存失败,不能执行otherProcess()处理。全部考虑进来后,使用注解处理起来就很麻烦。

2)事务传播行为的复杂性

@Transactional注解支持不同的事务传播行为,虽然这提供了灵活性,但在实际应用中,错误的事务传播配置可能导致难以追踪的问题,如意外的事务提交或回滚。

而且经常有多层子函数调用,很容易子函数有一个耗时操作(比如RPC调用或请求外部应用),一方面可能出事长事务,另一方面还可能因为外调抛异步,导致事务回滚,数据库中都没有记录保存。

以前就在生产上碰到过类似的问题,因为在父方法使用了@Transactional注解,子函数抛出异常导致事务回滚,去数据库找问题单据,竟然没有记录,翻代码一行行看,才发现问题。

事务模板示例:

public class PaymentSystemTransactionTemplate { 

    public static <R> R execute(FlowContext context, Supplier<R> callback) { 
        TransactionTemplate template = context.getTransactionTemplate();
        Assert.notNull(template, "transactionTemplate cannot be null"); 
        
        PlatformTransactionManager transactionManager = template.getTransactionManager();
        Assert.notNull(transactionManager, "transactionManager cannot be null"); 

        boolean commit = false;
        try {
            TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); // Corrected "TranscationStatus" to "TransactionStatus"
            R result = null;
            try {
                result = callback.get();
            } catch (Exception e) {
                transactionManager.rollback(status); 
                throw e;
            }
            transactionManager.commit(status);
            commit = true;
            return result;
        } finally {
            if (commit) {
                invokeAfterCommit(context);
            }
        }
    }

    private static void invokeAfterCommit(FlowContext context) {
        try {
            context.invokeAfterCommit();
        } catch (Exception e) {
            // 打印日志
            ... ...
        }
    }
}

13. 分库分表

当数据量大的时间,分库分表是再所难免的。一个经典的面试题是:如果分了100张表,按商户来分表,还是按商户订单号来分表?如果按商户分表怎么解决各表流水数据量平衡问题?如果是按商户订单号来分表,商户想按时间段查询怎么办?

解法有很多种。一种典型的解法,就是线上数据库按商户订单号分表,同时有一个离线库冗余一份按商户号分表的数据,甚至直接使用离线数据平台的能力,把商户的按时间段查询需求从在线库剥离出来。

网上资料很多,不赘述。

14. 分布式事务

分布式事务是个好东西,但是复杂度也高,还经常出现所谓的事务悬挂问题,且虽然各家都号称简单易用,对业务代码侵入少,但事实并非如此。

所以我个人更倾向于避免使用分布式事务解决方案,而是采用最终一致性来解决。对大部分中小公司来说,最终一致性已经够用。

网上资料很多,不赘述。

15. 流程引擎

在支付系统中,流程编排随处可见,比如收银台编排获取各种绑定资产,渠道网关调用外部的渠道进行支付等。可以考虑使用成熟的工具,也可以考虑自研。

下面是一个支付流程编排图:

Activiti和jBPM:配置文件非常繁琐。

liteflow:配置文件很简单,但是配置上只知道节点的流转,核心业务逻辑在代码里面,比如什么条件下推进到成功。

自研流程引擎使用这样的配置

whenOrderState(CommonOrderState.INIT) // 初始条件:主单状态INIT
.onEvent(CommonEvent.CREATE) // 触发事件:创建
.transitionOrderStateTo(CommonOrderState.PROCESS) // 推进到:支付中
.request(CommonOperation.PAY) // 操作:外发银行
   .when("subOrder.currentState == SubOrderState.S") // 银行返回成功推进主单成功
      .transitionOrderStateTo(CommonOrderState.SUCCESS)
   .when("subOrder.currentState == SubOrderState.F") // 银行返回失败推进主单失败
      .transitionOrderStateTo(CommonOrderState.FAIL)
   .when("subOrder.currentState == SubOrderState.U && subOrder.webForm != null") // 推进发消息
      .notifyNode();

通过这个配置,知道初始状态,触发条件,如何推进。

16. 支付安全

支付安全核心关注点

支付安全是一个很大的范畴,但我们一般只需要重点关注以下几个核心点就够:

  1. 敏感信息安全存储。

对个人和商户/渠道的敏感信息进行安全存储。

个人敏感信息包括身份证信息、支付卡明文数据和密码等,而商户/渠道的敏感信息则涉及商户登录/操作密码、渠道证书密钥等。

  1. 交易信息安全传输。

确保客户端与支付系统服务器之间、商户系统与支付系统之间、支付系统内部服务器与服务器之间、支付系统与银行之间的数据传输安全。这包括采用加密技术等措施来保障数据传输过程中的安全性。

  1. 交易信息的防篡改与防抵赖。

确保交易信息的完整性和真实性,防止交易信息被篡改或者被抵赖。一笔典型的交易,通常涉及到用户、商户、支付机构、银行四方,确保各方发出的信息没有被篡改也无法被抵赖。

  1. 欺诈交易防范。

识别并防止欺诈交易,包括套现、洗钱等违规操作,以及通过识别用户信息泄露和可疑交易来保护用户资产的安全。这一方面通常由支付风控系统负责。

  1. 服务可用性。

防范DDoS攻击,确保支付系统的稳定运行和服务可用性。通过部署防火墙、入侵检测系统等技术手段,及时发现并应对可能的DDoS攻击,保障支付服务的正常进行。

极简支付安全大图

支付安全是一个综合性的系统工程,除了技术手段外,还需要建立健全的安全制度和合规制度,而后两者通常被大部分人所忽略。

下图是一个极简版的支付安全大图,包含了支付安全需要考虑的核心要点。

说明:

  1. 制度是基础

哪种场景下需要加密存储,加密需要使用什么算法,密钥长度最少需要多少位,哪些场景下需要做签名验签,这些都是制度就明确了的。制度通常分为行业制度和内部安全制度。行业制度通常是国家层面制定的法律法规,比如《网络安全法》、《支付业务管理办法》等。内部安全制度通常是公司根据自身的业务和能力建立的制度,小公司可能就没有。

  1. 技术手段主要围绕四个目标:

1)敏感数据安全存储。

2)交易安全传输。

3)交易的完整性和真实性。

4)交易的合法性(无欺诈)。

对应的技术手段有:

    • 敏感信息安全存储:采用加密技术对个人和商户/渠道的敏感信息进行加密存储,限制敏感信息的访问权限,防止未授权的访问和泄露。
    • 交易信息安全传输:使用安全套接字层(SSL)或传输层安全性协议(TLS)等加密技术,确保数据在传输过程中的机密性和完整性。
    • 交易的完整性和真实性:采用数字签名技术和身份认证技术确保交易信息的完整性和真实性,对交易信息进行记录和审计,建立可追溯的交易日志,以应对可能出现的交易篡改或抵赖情况。
    • 防范欺诈交易:通过支付风控系统,及时识别和阻止可疑交易行为。
    • 服务可用性:部署流量清洗设备和入侵检测系统,及时发现并阻止恶意流量,确保支付系统的稳定运行和服务可用性,抵御DDoS攻击。

17. 资损防控

所有支付公司都对资损(资金损失)看得很重,轻则钱没了,重则舆论风波,要是引起监管介入,更是吃不了兜着走。

资损本质

资损防控本质

资损防控全生命周期


资损风险分类

资损场景(应对手段限于篇幅无法展开说明)

  1. 金额放大缩小。
  2. 幂等击穿。
  3. 流水号及短号重复。
  4. 返回码映射错误。
  5. 数据乱序,但是代码强要求有序列性。
  6. 越权/测试环境配置外部渠道生产环境。
  7. 数据库操作没有考虑并发。
  8. 状态机推进逻辑不严谨。
  9. 多线程与资源共享导致线程变量污染。
  10. 系统升级没有考虑到兼容与灰度逻辑。
  11. 运营操作失误。

18. 监控

监控一般是监控实时交易数据,包括:提交量,成功量,成功率。

对应的有:跌0,同比,环比等。

考虑到支付流量在白天晚上(忙时、闲时),有些渠道流量大,有些渠道流量小,如果想做要到噪音少(告警准确),覆盖全(没有遗漏),建议使用时间窗口算法来做

19. 核对

监控一般只能监控某个域的数据,但是互联网应用拆成了很多微服务,微服务之间容易出现数据不一致性的问题,需要尽快发现。这个时候就需要用到核对。

有些公司把核对也叫对账,都是一个意思。

实时核对与离线核对是最后的底线

一般的支付平台都会有内部系统之间的两两核对,这种核对主要是信息流层面的对账,主要勾兑状态、金额、笔数等数据的一致性。

再细分,还可以拆成实时对账和离线对账。

实时对账一般就是监听数据库的binlog,当数据有变动时,延时几秒后请求双方系统的查询接口,查到数据后进行对账。

离线对账一般就是把生产数据库的数据定时清洗到离线库(一般还可以分为天表和小时表),然后进行对账。

20. 三层对账

这里主要是指支付平台和外部渠道的对账。存在三种情况:

  1. 支付平台和外部渠道的流水数据对不上。
  2. 流水数据对上后,账单对不上。
  3. 账单对上后,实际打款对不上。

所以对应就有三层对账体系来解决这个问题。

第一层是信息流对账。我方流水和银行清算文件的流水逐一勾兑。可能会存在长短款情况。

第二层是账单对账。就是把我方流水汇总生成我方账单,然后把银行流水汇总生成银行账单,进行对账。可能会存在银行账单和我方账单不一致的情况,比如共支付100万,渠道分2次打款,一笔98万,一笔2万。

第三层是账实对账。就是我方内部记录的银行头寸和银行真实的余额是否一致。可能存在我方记录的头寸是220万,但是银行实际余额只有200万的情况。

21. 结束语

如前面所说,限于篇幅,只从专题文章中摘录了部分关键思路,算是一个引子。意犹未尽的读者可关注公众号后续推出的文章。

深耕境内/跨境支付架构设计十余年,欢迎关注并星标公众号“隐墨星辰”,和我一起深入解码支付系统的方方面面。

这是《图解支付系统设计与实现》专栏系列文章中的第(49)篇。