金融级业务下分布式事务保证数据一致性

前言

随着分布式服务架构的流行与普及,原来在单体应用中执行的多个逻辑操作,现在被拆分成了多个服务之间的远程调用。微服务化后,随着带来的服务之间的分布式事务问题,尤其是在金融业务下,分布式事务是保证数据一致性的重要保证。本文着重会讲分布式事务场景和业界主流的解决方案。

一、引入

 资金转账在金融业务下是一个非常重要而且常见的场景,如果因为技术问题导致资金转账错误,导致数据不一致问题,那么就会造成无法预测的后果。
 笔者这里拿银行转账的例子来说(这里的转账有很多场景比如银行卡之间充值提现、银行账户之间的转账等等),比如甲银行账户A向乙银行账户B转账1W:

同步调用:

  1. A银行对转出账户执行检查校验,进行账户金额扣减。
  2. A银行同步调用B银行转账接口。
  3. B银行对转入账户进行检查校验,进行账户金额增加。
  4. B银行返回处理结果给A银行。

    同步调用问题:
  • 如果B银行因为网络原因导致接口不通,那么A调用线程会长时间阻塞。
  • 如果A扣减后,发送请求后,在网络中丢失了,B银行没有收到请求,导致账户A扣减了,账户B没有加
  • 如果账户B扣减成功了,由于某种原因比如网络异常没有及时回调给甲银行,那么账户A就认为是异常请求,则会回滚事务,导致数据不一致。

再来看一下异步调用:

  1. A银行对转出账户执行检查校验,进行账户金额扣减。
  2. 主线程将请求数据异步写入队列MQ
  3. 真正消费者程序对B银行进行远程调用
  4. B银行对转入账户进行检查校验,进行账户金额增加。
  5. B银行返回处理结果给A银行。

    异步调用问题:
  • 如果账户A扣减本地事务成功了,但是消息发出后,因为网络原因或者其他宕机原因,导致消息未发送成功,没有进行B账户远程调用,导致本地事务和消息不一致性。
  • MQ消费端程序如果消费消息成功,请求银行成功了,但是回传ACK给MQ失败了,那么回导致消费端程序重复消费问题,那么就会出现重复转账的问题。

异步调用解决了同步调用的主线程阻塞问题,但还是没有解决数据一致性问题。而且引入MQ中间后,还要考虑到本地事务和MQ消息一致性问题,还有其他的引入后的维护工作,比如消息丢失,消息重发等等问题。

二、分布式事务解决方案

 讲到了分布式事务,自然离不开分布式系统的一些基本原则和定理:CAP原则和BASE理论,相信读者应该都知道,这里不做过多阐述。业界根据这些规则和理论,衍生出了各种分布式事务解决方案:XA规范,2PC,3PC,本地消息表方案,基于消息中间件的最终一致性方案,TCC方案,阿里的SEATA,SAGA方案和最大努力通知等等。
 以上每个方案都有自己的应用场景,就拿2PC来说,MySQL的事务型日志redolog二段提交(redolog(prepare)--》binlog--》redolog(commit))保证binlog和redolog数据一致性,Zookeeper的proposal事务二段提交(半数以上ack返回成功表示写入数据成功)保证leader和foller的数据一致性,这些都是2PC的应用。
 金融场景下类似资金业务需要保证最终一致性解决分布式事务,不需要保证转账实时性。所以本地消息表、基于MQ中间件的最终一致性等柔性方案是首选的方案。这些基于消息的分布式事务,本质上就是,本地事务+从事务,从事务从消息中获取信息进行本地提交,这里保持异步事务机制、只能保证最终一致性

2.1 利用本地消息表思想解决一致性问题

 一般来说,跨行转账的原理,会存在一个中国人民银行的中间人角色来操作转账,但不在本次讨论的范围内。
 业界银行转账大部分都是同步转账,异步获取转账结果,包括第三方支付平台对接银行都是这样玩的。这里笔者就利用本地消息表思想来具体叙述数据一致性是如何保证的,老规矩先放图:

其中交易记录表大概长这个样子:

字段 描述
id 自增ID,没有业务意义
trade_order_num 交易订单号,作为转账记录唯一标识
source_account_num 交易转出方账户ID
target_account_num 交易收款方账户ID
status 状态机,0=预创建,1=转账中,2=转账成功,3=转账失败
pay_success_time 记录转账成功时间
create_time 记录创建时间,可作为窗口时间内判断标准
update_time 记录 更新时间

账户表大概长这个样子:

字段 描述
id 自增ID,没有业务意义
account_num 账户ID
current_amt 当前账户余额
lock_amt 冻结金额,用来记录临时状态的核心转账数据 。真实余额=current_amt-lock_amt

图中的步骤大致分为8步,这里细致讲一下每一步的详细步骤,分别是:

  1. 插入初始状态的交易数据。 这一步骤的目的是保证发起同步转账请求和本地初始事务一致性,还有一个目的就是生成转账记录唯一标识,用来标识本次转账
  2. 同步发起转账请求,带上唯一标识以及其他的业务参数
  3. 银行乙会校验参数信息,并且同步返回转账通知,类似“我接收到了你的请求了,我还有其他事情,我等会返回结果给你”
  4. 会根据同步的转账通知来判断这次交易是否合法,然后会记录结果到交易表中。这里并且需要冻结账户的一部分金额,作为临时中间态数据。并且需要更新本地交易表状态为转账中
  5. 银行乙需要记录本次交易记录,插入一条交易中的数据,直至回调转账结果给银行甲,将这条记录置为转账成功。并且银行乙自己生成的收款交易流号,然后放入到回调结果中,传给银行甲
  6. 异步回调结果给银行甲,其中回调参数重要的有银行甲的唯一的转账标识,还有银行乙自己生成的收款交易流号
  7. 根据异步回调的状态,更新交易状态数据,如果成功,那么会扣减账余额,并且释放冻结金额,如果失败,直接释放冻结金额。此时算是正常的一条流程闭环走完
  8. 当然不是所有的业务能够正常走完流程闭环,也会出现各种原因导致不能走完。为了保证数据一致性,会增加一个补偿程序,定时去拉取异常数据,异常数据指的是交易状态为0和1并且不在正常窗口业务时间内的数据(0和1属于中间态,而2或者3数据终态),窗口时间指的是正常业务从开始到结束的时间。
  • 如果异常数据状态是0,那么表示有可能是本地更新事务失败了,也有可能是请求或者返回在网络中丢失了,补偿程序里面会根据本地的表数据判断是哪个步骤除问题了,就比如说本地数据没有银行乙的交易流水号,那么就是网络出问题了,后面就可以进行补偿操作
  • 如果异常数据状态是1,那么表示可能是银行乙接口有问题或者网络有问题原因导致没有及时回调,这个时候补偿程序就用银行乙的交易流水号是去查询交易是否完成,然后更新自己本地的数据。

以上流程是一次正常的交易过程,当然不是所有的交易流程都是这样走的,不过大部分转账流程和上述步骤相类似,其中的细致步骤在每个交易系统中略有不同。

总结起来,利用本地消息表思想能够解决上面第一部分文章的同步调用的缺点,能够解决第二个第三 个问题,但是第一个问题就无法解决了,只要是同步调用都会出现这个问题,不过有其他方式去解决这个问题。以笔者认知来说,银行转账的业务都是同步调用的。出现接口阻塞这个问题,需要设置超时时间,如果超过超时时间,就记录下这条交易,异步放入重试队列,一段时间后进行重试

2.2 事务消息解决本地事务和MQ消息一致性问题

 转账业务,如果用异步的话,当出现MQ问题或者消费也者程序出现消息挤压或者消费者端出现问题 的话,那么整个业务时间线会拉的非常长。所以笔者认为异步不适合这种业务,异步本质上是对下游服务的一个缓冲,适合在自己系统中使用,不适合跨系统或者三方调用。当然不是所有的场景都不合适,如果流量非常大话,对方系统有限流机制,使用MQ也算是一种解决方案,这还是看具体业务。

 什么样的场景适合使用MQ?一般来说在需要限流削峰、异步解耦等场景使用,所以还是拿上面的图,图中标框的部分业务适合用MQ来解决

 如果用MQ来做的话,那么会有如下图步骤:

图中的步骤大致分为6步,这里细致讲一下每一步的详细步骤,分别是:

  1. 消息生成者发送消息,broker接受消息
  2. MQ broker收到消息,随即将消息进行持久化,并且存入库。这一步是防止MQ因为物理原因宕机导致的消息丢失,并且入库的时候要判断幂等,防止没有及时返回ack,导致生产者重发消息。
  3. 返回ACK给生产者。如果不及时返回或者长时间没有返回,生产者会认为这条消息发送失败,会重新发送。
  4. MQ push消息给对应的消费者或者消费者主动来pull消息,然后等待消费者返回ACK
  5. 如果消息消费者在指定时间内成功返回ack,那么MQ认为消息消费成功,在存储中删除消息,即执行第6步;如果MQ在指定时间内没有收到ACK,则认为消息消费失败,会尝试重新push或者pull消息,重复执行4、5、6步骤
  6. MQ删除消息

 以上为一条正常的消息从生产到消费的过程,每一步都是不可或缺的。而且我们可以看到,当引入中间件MQ后,消费端业务需要保持幂等。

 回到上面文章最开始的部分,并没有解决异步调用问题一。没有彻底解决本地事务和消息不一致性。所以这个时候,就需要事务消息解决本地事务和MQ消息一致性问题了,笔者重新画了一张图来说明一下事务消息是如何做的:

图中的步骤大致分为几步,分别是:

  1. 生产者发送一条prepare消息
  2. MQ接受到消息后,先进行持久化,状态为待确认的消息
  3. 返回ACK给消息生产者
  4. 执行本地事务:扣减账户余额,插入交易流水。如果这个事务执行失败,那么相当于业务执行失败,抛给用户交易失败
  5. 执行完成后,将结果发送执行结果给MQ
  6. 根据结果将消息commit或者rollback commit:将消息状态置为已确认 rollback:将消息删除
  7. 采用pull或者push消费已确认的消息 ,后面流程大致和普通的流程都一样

 这里还没有体现另外一个流程,就是如果消息待确认状态在一定时间内没有转换为已确认,那么MQ会回查本地事务执行状态是否成功。这个是为了保证在第五步发送的消息在网络中丢失或者消费者宕机等情况下,能够回滚。
 以上是事务消息大致的流程,能够解决本地事务和MQ消息一致性问题,这里强调的是,不管是是事务消息还是普通的消息,消费端都需要做幂等处理。
 总结来说,ack+补偿+重试+幂等是保证一致性的关键。

2.2.1 事务消息常见问题

  1. 如果consumer消费失败,是否需要producer做回滚呢?
    不需要,MQ作用要保证的就是最终一致性,如果consumer消费失败,就让它进行重试直至成功。如果重试超过一定次数的话,那么就人工介入。

三、 其他方式保证数据一致性

 当然,保持数据一致性不光是分布式事务来保证,业务上还要配合其他的辅助来保证,这里笔者就列举几种

  1. 全链路幂等
    全链路幂等保证不产生脏数据,保护核心流程正常执行。
  2. 重试机制
    对异常业务进行重试,超过指定重试次数仍失败的进行人工介入。
  3. 业务对账
    业务内部准实时对账,比如业务发生后充值提现,对比用户余额是否正确,用户业务流水是否正确。
    T+1日对账,程序或者人工定时扫描核心业务数据,保证当日数据准确。对账后自动检测并且修复重试业务
  4. 业务指标监控
    监控数据库中的订单预占资金没有释放,状态机是不是最终态监控,单位窗口时间内业务状态是否异常,账户中的预扣减金额是否释放,业务重试次数是否超过阈值等等业务监控。

四、总结

 分布式场景,要用分布式的思维去思考问题。要考虑任何的超时,断电,维护不同物理存储的数据的可能存在的状态不一致的场景,说白了要面向失败编程。

五、参考

  • 有赞出金系统——https://tech.youzan.com/build-a-withdraw-sys/
  • 分布式事务的思考——https://www.cnblogs.com/sujing/p/11006424.html
  • 阿里云RocketMQ文档——https://help.aliyun.com/document_detail/43348.html