最终一致性的方案
知识储备
分布式系统中不可避免存在分布式事务带来的一致性问题。为了解决这个问题,需要熟悉业界相关的理论:
-
ACID
-
CAP
-
BASE
-
2PC
-
3PC
-
TCC
对于一致性的处理,分为强一致和最终一致性。强一致,对系统的吞吐量和性能有较大损耗,一般用在金融/银行系统,而最终一致性,是以牺牲短期的数据强一致、提升可用性的方案。 对于大部分分布式系统,强烈建议放弃强一致性,采取最终一致性方案。
跨系统调用存在的问题
同步调用
-
现状:微服务之间采用HTTP调用,在一个事务内涉及跨系统调用,未考虑过事务一致性问题
-
问题:在异常情况下一定出现数据不一致和脏数据
-
异步消息
-
现状:采用消息队列进行模块解耦,相比第一方案,在吞吐量和可用性方面是更好选择。我们来分析下该方案
-
问题:出现数据不一致
场景 | 本地事务 | 消息处理 | 出现原因 | 数据一致 |
---|---|---|---|---|
1 | 本地处理成功 | 消息发送成功 | 一致 | |
2 | 本地处理成功 | 消息发送失败 | - 消息服务出问题 |
-
消息没有正确投递 | 没有不一致 | | 3 | 本地处理失败 | 消息发送失败 | | 没有不一致 | | 4 | 本地处理失败 | 消息发送成功 | - 发消息客户端超时,消息服务端成功
-
发消息成功,然后A系统突然挂了 | 不一致 |
-
业界最终一致性方案
本地消息表
该方案的核心:
-
在发起远程调用前,先将远程调用的上下文持久化到一个消息表中,并要求消息表的操作与业务表的操作在一个本地事务中,然后通过异步机制去做远程调用。
-
消息表中维护了远程调用操作的状态机,当远程调用成功后,需要标记状态为成功。
-
有一点需要注意:如果遇到异步调用没有成功触发(网络原因或系统down机),需要有补偿重试机制,扫描本地消息表的数据,触发远程调用直到成功。
该方案实现方式较重,需要在每个使用该方案的业务系统专门维护一张消息表。
外部消息表
也称可靠型消息。和本地消息表的区别在于,将消息表移到了云端,由消息中间件统一管理消息的状态机,负责消息的初始化、重投、删除。RocketMQ是典型的例子。
Seata
Seata总共提供了4种模式,分别为AT、TCC、SAGA、XA。其中XA是强一致的,性能较差。
AT
AT是Seata主推的模式,是基于改进后的二阶段协议实现的。其技术核心是在每个服务的业务数据库中创建一个undolog表。
-
在事务第一阶段,Seata确保业务表与undolog表的操作在一个本地事务内。 在undolog表中,会分别记录事务提交前后的数据,称之为前镜像和后镜像,Seata框架会根据前后镜像以及当前SQL的类型,动态分析、计算出反向的回滚SQL。
-
在事务二阶段,如果需要提交,则会删除undolog;如果需要回滚,则Seata框架会执行底层自动生成的回滚SQL。
AT模式不能保证强一致,会存在中间状态,性能较高。AT要求我们拥有每个数据库的管理权,适用于企业内部的系统。
TCC
TCC是广为人知的模式,分为try、confirm、cancel三阶段。
try阶段就是对资源进行预占用,这个就需要对业务模型进行改造,增加中间态字段。
典型的例子,需要在单据表中增加维护预锁定资源的信息,例如锁定库存、预占用金额等。
confirm阶段和cancel阶段,将锁定资源释放,刷新实际资源信息,刷新库存、实际金额等。
TCC不是强一致的,同样存在中间状态的数据。它对业务系统的侵入性很高,所以使用场景比较局限。TCC和AT一样,要求我们拥有每个数据库的管理权。
SAGA
SAGA是基于状态机实现的二阶段协议。其原理:针对每个分支事务的正向业务逻辑,都要求提供一个反向的逻辑实现,以便在出现异常时可以调用反向逻辑进行回滚。SAGA的正向逻辑和反向逻辑,都需要程序员去实现,使用成本较高。它比较适用于长事务场景,尤其是涉及和第三方系统进行交互的场景(业务数据库无法由我方管理)。SAGA不是强一致的,同样存在中间态的数据。
事务消息接入
对数据一致性有要求的场景,可以使用rocketmq的事务型消息,接入比较简单。
使用方式
TransactionMQProducer,区别于发普通消息的DefaultMQProducer
@Override
public TransactionSendResult sendMessageInTransaction(final Message msg,
final Object arg) throws MQClientException {
if (null == this.transactionListener) {
throw new MQClientException( "TransactionListener is null" , null);
}
return this.defaultMQProducerImpl.sendMessageInTransaction(msg, null, arg);
}
TransactionMQProducer初始化时要设置一TransactionListener。
事务提交和事务回查都在TransactionListener实现。
public interface TransactionListener {
/**
* When send transactional prepare(half) message succeed, this method will be invoked to execute local transaction.
* @param msg Half(prepare) message
* @param arg Custom business parameter
* @return Transaction state
*/
LocalTransactionState executeLocalTransaction(final Message msg, final Object arg);
/**
* When no response to prepare(half) message. broker will send check message to check the transaction status, and this
* method will be invoked to get local transaction status.
* @param msg Check message
* @return Transaction state
*/
LocalTransactionState checkLocalTransaction(final MessageExt msg);
事务状态
public enum LocalTransactionState { COMMIT_MESSAGE, ROLLBACK_MESSAGE, UNKNOW, }
executeLocalTransaction方法
有两种接入方法:
-
标准的实现,是将本地的事务逻辑都写在此方法内部,但缺点是对代码的侵入性较大,尤其是当要对老代码进行改造时难度较大
-
另外一种取巧的方法,本地事务逻辑正常写在其他处,然后在executeLocalTransaction方法中返回UNKNOW 状态,这样就完全依靠回查来决定事务的提交状态。
checkLocalTransaction方法
在此方法中汇报本地事务的提交、回滚状态。一般需要通过查询业务表来实现。 以电商系统为例,在订单生成时,发送消息通知物流系统生成物流单据,由于写入订单单据和发送消息要求保证原子性,而本地事务的状态,可以通过判断订单单据是否写入来判断,故checkLocalTransaction的逻辑是:根据订单号查询订单的记录
-
如果订单记录不存在,表明事务未提交,需返回COMMIT_MESSAGE
-
如果订单记录存在,表明事务提交,需返回ROLLBACK_MESSAGE
-
如果方法执行异常,返回UNKNOW,等待rockermq下一次重试回调
总结
本文介绍了分布式系统下最终一致性的常用解决方案,包括本地消息表、事务消息、seata的几种事务模式,他们都有对应的场景。
-
本地消息表是一个可以满足多数业务要求的场景,可用性较高,如果不希望引入其他中间件,可以考虑该方案。在具体实践中,可以将消息的持久化、异步分发远程调用、补偿重试等共性逻辑封装成组件。
-
事务消息有比较广泛的使用场景,稳定性有保障,但由于依赖消息中间件,稳定性不如本地消息表,另外在出现问题时排查不大方便,建议对于链路监控多做考虑。
-
seata的几种模式本文有详细介绍,在实践中要因地制宜的选择。