【分布式】怎么保证一致性

说到一致性就不能不提事务,事务这个词现在经常用于数据库,但是有一点要注意,站在一定的角度,事务并非只适用于数据库。如果站在数据库角度非要下一个定义的话:

事务(Transaction),一般是指要做的或所做的事情。在计算机术语中是指访问并可能更新数据库中各种数据项的一个程序执行单元(unit)。事务通常由高级数据库操纵语言或编程语言(如SQL,C++或Java)书写的用户程序的执行所引起,并用形如begin transaction和end transaction语句(或函数调用)来界定。事务由事务开始(begin transaction)和事务结束(end transaction)之间执行的全体操作组成。

为了更好的认识并实现事务,抽象出了ACID理论,换句话说,如果一个系统能实现ACID特性,那么就实现了事务特性。

  • Atomic原子性: 一个事务的所有系列操作步骤被看成是一个动作,所有的步骤要么全部完成要么一个也不会完成。
  • Consistent一致性:事物完成时,必须所有数据保持一致状态
  • Isolated隔离性:主要用于实现并发控制,
    隔离能够确保并发执行的事务能够顺序一个接一个执行,通过隔离,一个未完成事务不会影响另外一个未完成事务。
  • Durable持久性:持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响

在单机上实现ACID并不困难,通常可以利用锁、时间序列或者顺序日志等机制来保证。这里多说一句,一般而言,隔离性可以利用锁机制来实现,而原子性、一致性和持久性都可以利用日志来实现,一致性算法Raft也是通过日志形式来保证一致性的。

但是在分布式环境下,网络环境却比单机要复杂的多,原因在于网络通信的不可靠性,处于不同网络的多个节点要想保证一致性,网络延迟、网络故障等因素都需要考虑。

本地事务

在介绍微服务下的数据一致性之前,先简单地介绍一下事务的背景。传统单机应用使用一个RDBMS作为数据源。应用开启事务,进行CRUD,提交或回滚事务,统统发生在本地事务中,由资源管理器(RM)直接提供事务支持。数据的一致性在一个本地事务中得到保证。

在这里插入图片描述

分布式事务

两阶段提交(2PC)

二阶段(2PC)提交协议是根据业界首个分布式事务标准规范X/OpenDTP提出的,顾名思义,它通过两个阶段来协商一个提交操作。

X/OpenDTP设计了一个模型来描述分布式事务的各个角色以及规范:
在这里插入图片描述

  1. AP:用户程序,负责触发分布式事务,在这个过程中采用了特殊的事务指令(XA指令),这些指令由TM接管并发送给相关的RM去执行
  2. RM:资源管理器,一般指数据库,每个RM只执行自己相关的指令。映射到程序级别如:ODBC,ADO.Net,JDBC等。
  3. TM:事务管理器或者说是事务协调者,它负责接收AP发起的指令,调度和协调参与事务的所有RM,确保事务正常完成或者回滚。

二阶段提交协议最早用来实现数据库的分布式事务,现在大部分数据库的分布式事务都采用了XA协议。在二阶段提交协议中,一个事务的提交过程被分为两个过程:

  1. 通知阶段。在这个阶段会通知所有参与事务的资源管理器(RM)进行资源的预留和其他准备工作,其中包括了持久化日志文件和资源的锁定。所以这个阶段在整个事务过程中占据了大部分时间,准备完成并把结果返回给事务管理器(TM)。
  2. 提交阶段。在该阶段,事务管理器(TM)会根据上一步的结果来决定是提交还是回滚操作。仅当全部资源管理器(RM)都同意提交的时候,事务管理器(TM)才通知所有的资源管理器(RM)正式提交事务,否则TM将会通知所有的RM取消事务。

在这里插入图片描述

准备阶段
在这里插入图片描述
两阶段提交-commit
在这里插入图片描述
两阶段提交-rollback

二阶段协议的精髓在于,它通过两个阶段来把不可靠事务提交失败的几率降低到了最小,在一个真正的二阶段提交事务的过程中,第一阶段其实占据了整个事务的大部分时间,而真正提交事务的第二阶段几乎是瞬间完成的,所以这正是二阶段的巧妙之处。

但是现实中我们很少使用二阶段提交协议来保证事务性,为什么呢?

  1. 在现实场景中,很少有强一致性的业务,最常用的是基于BASE理论的最终一致性
  2. 二阶段提交协议需要锁定资源,在性能上会有一定损失,这在高并发的场景中是不适合的。
  3. 二阶段提交协议引入了事务管理器(TM),增加了系统的复杂性,而且多数开发人员并不精通TM以及RM的技能。

然而两阶段提交也不能完全保证数据一致性问题,并且有同步阻塞的问题,所以其优化版本三阶段提交(3PC)被发明了出来。

三阶段提交(3PC)

在这里插入图片描述
然而3PC也只能保证绝大多数情况下的数据一致性。

那么,分布式事务2PC或者3PC是否适合于微服务下的事务管理呢?答案是否定的,原因有三点:

  • 由于微服务间无法直接进行数据访问,微服务间互相调用通常通过RPC(Dubbo)或Http API(Spring
    Cloud)进行,所以已经无法使用TM统一管理微服务的RM。
  • 不同的微服务使用的数据源类型可能完全不同,如果微服务使用了NoSQL之类不支持事务的数据库,则事务根本无从谈起。
  • 即使微服务使用的数据源都支持事务,那么如果使用一个大事务将许多微服务的事务管理起来,这个大事务维持的时间,将比本地事务长几个数量级。如此长时间的事务及跨服务的事务,将为产生很多锁及数据不可用,严重影响系统性能。

由此可见,传统的分布式事务已经无法满足微服务架构下的事务管理需求。那么,既然无法满足传统的ACID事务,在微服务下的事务管理必然要遵循新的法则--BASE理论。

BASE理论由eBay的架构师Dan Pritchett提出,BASE理论是对CAP理论的延伸,核心思想是即使无法做到强一致性,应用应该可以采用合适的方式达到最终一致性。BASE是指基本可用(Basically Available)、软状态( Soft State)、最终一致性( Eventual Consistency)。

  • 基本可用:指分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。
  • 软状态:允许系统存在中间状态,而该中间状态不会影响系统整体可用性。分布式存储中一般一份数据至少会有三个副本,允许不同节点间副本同步的延时就是软状态的体现。
  • 最终一致性:最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。

BASE中的最终一致性是对于微服务下的事务管理的根本要求,既基于微服务的事务管理无法达到强一致性,但必须保证最重一致性。那么,有哪些方法可以保证微服务下的事务管理的最终一致性呢,按照实现原理分主要有两类,事件通知型和补偿型,其中事件通知型又可分为可靠事件通知模式及最大努力通知模式,而补偿型又可分为TCC模式、和业务补偿模式两种。这四种模式都可以达到微服务下的数据最终一致性。

可靠事件通知模式

同步事件

可靠事件通知模式的设计理念比较容易理解,即是主服务完成后将结果通过事件(常常是消息队列)传递给从服务,从服务在接受到消息后进行消费,完成业务,从而达到主服务与从服务间的消息一致性。首先能想到的也是最简单的就是同步事件通知,业务处理与消息发送同步执行,实现逻辑见下方代码及时序图。

public void trans() {
    
    
    try {
    
    
    // 1. 操作数据库
        bool result = dao.update(data);
    // 操作数据库失败,会抛出异常    
    // 2. 如果数据库操作成功则发送消息        
    if(result){
    
                
        mq.send(data);
        // 如果方法执行失败,会抛出异常
     }    
    } catch (Exception e) {
    
            
        roolback();
        // 如果发生异常,就回滚
    }
}

在这里插入图片描述
上面的逻辑看上去天衣无缝,如果数据库操作失败则直接退出,不发送消息;如果发送消息失败,则数据库回滚;如果数据库操作成功且消息发送成功,则业务成功,消息发送给下游消费。然后仔细思考后,同步消息通知其实有两点不足的地方。

  1. 在微服务的架构下,有可能出现网络IO问题或者服务器宕机的问题,如果这些问题出现在时序图的第7步,使得消息投递后无法正常通知主服务(网络问题),或无法继续提交事务(宕机),那么主服务将会认为消息投递失败,会滚主服务业务,然而实际上消息已经被从服务消费,那么就会造成主服务和从服务的数据不一致。具体场景可见下面两张时序图。
    在这里插入图片描述
    在这里插入图片描述
  2. 事件服务(在这里就是消息服务)与业务过于耦合,如果消息服务不可用,会导致业务不可用。应该将事件服务与业务解耦,独立出来异步执行,或者在业务执行后先尝试发送一次消息,如果消息发送失败,则降级为异步发送。

异步事件

本地事件服务:

为了解决上述同步事件中描述的同步事件的问题,异步事件通知模式被发展了出来,既业务服务和事件服务解耦,事件异步进行,由单独的事件服务保证事件的可靠投递。
在这里插入图片描述
当业务执行时,在同一个本地事务中将事件写入本地事件表,同时投递该事件,如果事件投递成功,则将该事件从事件表中删除。如果投递失败,则使用事件服务定时地异步统一处理投递失败的事件,进行重新投递,直到事件被正确投递,并将事件从事件表中删除。这种方式最大可能地保证了事件投递的实效性,并且当第一次投递失败后,也能使用异步事件服务保证事件至少被投递一次。

然而,这种使用本地事件服务保证可靠事件通知的方式也有它的不足之处,那便是业务仍旧与事件服务有一定耦合(第一次同步投递时),更为严重的是,本地事务需要负责额外的事件表的操作,为数据库带来了压力,在高并发的场景,由于每一个业务操作就要产生相应的事件表操作,几乎将数据库的可用吞吐量砍了一半,这无疑是无法接受的。正是因为这样的原因,可靠事件通知模式进一步地发展-外部事件服务出现在了人们的眼中。

外部事件服务:

外部事件服务在本地事件服务的基础上更进了一步,将事件服务独立出主业务服务,主业务服务不在对事件服务有任何强依赖。
在这里插入图片描述
业务服务在提交前,向事件服务发送事件,事件服务只记录事件,并不发送。业务服务在提交或回滚后通知事件服务,事件服务发送事件或者删除事件。不用担心业务系统在提交或者会滚后宕机而无法发送确认事件给事件服务,因为事件服务会定时获取所有仍未发送的事件并且向业务系统查询,根据业务系统的返回来决定发送或者删除该事件。

外部事件虽然能够将业务系统和事件系统解耦,但是也带来了额外的工作量:外部事件服务比起本地事件服务来说多了两次网络通信开销(提交前、提交/回滚后),同时也需要业务系统提供单独的查询接口给事件系统用来判断未发送事件的状态。

可靠事件通知模式的注意事项:

可靠事件模式需要注意的有两点:1. 事件的正确发送;2. 事件的重复消费。

通过异步消息服务可以确保事件的正确发送,然而事件是有可能重复发送的,那么就需要消费端保证同一条事件不会重复被消费,简而言之就是保证事件消费的幂等性。

如果事件本身是具备幂等性的状态型事件,如订单状态的通知(已下单、已支付、已发货等),则需要判断事件的顺序。一般通过时间戳来判断,既消费过了新的消息后,当接受到老的消息直接丢弃不予消费。如果无法提供全局时间戳,则应考虑使用全局统一的序列号。

对于不具备幂等性的事件,一般是动作行为事件,如扣款100,存款200,则应该将事件ID及事件结果持久化,在消费事件前查询事件ID,若已经消费则直接返回执行结果;若是新消息,则执行,并存储执行结果。

最大努力通知模式

相比可靠事件通知模式,最大努力通知模式就容易理解多了。最大努力通知型的特点是,业务服务在提交事务后,进行有限次数(设置最大次数限制)的消息发送,比如发送三次消息,若三次消息发送都失败,则不予继续发送。所以有可能导致消息的丢失。同时,主业务方需要提供查询接口给从业务服务,用来恢复丢失消息。最大努力通知型对于时效性保证比较差(既可能会出现较长时间的软状态),所以对于数据一致性的时效性要求比较高的系统无法使用。这种模式通常使用在不同业务平台服务或者对于第三方业务服务的通知,如银行通知、商户通知等,这里不再展开。

业务补偿模式

接下来介绍两种补偿模式,补偿模式比起事件通知模式最大的不同是,补偿模式的上游服务依赖于下游服务的运行结果,而事件通知模式上游服务不依赖于下游服务的运行结果。首先介绍业务补偿模式,业务补偿模式是一种纯补偿模式,其设计理念为,业务在调用的时候正常提交,当一个服务失败的时候,所有其依赖的上游服务都进行业务补偿操作。举个例子,小明从杭州出发,去往美国纽约出差,现在他需要定从杭州去往上海的火车票,以及从上海飞往纽约的飞机票。如果小明成功购买了火车票之后发现那天的飞机票已经售空了,那么与其在上海再多待一天,小明还不如取消去上海的火车票,选择飞往北京再转机纽约,所以小明就取消了去上海的火车票。这个例子中购买杭州到上海的火车票是服务a,购买上海到纽约的飞机票是服务b,业务补偿模式就是在服务b失败的时候,对服务a进行补偿操作,在例子中就是取消杭州到上海的火车票。

补偿模式要求每个服务都提供补偿借口,且这种补偿一般来说是不完全补偿,既即使进行了补偿操作,那条取消的火车票记录还是一直存在数据库中可以被追踪(一般是有相信的状态字段“已取消”作为标记),毕竟已经提交的线上数据一般是不能进行物理删除的。

业务补偿模式最大的缺点是软状态的时间比较长,既数据一致性的时效性很低,多个服务常常可能处于数据不一致的情况。

TCC

由于二阶段提交协议的一系列缺陷,TCC被引入分布式事务。TCC是Try(预留)、Confirm(确认)、Cancel(撤销)3个操作的简称,它包含了预留、确认或撤销这2个阶段。TCC针对分布式事务大体过程是这样的:

TCC模式是一种优化了的业务补偿模式,它可以做到完全补偿,既进行补偿后不留下补偿的纪录,就好像什么事情都没有发生过一样。同时,TCC的软状态时间很短,原因是因为TCC是一种两阶段型模式,只有在所有的服务的第一阶段(try)都成功的时候才进行第二阶段确认(Confirm)操作,否则进行补偿(Cancel)操作,而在try阶段是不会进行真正的业务处理的。

  • 预留阶段:事务发起者分别向所有参与事务的业务方发起请求,要求预留业务资源,业务方并给予回复。
  • 提交阶段:事务发起者收到每个参与事务的业务方的回复,如果都是ok,则通知每个业务方提交事务操作,如果至少有一个业务方返回结果非ok,则通知所有的业务方撤销事务操作。
    在这里插入图片描述

TCC模式的具体流程为两个阶段:

  • Try,业务服务完成所有的业务检查,预留必需的业务资源
  • 如果Try在所有服务中都成功,那么执行Confirm操作,Confirm操作不做任何的业务检查(因为try中已经做过),只是用Try阶段预留的业务资源进行业务处理;否则进行Cancel操作,Cancel操作释放Try阶段预留的业务资源


在这里插入图片描述

这么说可能比较模糊,下面我举一个具体的例子:

小明在线从招商银行转账100元到广发银行。这个操作可看作两个服务,服务a从小明的招行账户转出100元,服务b从小明的广发银行帐户汇入100元。

服务a(小明从招行转出100元):

try:update cmb_account set balance=balance-100, freeze=freeze+100 where acc_id=1 and balance>100;

confirm:update cmb_account set freeze=freeze-100 where acc_id=1;

cancel:update cmb_account set balance=balance+100, freeze=freeze-100 where acc_id=1;

服务b(小明往广发银行汇入100元):

try:update cgb_account set freeze=freeze+100 where acc_id=1;

confirm:update cgb_account set balance=balance+100, freeze=freeze-100 where acc_id=1;

cancel:update cgb_account set freeze=freeze-100 where acc_id=1;

具体说明:

a的try阶段,服务做了两件事:1. 业务检查,这里是检查小明的帐户里的钱是否多余100元;2. 预留资源,将100元从余额中划入冻结资金。

a的confirm阶段,这里不再进行业务检查,因为try阶段已经做过了,同时由于转账已经成功,将冻结资金扣除。

a的cancel阶段,释放预留资源,既100元冻结资金,并恢复到余额。

b的try阶段进行,预留资源,将100元冻结。

b的confirm阶段,使用try阶段预留的资源,将100元冻结资金划入余额。

b的cancel阶段,释放try阶段的预留资源,将100元从冻结资金中减去。

从上面的简单例子可以看出,TCC模式比纯业务补偿模式更加复杂,所以在实现上每个服务都需要实现Cofirm和Cancel两个接口。

TCC本质上是补偿事务,从操作上就可以看出来,每个业务方针对当前事务需要注册三个操作:预留操作,确认操作,取消操作。这三个操作是需要参与事务的每个业务来编码实现的。对应到编码层面,每个业务方都需要提供三个操作的接口,为了一致性,确认操作和取消操作必须是幂等的,因为这两个操作可能会系统性的重试或者人为干预的重试。

TCC在操作上更像是一种编程模型,它主要针对业务层面,所以它在性能上要比主要针对数据库层面的二阶段提交要高很多。目前二阶段提交协议主要的应用场景还是在数据库上,所以它本质上使用的是数据库的锁机制,这也是在高并发的互联网应用中很少使用二阶段提交协议的重要原因之一。

总结

下面的表格对这四种常用的模式进行了比较:
在这里插入图片描述

来源

分布式环境下,如何保证数据一致性??
分布式环境下保证数据一致性的几种实现方式

猜你喜欢

转载自blog.csdn.net/weixin_44231544/article/details/126517019