分布式系统下最终统一性研究

xiaoxiao2021-02-28  8

CAP定理是2000年,由 Eric Brewer 提出来的

Brewer认为在分布式的环境下设计和部署系统时,有3个核心的需求,以一种特殊的关系存在。这里的分布式系统说的是在物理上分布的系统,比如我们常见的web系统。

这3个核心的需求是:Consistency,Availability和Partition Tolerance,赋予了该理论另外一个名字 - CAP。

Consistency:一致性,这个和数据库ACID的一致性类似,但这里关注的所有数据节点上的数据一致性和正确性,而数据库的ACID关注的是在在一个事务内,对数据的一些约束。

Availability:可用性,关注的在某个结点的数据是否可用,可以认为某一个节点的系统是否可用,通信故障除外。

PartitionTolerance:分区容忍性,是否可以对数据进行分区。这是考虑到性能和可伸缩性。

为 什么不能完全保证这个三点了,因为一旦进行分区了,就说明了必须节点之间必须进行通信,涉及到通信,就无法确保在有限的时间内完成指定的行文,如果要求两个操作之间要完整的进行,因为涉及到通信,肯定存在某一个时刻只完成一部分的业务操作,在通信完成的这一段时间内,数据就是不一致性的。如果要求保证一致 性,那么就必须在通信完成这一段时间内保护数据,使得任何访问这些数据的操作不可用。如果想保证一致性和可用性,那么数据就不能够分区。一个简单的理解就是所有的数据就必须存放在一个数据库里面,不能进行数据库拆分。这个对于大数据量,高并发的互联网应用来说,是不可接受的。

目前的应用系 统,最终数据的一致性是每个应用系统都要面临的问题,随着分布式的逐渐普及,数据一致性更加艰难,但是也很难有银弹的解决方案,也并不是引入特定的中间件或者特定的开源框架能够解决的,更多的还是看业务场景,根据场景来给出解决方案。应用系统在编码的时候,更加关注数据的一致性,这样系统才是健壮的。

单数据库情况下的事务

如 果系统是单一的数据库,那么这个很好保证,利用数据库的事务特性来满足事务的一致性,这时候的一致性是强一致性的。对于java应用系统来讲,很少直接通 过事务的start和commit以及rollback来硬编码,大多通过spring的事务模板或者声明式事务来保证。

基于事务型消息队列的最终一致性

借 助消息队列,在处理业务逻辑的地方,发送消息,业务逻辑处理成功后,提交消息,确保消息是发送成功的(需要做消息回查,目前开源的rocketmq不支持 此功能,阿里 云ONS的half消息及本地事务可以满足),之后消息队列投递来进行处理,如果成功,则结束,如果没有成功,则重试,直到成功,不过仅仅适用业务逻辑中,第一阶段成功,第二阶段必须成功的场景。   事务消息:你的事务包括两部分:1,业务逻辑,2,发送消息;你要保证这 两部分是一致性的,就是:业务逻辑处理成功则,消息一定发出去了,业务逻辑处理失败,则消息一定不能发出去。不能出现不一致的情况

基于消息队列+定时补偿机制的最终一致性

前面部分和上面基于事务型消息的队列,不同的是,第二阶段重试的地方,不再是消息中间件自身的重试逻辑了,而是单独的补偿任务机制。其实在大多数的逻辑中,第二阶段失败的概率比较小,所以单独独立补偿任务表出来,可以更加清晰,能够比较明确的直到当前多少任务是失败的。

 

我们可以拿一个简单的例子来说明:假设一个购物系统,卖家A和卖家B做了一笔交易100元,交易成功了,买家把钱给卖家。

这里面存在两张表的数据:Trade表Account表 ,涉及到三条数据Trade(100),Account A,Account B

假 设 trade表和account表在一个数据库,那么只需要使用数据库的事务,就可以保证一致性,同时不会影响可用性。但是随着交易量越来越大,我们可以考虑按照业务分库,把交易库和account库单独分开,这样就涉及到trade库和account库进行通信,也就是存在了分区,那么我们就不可能同时保 证可用性和一致性。

我们假设初始状态

trade(buyer,seller,tradeNo,status)= trade(A,B,20160101,I)

account(accountNo,balance)= account(A,300)

account(accountNo,balance)= account(B,10)

在理想情况下,我们期望的状态是

trade(buyer,seller,tradeNo,status)= trade(A,B,20160101,S)

account(accountNo,balance)= account(A,200)

account(accountNo,balance)= account(B,110)

 

但是考虑到一些异常情况

假设在trade(20121001,S)更新完成之前,帐户A进行扣款之后,帐户A进行了另外一笔300款钱的交易,把钱消费了,那么就存在一个状态

trade(buyer,seller,tradeNo,status)= trade(A,B,20160101,S)

account(accountNo,balance)= account(A,0)

account(accountNo,balance)= account(B,10)

产生了数据不一致的状态

 

由 于这个涉及到资金上的问题,对资金要求比较高,我们必须保证一致性,那么怎么办,只能在进行trade(A,B,20160101)交易的时候,对于任何 A的后续交易请求trade(A,X,X),必须等到A完成之后,才能够进行处理,也就是说在进行trade(A,B,20160101)的时 候,Account(A)的数据是不可用的。

 

任何架构师在设计分布式的系统的时候,都必须在这三者之间进行取舍。首先就是 是否选择分区,由于在一个数据分区内,根据数据库的ACID特性,是可以保证一致性的,不会存在可用性和一致性的问题,唯一需要考虑的就是性能问题。对于 可用性和一致性,大多数应用就必须保证可用性,毕竟是互联网应用,牺牲了可用性,相当于间接的影响了用户体验,而唯一可以考虑就是一致性了。

 

牺牲一致性

对 于牺牲一致性的情况最多的就是缓存和数据库的数据同步问题,我们把缓存看做一个数据分区节点,数据库看作另外一个节点,这两个节点之间的数据在任何时刻都无法保证一致性的。在web2.0这样的业务,开心网来举例子,访问一个用户的信息的时候,可以先访问缓存的数据,但是如果用户修改了自己的一些信息,首先修改的是数据库,然后在通知缓存进行更新,这段期间内就会导致的数据不一致,用户可能访问的是一个过期的缓存,而不是最新的数据。但是由于这些业务对一 致性的要求比较高,不会带来太大的影响。

异常错误检测和补偿

还有一种牺牲一致性的方法就是通过一种错误补偿机制来进行,可以拿上面购物的例子来说,假设我们把业务逻辑顺序调整一下,先扣买家钱,然后更新交易状态,在把钱打给卖家

我们假设初始状态

account(accountNo,balance)= account(A,300)

account(accountNo,balance)= account(B,10)

trade(buyer,seller,tradeNo,status)= trade(A,B,20121001,I)

 

那么有可能出现

account(accountNo,balance)= account(A,200)

trade(buyer,seller,tradeNo,status)= trade(A,B,20121001,S)

account(accountNo,balance)= account(B,10)

 

那么就出现了A扣款成功,交易状态也成功了,但是钱没有打给B,这个时候可以通过一个时候的异常恢复机制,把钱打给B,最终的情况保证了一致性,在一定时间内数据可能是不一致的,但是不会影响太大。

 

按功能分割

相关的功能部分应该合在一起,不相关的功能部分应该分割开来——不管你把它叫做SOA、功能分解还是工程秘诀。而且,不相关的功能之间耦合程度越松散,就越能灵活地独立伸缩其中的一部分。

将过程转变为异步的流

用 异步的原则解耦程序,尽可能将过程变为异步的。对于要求快速响应的系统,这样做可以从根本上减少请求者所经历的响应延迟。对于网站或者交易系统,牺牲数据或执行的延迟时间(完成全部工作的实践)来换取用户的延迟时间(用户得到响应的时间)是值得的。活动跟踪、单据开付、决算和报表等处理过程显然都 应该属于后台活动。主要用例过程中常常有很多步骤可以进一部分解成异步运行。任何可以晚点再做的事情都应该晚点再做。

适当地使用缓存

要适当地使用缓存。因为缓存是否高效极大地依赖于用例的细节。说到底,要在存储约束、对可用性的需求、对陈旧数据的容忍程度等条件下最大化缓存的命中率,这才是一个高效的缓存系统的最终目标。要平衡众多因素是极其困难的,即使暂时达到目标,情况也极可能随着时间而改变。

最适合缓存的是很少改变、以读为主的数据——比如元数据、配置信息和静态数据。积极地缓存这种类型的数据,并且结合使用“推”和“ 拉”两种方法保持系统在一定程度上的更新同步。减少对相同数据的重复请求能达到非常显著的效果。频繁变更、读写兼有的数据很难有效地缓存。

 

转载请注明原文地址: https://www.6miu.com/read-1999994.html

最新回复(0)