<返回更多

彻底搞透分布式一致性

2023-11-06  微信公众号  geekhalo
加入收藏

分布式系统下的数据一致性可以分为两大类:

  1. 事务一致性:当多个节点进行操作时,所有节点最终达成的状态都是一致的。这需要通过协调来保证操作的正确性,避免出现数据不一致的情况;
  2. 副本一致性:数据的多个副本之间保持一致性,这需要保证在对数据进行修改时,所有副本都能够及时更新,避免数据出现不同步的情况;

定义都比较抽象,举个例子感受一下:

  1. 事务一致性:电商平台使用优惠券下单场景:

彻底搞透分布式一致性图片

  1. 下单成功,优惠券必须处于“已锁定”状态;
  2. 支付成功,优惠券必须处于“已使用”状态;
  3. 订单取消,优惠券需要恢复为“待使用”状态;
  4. 优惠券和订单间就属于“事务一致”,两者间存在强关联关系。
  1. 副本一致性:
  1. 彻底搞透分布式一致性image

【注】本文着重介绍 “事务一致性”,多副本一致性,详见 缓存 或 ES 篇。

1. 脱离数据库事务的怀抱

在关系型数据库中,事务(Transaction)是指一组数据库操作,这些操作要么全部成功要么全部失败。事务可以保证某些数据操作的一致性,当某一条操作失败时,会进行回滚,即撤销已执行的操作,使数据恢复到操作前的状态。

提到事务一致性,不得不说数据库事务 ACID:ACID是指数据库事务的四个关键特性,分别为原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability):

  1. 原子性(Atomicity):事务应该被视为一个原子操作,即事务中的所有操作要么全部执行成功,要么全部失败回滚。如果事务执行过程中出现错误,所有修改操作将被回滚撤销,不会对数据造成损坏;
  2. 一致性(Consistency):事务执行前后,数据应该保持一致状态。所有数据修改操作都必须确保数据库的约束条件、触发器等规则不会被破坏,保持数据完整性;
  3. 隔离性(Isolation):多个事务同时对同一数据进行操作时,事务之间应该相互隔离,互不干扰。数据库系统应该确保在并发情况下,事务的执行结果和串行执行的结果一致;
  4. 持久性(Durability):事务完成后,其对数据库所作的所有修改都应该被永久保存,即使系统崩溃或重启后,修改的数据也应该是可用的;

银行转账应用程序就是典型的 ACID 模型的应用场景。假设用户A要向用户B转账1000元,转账过程就是一个事务,具有原子性、一致性、隔离性和持久性四大特性:

  1. 原子性:转账过程总共涉及两个操作:从A账户中减去1000元,向B账户中加上1000元。如果这两个操作中的任何一个失败,整个事务都将失败回滚;
  2. 一致性:转账前后所有账户的余额总和应该是不变的,不会出现余额不足或超额的情况;
  3. 隔离性:如果同时发起两个转账事务,应该确保每个事务只访问自己的数据,不会互相干扰;
  4. 持久性:一旦转账完成,更改数据的事务就必须写入磁盘,保证即使系统崩溃或重启后,这些数据仍然是可用的;

数据库事务绝对是程序员的一大利器,但由于各种原因,这把利器离我们越来越远:

  1. 负载的挑战:随着业务的快速增长,数据库中的数据量或负载也会达到单一实例的上线,此时,我们:

垂直拆分:将不同的表放到不同的数据库实例,比如拆分出 User 实例,Order 实例;

彻底搞透分布式一致性图片

水平拆分:数据量超过单表最大容量时,将数据分拆到不同的数据库,比如 Order-1 实例、Order-2 实例;

彻底搞透分布式一致性图片

垂直+水平拆分:先进行垂直拆分,在进行水平拆分;

彻底搞透分布式一致性图片

  1. 微服务的挑战:微服务已经成为系统的事实架构,特别是 Spring Boot 和 Spring Cloud 的流行:

微服务的“自治”要求每个微服务都应该有自己的独立数据存储,避免与其他服务共享数据存储,从而降低服务之间的耦合性;

微服务间通过服务发现、负载均衡等方式,将服务之间的关系解耦,从而使得每个服务都具备独立的自治性;

彻底搞透分布式一致性图片

不管触发哪一种条件,都会产生跨数据库事务,从而增加系统设计的难度。

2. 常见一致性保障机制

针对该问题前人已经提出来多种应对方案,特别是关系型数据库。

2.1. MySQL事务一致性

熟悉 MySQL 实现的伙伴知道,MySQL 是通过 Redo log 和 Undo log 来实现事务一致性的:

  1. Redo Log:Redo Log 记录了事务对数据库所作的修改,包括插入、更新、删除等操作,它在事务提交前就被写入磁盘。如果出现故障导致系统崩溃,MySQL 会从 Redo Log 中恢复数据;
  2. Undo Log:Undo Log 记录了事务对数据库所作的修改的「前置操作」,并且在事务回滚时用来撤销事务所做的修改。当事务执行更新时,MySQL 会先将修改前的数据存储到 Undo Log 中,当事务需要回滚时,MySQL 会根据 Undo Log 中的记录将数据还原为修改前的状态。

具体的如下图所示:

彻底搞透分布式一致性图片

从图中可知:

  1. 每一个 DML 语句都会为其生成对应的 Redo log 和 Undo log。

Redo log 记录正向修改;

Undo log 记录逆向恢复;

  1. 事务提交应用全部 Redolog 以持久化正向修改;
  2. 事务回滚应用全部 Undolog 以逆向恢复;

其中,可以看出存在两个核心流程:

  1. 向前补偿:redo log 记录了事务执行的过程,以及事务提交前的数据修改,可以通过重做日志来恢复数据,实现向前补偿;
  2. 向后补偿:undo log 记录了事务执行过程中对数据的修改,可以用于回滚事务,实现向后补偿;

除了两种补偿机制外,还涉及一个重要的组件“补偿管理器”,用于对补偿机制进行统一协调。

2.2. 2PC 和 XA

2PC(Two-Phase Commit)和XA是分布式事务中常用的协议和接口:

MySQL 采用了两阶段提交(Two-Phase Commit,简称 2PC)协议,保证 Redolog 和 Binlog 间的数据一致性,确保事务在所有相关节点(包括 Redolog 和 Binlog)执行的情况下,要么全部提交成功,要么全部回滚失败。

2PC只能应用于两个事务参与者的场景,而XA可以应用于多个事务参与者的场景,具体如图所示:

彻底搞透分布式一致性图片

XA 定义了一组接口:

对应的事务提交和回滚流程如下:

2PC (包括升级后的 3PC),在事务执行的整个流程中都需要对资源进行锁定,在分布式环境下将大幅增加系统响应时间,降低整个系统的吞吐,在实际工作中使用的非常少。

2.3. TCC

TCC 是实现分布式事务解决方案的一种有效方法,更是真正应用于实际工作的一大解决方案。

彻底搞透分布式一致性图片

TCC (try-confirm-cancel) 是一种分布式事务解决方案,它将一个分布式事务拆分成三个过程:

TCC 的操作流程如下:

TCC 是一种补偿型事务机制,通过人工干预来处理异常,本身具备极佳的灵活性,适用于各种不同类型的应用场景。

2.4. 事务一致性本质

看了不少一致性解决方案,不知道有没有发现一些规律?

核心组件基本一致:

核心流程基本一致:

简单来说:事务一致性就是通过协调各个参与节点来实现分布式事务的提交或回滚,确保所有涉及到的操作,要么全部执行成功,要么全部不执行。不同的实现方式只是不同的工具,其实现思路基本一致。

3. 业务一致性保障机制

前人已经为我们提供足够多的工具,如何更好的使用这些工具,就需要对业务场景进行深入分析。

业务系统一致性是指在多个系统或不同的环境中,不同用户或系统操作所产生的数据在逻辑上是相同的。它的本质是确保在任何情况下,不同系统或用户产生的数据都是一致的,并且在系统中的所有操作都是以预期方式进行的。业务系统一致性是确保数据的准确性和可靠性的关键因素,可以有效地避免数据错误和丢失,提高业务系统的可用性和可靠性,保障企业的持续发展。\如下图所示:

彻底搞透分布式一致性图片

如果可重试性事务间不存在依赖关系,可以并行执行,具体如下:

彻底搞透分布式一致性图片

在一个复杂的业务流程中,可以将事务分为三类:

我们以分布式系统中的下单流程为例:

彻底搞透分布式一致性图片

3.1. 关键性事务

关键性事务:指在分布式系统中,只有当某个事务被成功提交后,整个系统才能认为这个事务是成功的。如果这个事务失败了,那么整个系统就会回滚到之前的状态。例如支付、订单提交等。

从关键性事务的使用场景出发,最适合的工具便是关系数据库的事务保障。

彻底搞透分布式一致性图片

3.2. 可补偿事务

可补偿事务指在某些业务操作中,如果其中一些子操作执行失败,可以由后续补偿操作进行补救,达到一定的业务目的,例如在资金交易中,如果账户余额不足而支付子操作失败,可以通过撤销订单等补偿操作来保障交易的正确性。

对于可补偿事务,需要提供两组操作:

  1. 正向:标准的业务操作,比如库存锁定
  2. 逆向:针对正向操作的恢复操作,比如释放锁定库存
3.2.1. Seata

Seata 是一个开源的分布式事务解决方案,旨在解决分布式系统中的事务一致性问题。在传统的分布式系统中,由于各个服务之间的数据交互和操作都是独立进行的,因此很容易出现数据不一致的情况。这会导致系统出现各种异常情况,如数据丢失、重复提交等,从而影响系统的稳定性和可靠性。

Seata 提供了多种解决方案来解决分布式事务一致性问题。其中包括 XA 模式、TCC 模式和 SAGA 模式等。

Seata 还提供了一些重要的功能,如事务日志记录、故障恢复、动态扩展等,使得用户可以更加方便地使用该框架来解决分布式事务一致性问题。同时,Seata 还具有高性能、高可用性和易用性等特点,可以满足各种不同场景下的需求。

【注】感兴趣的话,可以找下 seata 的官方文档。

3.2.2. Context + Rollback

Seata 虽好,但中间件的引入将大幅提升系统的复杂性,对于一些不太严谨的场景或者一些运维能力不足的小团队可以自己实现回滚方案。

整体方案如下:

彻底搞透分布式一致性图片

关键事务提交成功,Context 注册的 RollbackEntry 便失去意义;

关键事务提交失败,调用 Context 的 fireFallback 方法进行逆向补偿,fireFallback 方法逆向调用注册的回滚方法,从而恢复业务状态

该方案基于内存实现,存在失灵的情况,不建议使用在严谨的场景。

3.3. 可重试性事务

可重试型事务指在业务操作中,如果某些操作由于网络波动等原因导致失败,可以通过重新执行这些操作来达到其预期的结果,例如在发送短信验证码时,由于网络状况不佳而发送失败,可以重新尝试发送,直到发送成功为止。

可重试性事务没有失败,只有成功,哪怕是短暂的失败也会通过不限的重试使其最终达到成功状态。

3.3.1. @Retry

@Retry 是 Spring 框架提供的一个注解,用于在方法调用失败时自动进行重试。

通过 @Retry  注解,我们可以定义重试的次数、间隔时间和异常类型等信息,从而实现更可靠的方法调用。

具体来说,@Retry 注解可以通过以下属性来配置:

我们看下具体的使用:

@Retryable(value = {Exception.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void doSomething() throws Exception {
    // 业务逻辑代码
}

该实现会在方法调用失败时进行最多3次的重试,每次重试之间会等待1秒的时间。如果超过3次重试仍然失败,则抛出异常。

@Retryable(value = {Exception.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000), fallback = @Fallback(fallbackMethod = "doDefault"))
public void doSomething() throws Exception {
    // 业务逻辑代码
}

private String doDefault(Exception e) {
    // 当出现指定异常时,执行该方法进行重试处理
}

该实现会在方法调用失败时进行最多3次的重试,每次重试之间会等待1秒的时间。如果超过3次重试仍然失败,则会执行 doDefault 方法来进行重试处理。在该方法中,我们可以自定义处理方式来处理异常情况。

@Retry 仍旧是一个内存解决方案,在极端场景下可能出现任务丢失的情况。因此在实际工作中,很少用于可重试性事务这种场景。

3.3.2. MQ

MQ(消息队列)消费者重试机制是指在消费消息时,如果消费者无法成功消费消息(比如网络异常、服务器故障等原因),会自动重试一定次数或间隔一定时间后再次尝试消费消息,以保证消息的可靠性和可用性。

如下图所示:

彻底搞透分布式一致性im

具有MQ的可重试性事务,需要以下保障:

一般情况下会采用多次投递的方式来实现消息投递和消息消费之间的一致性,所以消息消费者需要保障幂等性,避免多次投递造成的业务问题。

3.3.2.1. 半消息

RocketMQ事务消息是一种支持分布式事务的消息模型,将消息生产和消费与业务逻辑绑定在一起,确保消息发送和事务执行的原子性,保证消息的可靠性。

事务消息分为两个阶段:发送消息和确认消息,确认消息分为提交和回滚两个操作。在提交操作执行完毕后,消息才会被消费端消费,而在回滚操作执行完毕后,消息会被删除,从而达到了事务的一致性和可靠性。

事务消息的发生流程如下:

彻底搞透分布式一致性图片

如果生成者发送 prepare 消息后,未在规定时间内发送 commit 或 rollback 消息,RocketMQ 将进入恢复流程,具体如下:

彻底搞透分布式一致性图片

使用 RocketMQ 的事务消息代码示例如下:

// 编写事务监听器类
public class TransactionListenerImpl implements TransactionListener {
    private AtomicInteger transactionIndex = new AtomicInteger(0);

    // 执行本地事务
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        int value = transactionIndex.getAndIncrement();
        System.out.println("executeLocalTransaction " + value);
        // TODO 执行本地事务,并返回事务状态
        // 本例假定 index 为偶数的消息执行成功,奇数的消息执行失败
        if (value % 2 == 0) {
            return LocalTransactionState.COMMIT_MESSAGE;
        }
        return LocalTransactionState.ROLLBACK_MESSAGE;
    }

    // 检查本地事务状态
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        System.out.println("checkLocalTransaction " + msg.getTransactionId());
        // 模拟检查本地事务状态,返回事务状态
        boolean committed = prepare(true);
        if (committed) {
            return LocalTransactionState.COMMIT_MESSAGE;
        }
        return LocalTransactionState.UNKNOW;
    }

    // 模拟操作预处理逻辑
    private boolean prepare(boolean commit) {
        System.out.println("prepare " + (commit ? "commit" : "rollback"));
        return commit;
    }

}

// 编写发送消息的代码
public class Producer {
    private static final String NAME_SERVER_ADDR = "localhost:9876";

    public static void main(String[] args) throws Exception {
        TransactionMQProducer producer = new TransactionMQProducer("MyGroup");
        producer.setNamesrvAddr(NAME_SERVER_ADDR);
        // 注册事务监听器
        producer.setTransactionListener(new TransactionListenerImpl());
        producer.start();

        // 发送事务消息
        String[] tags = {"TagA", "TagB", "TagC"};
        for (int i = 0; i < 3; i++) {
            Message msg = new Message("TopicTest", tags[i], ("Hello RocketMQ " + i).getBytes(StandardCharsets.UTF_8));
            // 在消息发送时传递给事务监听器的参数
            SendResult sendResult = producer.sendMessageInTransaction(msg, null);
            System.out.printf("%s%n", sendResult);
        }

        // 关闭生产者
        producer.shutdown();
    }
}

单看代码很难理解,简单画了张图,具体如下:

彻底搞透分布式一致性图片

其核心部分就是 TransactionListener 实现,其他部分与正常的消息发送基本一致,TransactionListener 主要完成:

为了使用事务消息,我们不得不在TransactionListener中编写进行大量的适配逻辑,增加研发成本,同时由于逻辑被拆分到多处,也增加了代码的理解成本。

事务消息存在一定的问题:

有没有实用性强、使用简单的方案,那可以使用 事务消息表 方案。

3.3.2.2. 事务消息表

事务消息表方案是一种常用的保证消息发送与业务操作一致性的方法。该方案基于数据库事务和消息队列,将消息发送和业务操作放入同一个事务中,并将业务操作和消息发送的状态记录在数据库的消息表中,以实现消息的可靠性和幂等性。

如下图所示:

彻底搞透分布式一致性图片

核心流程如下:

通过事务消息表方案,可以保证消息的可靠性和幂等性。即使在消息发送失败或应用程序崩溃的情况下,也可以通过重新发送消息将业务操作和消息发送的状态同步。同时,该方案可以避免消息重复发送和漏发的情况。

作为一种通用解决方案,lego 对其进行支持,可参考 reliable-message 模块。

4. 业务补偿

不管在设计时使用哪种方案,都是在尽力降低不一致出现的概率,但可怕的是不一致问题终究会发生。

是不是有些奇怪,做了这么多还是无法从根源上彻底解决一致性问题,在实际工作中就是这样:

除了主动降低不一致性概率,还需要添加一些被动保护机制,也就是常说的业务补偿。

4.1. 查询模式

查询模型是最常用的一种方式,主要用于应对网络传输中的第三态问题。

第三态指的是在分布式系统中,在进行跨网络调用时,调用方无法确定被调用方的状态是否改变了,因为这两者之间存在一段未知而不可控的网络延迟时间,导致调用方无法立即得到被调用方的结果。这种情况下,第三态可以看做是一个未知的状态,需要通过一些机制来解决这个问题。

彻底搞透分布式一致性图片

当网络调用出现第三态时,最简单的方式便是对不确定的状态进行查询,如上图所示:

已完成,则继续执行后续流程;

未完成,在重新发起业务调用;

RocketMQ 的事务消息便是基于该机制进行实现。

4.2. 任务检测模式

当一个业务操作完成后,需要处理多个后续任务,为了保障所有任务都会被执行,可以使用该模式。

如下图所示:

彻底搞透分布式一致性图片

image

已经执行,则更新任务状态

如果未执行,则触发任务执行

本地消息表就是基于该模式进行构建。

4.3. 对账模式

对账模式经常出现在与银行等金融机构对接的场景。

彻底搞透分布式一致性图片

业务对账思路非常简单:

一致,则说明系统一致

不一致,进行报警,人工介入进行处理

必须是双向对账,单向对账会出现数据丢失情况。

5. 小结

一致性是分布式系统面临的巨大挑战,根据不同场景可以将一致性分为:

本文重点对事务一致性进行全方位的阐述,包括:

MySQL 实现

2PC和XA协议

TCC 解决方案

有了这些方案后,很多场景下仍需落地业务补充,常见方案包括:

关键词:分布式      点击(10)
声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多分布式相关>>>