分布式事务一致性解决方案

3,191 阅读11分钟

一、从数据一致性谈起

一致性问题,“万恶之源”是数据冗余和分布并通过网络交互+网络异常是常态。

1、数据一致性的情形

  • 主库、从库和缓存数据一致性,相同数据冗余,关系数据库,为保证关据库的高可用和高性能,一般会采用主从(备)架构并引入缓存。其中数据不一致性存在于数据冗余的时间窗口内。
  • 多副本数据之间的数据一致性,相同数据副本,大数据领域,一份数据会有多个副本并存储到不同的节点上。客户端可以访问任何一个节点进行读写操作。常用的解决方案是基于Paxos、ZAB、Raft、Quorum、Gossip等的开源实现。这里只是一提,暂不探讨。感兴趣可以自行谷歌或百度。
  • 分布式服务之间的数据一致性,相关数据分布,分布式服务,不同的服务操作不同的库(表),而且库(表)间要保持一致。

2、数据一致性的概念

  • 强一致性
  • 弱一致性
  • 最终一致性

3、数据一致性的原理

  • ACID
  • CAP
  • BASE

4、数据一致性的协议

  • 两阶段提交协议
  • 三阶段提交协议
  • TCC协议
  • Paxos协议
  • ZAB协议
  • Raft协议
  • Quorum协议
  • Gossip协议

二、分布式服务间的数据一致性

所谓分布式服务,就是把之前通过本地接口交互的模块,拆分成单独的应用独立部署,并通过远程接口和网络消息交互。且不管这样说严不严密,正不正确,理解就好。本文的重点也不是这个话题。简单画一张图辅助理解,如图。集中式架构,要想保证订单表和库存表的一致性,只要一个本地事务(ACID)就能保证两者的强一致性。分布式架构,订单表由订单服务操作,库存表由库存服务操作。要想保证订单表和库存表的一致性,那么就必须保证订单服务对订单表的操作和库存服务对库存表的操作同事成功。之前的一个本地事务就变成了一个分布式事务。由于服务之间通过网络交互+网络异常是常态,就会产生服务间数据不一致的情况。这就涉及一个分布式事务一致性的问题。

三、分布式事务一致性解决方案

1、接口同步调用模式与一致性解决方案

模式分析:A服务同步调用B服务的接口并等待结果返回,后续的流程会依赖B服务的返回结果。这种交互模式下,A服务得到的结果细分有三种情况。

  1. 请求发起阶段网络超时或异常,此时,B服务未收到请求,未作出相应的处理;
  2. 结果返回阶段网络超时或异常,此时,B服务已收到请求,并作出相应的处理;
  3. 正常结果返回(明确的成功或失败)。

业务场景:适用于大规模、高并发的短小操作且依赖返回值的场景。例如,交易服务和库存服务(卡券服务、红包服务等)的交互、用户登录和准入服务的交互等。

解决方案:方案一,服务调用方查询重试方案;方案二,TCC方案。

:这两种方案,保证数据一致性实际上还是靠“异步”,只不过需要快速校准,准实时。

服务调用方查询重试方案,适合一个从业务服务场景。

下单减库存方法() {
    // 1.准备操作
    // 2.重试调用B服务
    result = RetryUtil {
        while(重试次数 < 最大重试次数) {
            try {
                if (重试次数 != 0) {
                    // case1:网络超时或异常(catch分支)
                    // case2:查询到扣减库存操作,result=成功(return)
                    // case3:查不到扣减库存操作,result=失败(继续下面操作)
                    result = rpc.查询扣减库存是否成功();
                    if (result == 成功) {
                        return result;
                    }
                }
                // case1:网络超时或异常(catch分支)
                // case2:扣减库存成功,result=成功(return)
                // case3:扣减库存失败,result=失败(return)
                return rpc.扣减库存();
            } catch (Exception e) {
                if (重试次数 = 最大重试次数) {
                    // 报警,人工处理或者(近实时)对账系统自动校准
                    // 抛出异常,中断后续流程
                    throw 自定义异常; //或者result封装异常
                }
            }
        }
    };
    // 3.后续操作
}

:1) 查询重试后依然失败(极少),报警,人工处理或者准实时对账系统自动校准;

  2) 重试次数不宜多,甚至只重试一次;

  3) B服务处理请求要做幂等。

  1. TCC方案,适合多个从业务服务场景。TCC是阿里在二阶段提交协议的基础上提出的一种解决分布式事务一致性的协议,原理图如下。其对应的产品是DTX(老版是DTS)。DTS中有个快速开始的例子看明白了,TCC就基本OK了。在蚂蚁金服内部被广泛地应用于交易、转账、红包等核心资金链路,服务于亿级用户的资金操作。
    :1) 关于TCC,个人认为,理解原理很重要。工作中遇到吻合的场景可以根据原理自行实现,满足业务即可;
      2) 一个开源实现:tcc-transaction

2、接口异步调用模式与一致性解决方案

模式分析:A服务调用B服务,B服务先受理请求并落库,状态是待处理。B服务处理请求很耗时,或者要依赖其他的服务。B服务处理完后通知A服务或者A服务定时去查询B服务的处理结果。这种交互模式下,对于CASE-1,第1步和第2步同接口同步调用模式,第3步同消息异步处理模式;对于CASE-2,相当于两次接口同步调用模式

业务场景:适用于非核心链路上负载较高的处理环节,这个环节经常耗时较长,并且对时效性要求不高。例如,用户提现时,账户系统和提现系统的交互(CASE-1);提现系统和三方系统(银行系统或者三方托管系统)的交互(CASE-2)。

解决方案服务被调方最大努力处理方案。由于B服务中请求有落库,所以可以用定时任务不断重试尽最大努力将请求处理出结果。处理后,将请求状态设置成对应的结果落库。然后再通知A服务或者A服务异步主动查询。

受理请求方法() {
    // 1.请求落库,状态为待处理
    // 2.返回受理结果
    if (落库成功) {
        // 返回受理成功
    } else {
        // 返回受理失败
    }
}

定时任务处理请求方法() {
    // 1.扫描待处理请求
    try {
        // 2.处理请求
        if (处理成功) {
            // 设置请求处理状态为处理成功
        } eles {
            //  设置请求处理状态为处理失败
        }
    } catch (Exception e) {
        // 不做任何操作,请求状态依旧为待处理
    }
    // 3.消息通知A服务处理结果或者等待A服务查询处理结果
}

:1) B服务通常都是接受请求并持久化后才返回A服务受理成功。避免服务进程被杀掉而导致请求丢失。

  2) 不管是第(1,2)两步还是CASE-2中的第(3,4)两步,如果查询重试失败,可以落库,用定时任务处理,知道成功。反正不像接口同步调用模式,A服务不需要实时的结果。

3、消息异步处理模式与一致性解决方案

模式分析:A服务将B服务需要的信息通过消息中间件传递给B服务,A服务无需知道B服务的处理结果。这种交互模式下,消息生产者要确保消息发送成功;消息消费者要确保消息消费成功。

业务场景:消息异步处理模式与接口异步调用模式类似,多应用于非核心链路上负载较高的处理环节中,井且服务的上游不关心下游的处理结果,下游也不需要向上游返回处理结果。例如,在电商系统中,用户下订单支付且交易成功后,发送消息给物流系统或者账务系统进行后续的处理。

解决方案生产者最大努力通知+消费者最大努力处理方案。

  1. 非事务消息,生产者先执行本地事务并将消息落库,状态标记为待发送,然后发送消息。如果发送成功,则将消息改为发送成功。定时任务定时从数据库捞取在一定时间内待发送的消息并将消息发送。通过定时任务来保证消息的发送。为确保消息一定能消费,消费者一般采用手动ACK机制,那么消息服务器必然会重发未ACK的消息,这就要求消息消费者做好幂等。

    交易完成发消息方法() {
        // 1.设置交易状态为已完成
        // 2.消息落库,状态为待发送
        // 可异步发送,也建议异步发送
        try {
            // 3.发送消息
            if (发送成功) {
                // 设置消息状态为发送成功
            }
        } catch (Exception e) {
            // 不做任何操作,消息状态依旧为待发送
        }
    }
    
    定时任务发送消息方法() {
        // 1.扫描待发送消息
        try {
            // 2.发送消息
            if (发送成功) {
                // 设置消息状态为发送成功
            }
        } catch (Exception e) {
            // 不做任何操作,消息状态依旧为待发送
        }
    }

  2. 事务消息,以RocketMQ为例,下图是RocketMQ事务消息的流程。官网有示例代码。和不支持事务的消息中间相比,只是消息发送的时候,保证了和本地事务的一致。消费者实现还是不变。

:1) 定时任务重试发送消息和消息服务器重发未ACK的消息一般都是时间阶梯式的(2n*时间间隔);

  2) 支持事务消息中间件之RocketMQ

四、保证操作幂等性的常用方法

  1. 有业务状态,业务逻辑来保证幂等。比如接到支付成功的消息订单状态变成支付完成,如果当前状态是支付完成,则再收到一个支付成功或者支付成功之前状态的消息则说明消息重复了,不用再次处理。
  2. 无业务状态,业务唯一ID保证幂等。增加一个去重表(或分布式缓存)来记录有业务唯一ID的操作。比如调用充值接口,当请求过来时,会根据唯一充值ID去查充值流水表,若已经存在,则直接返回;否则继续进行充值操作。

:保证幂等性的方法很多,根据具体的业务场景,总能找到保证幂等性的方法。

五、总结

  1. 接口同步调用模式,服务调用方查询重试方案和TCC方案。
  2. 接口异步调用模式,服务被调方最大努力处理方案。
  3. 消息异步处理模式,生产者最大努力通知+消费者最大努力处理方案。
  4. 任何服务操作都需要提供一个查询接口,用来向外部输出操作执行的状态。
  5. 永远不要在本地事务中调用远程服务,在这种场景下如果远程服务出现了问题,则会拖长事务,导致应用服务器占用太多的数据库连接,让服务器负载迅速攀升,在严重情况下会压垮数据库。
  6. 最后一道防线 - 对账系统。
  7. 同步和异步的抉择:
  • 可以异步的地方,就应该异步实现。如果业务逻辑允许,则我们可以将一些耗时较长的、用户对响应没有特别要求的操作异步化,以此来减少核心链路的层级,释放系统的压力。
  • 能用同步解决的问题,不要引入异步。如果性能不是问题,或者所处理的操作是短小的轻量级处理逻辑,那么同步调用方式是最理想不过的,因为这样不需要引入异步化的复杂处理流程。