分布式系统下的数据一致性可以分为两大类:
定义都比较抽象,举个例子感受一下:
图片
【注】本文着重介绍 “事务一致性”,多副本一致性,详见 缓存 或 ES 篇。
在关系型数据库中,事务(Transaction)是指一组数据库操作,这些操作要么全部成功要么全部失败。事务可以保证某些数据操作的一致性,当某一条操作失败时,会进行回滚,即撤销已执行的操作,使数据恢复到操作前的状态。
提到事务一致性,不得不说数据库事务 ACID:ACID是指数据库事务的四个关键特性,分别为原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability):
银行转账应用程序就是典型的 ACID 模型的应用场景。假设用户A要向用户B转账1000元,转账过程就是一个事务,具有原子性、一致性、隔离性和持久性四大特性:
数据库事务绝对是程序员的一大利器,但由于各种原因,这把利器离我们越来越远:
垂直拆分:将不同的表放到不同的数据库实例,比如拆分出 User 实例,Order 实例;
图片
水平拆分:数据量超过单表最大容量时,将数据分拆到不同的数据库,比如 Order-1 实例、Order-2 实例;
图片
垂直+水平拆分:先进行垂直拆分,在进行水平拆分;
图片
微服务的“自治”要求每个微服务都应该有自己的独立数据存储,避免与其他服务共享数据存储,从而降低服务之间的耦合性;
微服务间通过服务发现、负载均衡等方式,将服务之间的关系解耦,从而使得每个服务都具备独立的自治性;
图片
不管触发哪一种条件,都会产生跨数据库事务,从而增加系统设计的难度。
针对该问题前人已经提出来多种应对方案,特别是关系型数据库。
熟悉 MySQL 实现的伙伴知道,MySQL 是通过 Redo log 和 Undo log 来实现事务一致性的:
具体的如下图所示:
图片
从图中可知:
Redo log 记录正向修改;
Undo log 记录逆向恢复;
其中,可以看出存在两个核心流程:
除了两种补偿机制外,还涉及一个重要的组件“补偿管理器”,用于对补偿机制进行统一协调。
2PC(Two-Phase Commit)和XA是分布式事务中常用的协议和接口:
MySQL 采用了两阶段提交(Two-Phase Commit,简称 2PC)协议,保证 Redolog 和 Binlog 间的数据一致性,确保事务在所有相关节点(包括 Redolog 和 Binlog)执行的情况下,要么全部提交成功,要么全部回滚失败。
2PC只能应用于两个事务参与者的场景,而XA可以应用于多个事务参与者的场景,具体如图所示:
图片
XA 定义了一组接口:
对应的事务提交和回滚流程如下:
2PC (包括升级后的 3PC),在事务执行的整个流程中都需要对资源进行锁定,在分布式环境下将大幅增加系统响应时间,降低整个系统的吞吐,在实际工作中使用的非常少。
TCC 是实现分布式事务解决方案的一种有效方法,更是真正应用于实际工作的一大解决方案。
图片
TCC (try-confirm-cancel) 是一种分布式事务解决方案,它将一个分布式事务拆分成三个过程:
TCC 的操作流程如下:
TCC 是一种补偿型事务机制,通过人工干预来处理异常,本身具备极佳的灵活性,适用于各种不同类型的应用场景。
看了不少一致性解决方案,不知道有没有发现一些规律?
核心组件基本一致:
核心流程基本一致:
简单来说:事务一致性就是通过协调各个参与节点来实现分布式事务的提交或回滚,确保所有涉及到的操作,要么全部执行成功,要么全部不执行。不同的实现方式只是不同的工具,其实现思路基本一致。
前人已经为我们提供足够多的工具,如何更好的使用这些工具,就需要对业务场景进行深入分析。
业务系统一致性是指在多个系统或不同的环境中,不同用户或系统操作所产生的数据在逻辑上是相同的。它的本质是确保在任何情况下,不同系统或用户产生的数据都是一致的,并且在系统中的所有操作都是以预期方式进行的。业务系统一致性是确保数据的准确性和可靠性的关键因素,可以有效地避免数据错误和丢失,提高业务系统的可用性和可靠性,保障企业的持续发展。\如下图所示:
图片
如果可重试性事务间不存在依赖关系,可以并行执行,具体如下:
图片
在一个复杂的业务流程中,可以将事务分为三类:
我们以分布式系统中的下单流程为例:
图片
关键性事务:指在分布式系统中,只有当某个事务被成功提交后,整个系统才能认为这个事务是成功的。如果这个事务失败了,那么整个系统就会回滚到之前的状态。例如支付、订单提交等。
从关键性事务的使用场景出发,最适合的工具便是关系数据库的事务保障。
图片
可补偿事务指在某些业务操作中,如果其中一些子操作执行失败,可以由后续补偿操作进行补救,达到一定的业务目的,例如在资金交易中,如果账户余额不足而支付子操作失败,可以通过撤销订单等补偿操作来保障交易的正确性。
对于可补偿事务,需要提供两组操作:
Seata 是一个开源的分布式事务解决方案,旨在解决分布式系统中的事务一致性问题。在传统的分布式系统中,由于各个服务之间的数据交互和操作都是独立进行的,因此很容易出现数据不一致的情况。这会导致系统出现各种异常情况,如数据丢失、重复提交等,从而影响系统的稳定性和可靠性。
Seata 提供了多种解决方案来解决分布式事务一致性问题。其中包括 XA 模式、TCC 模式和 SAGA 模式等。
Seata 还提供了一些重要的功能,如事务日志记录、故障恢复、动态扩展等,使得用户可以更加方便地使用该框架来解决分布式事务一致性问题。同时,Seata 还具有高性能、高可用性和易用性等特点,可以满足各种不同场景下的需求。
【注】感兴趣的话,可以找下 seata 的官方文档。
Seata 虽好,但中间件的引入将大幅提升系统的复杂性,对于一些不太严谨的场景或者一些运维能力不足的小团队可以自己实现回滚方案。
整体方案如下:
图片
关键事务提交成功,Context 注册的 RollbackEntry 便失去意义;
关键事务提交失败,调用 Context 的 fireFallback 方法进行逆向补偿,fireFallback 方法逆向调用注册的回滚方法,从而恢复业务状态
该方案基于内存实现,存在失灵的情况,不建议使用在严谨的场景。
可重试型事务指在业务操作中,如果某些操作由于网络波动等原因导致失败,可以通过重新执行这些操作来达到其预期的结果,例如在发送短信验证码时,由于网络状况不佳而发送失败,可以重新尝试发送,直到发送成功为止。
可重试性事务没有失败,只有成功,哪怕是短暂的失败也会通过不限的重试使其最终达到成功状态。
@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 仍旧是一个内存解决方案,在极端场景下可能出现任务丢失的情况。因此在实际工作中,很少用于可重试性事务这种场景。
MQ(消息队列)消费者重试机制是指在消费消息时,如果消费者无法成功消费消息(比如网络异常、服务器故障等原因),会自动重试一定次数或间隔一定时间后再次尝试消费消息,以保证消息的可靠性和可用性。
如下图所示:
im
具有MQ的可重试性事务,需要以下保障:
一般情况下会采用多次投递的方式来实现消息投递和消息消费之间的一致性,所以消息消费者需要保障幂等性,避免多次投递造成的业务问题。
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中编写进行大量的适配逻辑,增加研发成本,同时由于逻辑被拆分到多处,也增加了代码的理解成本。
事务消息存在一定的问题:
有没有实用性强、使用简单的方案,那可以使用 事务消息表 方案。
事务消息表方案是一种常用的保证消息发送与业务操作一致性的方法。该方案基于数据库事务和消息队列,将消息发送和业务操作放入同一个事务中,并将业务操作和消息发送的状态记录在数据库的消息表中,以实现消息的可靠性和幂等性。
如下图所示:
图片
核心流程如下:
通过事务消息表方案,可以保证消息的可靠性和幂等性。即使在消息发送失败或应用程序崩溃的情况下,也可以通过重新发送消息将业务操作和消息发送的状态同步。同时,该方案可以避免消息重复发送和漏发的情况。
作为一种通用解决方案,lego 对其进行支持,可参考 reliable-message 模块。
不管在设计时使用哪种方案,都是在尽力降低不一致出现的概率,但可怕的是不一致问题终究会发生。
是不是有些奇怪,做了这么多还是无法从根源上彻底解决一致性问题,在实际工作中就是这样:
除了主动降低不一致性概率,还需要添加一些被动保护机制,也就是常说的业务补偿。
查询模型是最常用的一种方式,主要用于应对网络传输中的第三态问题。
第三态指的是在分布式系统中,在进行跨网络调用时,调用方无法确定被调用方的状态是否改变了,因为这两者之间存在一段未知而不可控的网络延迟时间,导致调用方无法立即得到被调用方的结果。这种情况下,第三态可以看做是一个未知的状态,需要通过一些机制来解决这个问题。
图片
当网络调用出现第三态时,最简单的方式便是对不确定的状态进行查询,如上图所示:
已完成,则继续执行后续流程;
未完成,在重新发起业务调用;
RocketMQ 的事务消息便是基于该机制进行实现。
当一个业务操作完成后,需要处理多个后续任务,为了保障所有任务都会被执行,可以使用该模式。
如下图所示:
图片
image
已经执行,则更新任务状态
如果未执行,则触发任务执行
本地消息表就是基于该模式进行构建。
对账模式经常出现在与银行等金融机构对接的场景。
图片
业务对账思路非常简单:
一致,则说明系统一致
不一致,进行报警,人工介入进行处理
必须是双向对账,单向对账会出现数据丢失情况。
一致性是分布式系统面临的巨大挑战,根据不同场景可以将一致性分为:
本文重点对事务一致性进行全方位的阐述,包括:
MySQL 实现
2PC和XA协议
TCC 解决方案
有了这些方案后,很多场景下仍需落地业务补充,常见方案包括: