本文主要转自 https://segmentfault.com/a/1190000044275677 并加上了一些我的评论
CAP原理
c a p分表是什么
-
C:Consistency
即一致性,访问所有的节点得到的数据应该是一样的。注意,这里的一致性指的是强一致性,也就是数据更新完,访问任何节点看到的数据完全一致,要和弱一致性,最终一致性区分开来。
-
A:Availability
即可用性,所有的节点都保持高可用性。详细点说,这里的能够提供正常服务必须满足两个条件:
- 必须在合理时间内给出相应,这个合理的时间是根据业务来定的。业务说要求200 毫秒内返回,合理的时间就是 200 毫秒,如果结果在 1 秒才返回,那么这个系统仍然是不满足可用性
- 系统内只要正常的节点都要能够做出响应,返回结果。这里包含两种情况:
- 如果系统内的某个节点或者是某些节点宕机了,但是其他的正常节点可以在合理的时间内做出响应
- 节点正常,但是节点上的数据有问题,比如不是最新数据,如果有请求达到这个节点上了,依然不能拒绝请求,要正常返回这个旧数据
也就是说,任何没有发生故障的服务必须在有限的时间内返回合理的结果集。
-
P:Partiton tolerance
即分区容忍性,这里的分区是指网络意义上的分区。由于网络是不可靠的,所有节点之间很可能出现无法通讯的情况,在节点不能通信时,要保证系统可以继续正常服务。
以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择
cap定理是什么
CAP原理说,一个数据分布式系统不可能同时满足C和A和P这3个条件。所以系统架构师在设计系统时,不要将精力浪费在如何设计能满足三者的完美分布式系统,而是应该进行取舍。由于网络的不可靠性质,大多数开源的分布式系统都会实现P,也就是分区容忍性,之后在C和A中做抉择。
在分布式系统中分区容错性(Partition-tolerance ) 是不得不选择的,所以选择上只能是AP或者是CP
CAP的简单证明
CAP原理简单证明
假设有节点data1和节点data2,一开始有个数据number=1。之后向data1提交更新,将数据number设置为2。
接着data1就需要将更新推送给data2,让data2也更新number数据。
接下来我们分3个场景分析:
- 在保证C和P的情况下
为了保证数据一致性,data1需要将数据复制给data2,即data1和data2需要进行通信。但是由于网络是不可靠的,我们系统有保证了分区容忍性,也就是说这个系统是可以容忍网络的不可靠的。这时候data2就不一定能及时的收到data1的数据复制消息,当有请求向data2访问number数据时,为了保证数据的一致性,data2只能阻塞等待数据真正同步完成后再返回,这时候就没办法保证高可用性了。
所以,在保证C和P的情况下,是无法同时保证A的。
- 在保证A和P的情况下
为了保证高可用性,data1和data2都有在有限时间内返回。同样由于网络的不可靠,在有限时间内,data2有可能还没收到data1发来的数据更新消息,这时候返回给客户端的可能是旧的数据,和访问data1的数据是不一致的,也就是违法了C。
也就是说,在保证A和P的情况下,是无法同时保证C的。
- 在保证A和C的情况下
如果要保证高可用和一致性,只有在网络情况良好且可靠的情况下才能实现。这样data1才能立即将更新消息发送给data2。但是我们都知道网络是不可靠的,是会存在丢包的情况的。所以要满足即时可靠更新,只有将data1和data2放到一个区内才可以,也就丧失了P这个保证。其实这时候整个系统也不能算是一个分布式系统了。
理解CAP理论的最简单方式是想象两个节点分处分区两侧。允许至少一个节点更新状态会导致数据不一致,即丧失了C性质。如果为了保证数据一致性,将分区一侧的节点设置为不可用,那么又丧失了A性质。除非两个节点可以互相通信,才能既保证C又保证A,这又会导致丧失P性质。
AP还是CP?一个例子
我们先来看个例子:以redis为例
异步复制
因为主从间为异步复制,所以会出现复制延迟情况。也就是采用读写分离方式(图2),客户端在主节点写入数据后,在从节点不一定读取到最新的数据(此时满足最终一致性,即当没有数据写入操作后,经过一段时间后主从节点数据最终将达成一致)。如果所有读写操作均在主节点进行(图1),此时似乎和单节点一样可以满足线性一致性的,但是一旦发生故障导致主节点不能访问,为保证系统可用性集群会进行主从切换将从节点提升为主节点,而此时未复制完成的数据就会丢失,客户端也有可能读取到旧数据。所以无论采取什么样的读数据模式,在Redis主从异步复制的架构下,均不满足线性一致性要求,不能用于分布式锁场景。从CAP定理角度看,Redis集群优先保证可用性,集群具备一定的容错能力,出现故障后集群依然可以对外提供服务,但是不保证获取到最新的数据。
同步复制
让我们假设Redis支持同步复制再分析以上读写的场景。同步复制就是主节点接收到写数据请求后,除了完成自身的写入操作外必须要等待所有从节点完成复制操作后才算操作完成并返回客户端(异步复制则不需要等待)。此时主节点数据和从节点数据没有复制延迟问题,无论从主节点或者从节点读取数据都可以获取到最新的值(主节点写入操作和所有从节点写入操作不是发生在同一时间点,而如何让主节点和从节点新写入数据在同一时间点对外可见还是有很多需要考虑的地方)。而且主从切换后也不会丢失数据。
但是同步复制模式也会带来新的问题,首先因为写操作要等待所有从节点完成,对于系统性能有比较大的影响。其次,一旦某个从节点故障或者网络故障,系统就无法写入数据了。显然在同步复制模式下,系统用降低可用性和性能为代价,换取数据一致性。这不仅符合CAP定理两者选其一的要求,也再一次体现了线性一致性对于性能的影不容小觑。
所以对于Redis来说,选择性能和可用性更加符合它的使用场景和自身定位。
脑裂
对于一个分布式系统由网络分区的等原因造成系统分割成不同的部分且都对外提供服务就称之为脑裂。对应到Redis集群场景就是一旦发生脑裂,会有两个Redis主节点同时接受客户端的写请求(图4),这会导致并发写入冲突而造成数据不一致现象。可以引起脑裂的场景很多,例如主从间网络延时、主节点故障后恢复、错误的自动/人工主从切换行为等。显然对于分布式集群脑裂是一个我们不得不解决的问题。
那对于业务而言,这两个架构该如何选择呢?任何方案选型都要根据业务场景出发,看业务场景适合哪种,就选哪种,一般而言:
CP 使用场景
比较典型的 CP 系统是金融领域,为了资金安全一般需要确保强一致性
AP 使用场景
AP则是适应于目前大多数对于用户体验要求高的互联网应用场景,比如社交媒体,内容分发业务,像微博、Instagram。用户量大,主机众多,分布式部署,而且集群的规模越来越大,节点故障、网络故障时有发生,要保证系统的可用性,保障 AP 放弃 CP 是常见的一种做法
在实际应用中,一致性和可用性并不只是简单的二选一问题,而是取决于各自的优先级。当我们强调一致性时,并不意味着系统的可用性会完全丧失。比如,在Zookeeper中,只有在主节点出现问题时,系统才可能会出现短暂的不可用状态,但在其他时间,系统通过各种方式来保证其可用性。同样,强调可用性时,通常也会采用技术手段来确保数据最终能够保持一致性。
对CAP原理的一些常见的理解误区
看到网上很多文章说CAP原理是分布式系统的基石,但是CAP原理其实是对分布式数据存储系统的一个定论。我们假设一个分布式系统各个节点都读写同一个mysql实例,那么对于这个分布式系统来说,讨论CAP原理是没有意义的。因为各个节点之间可以不用因为数据复制而进行通信,满足分区容忍性(P),可以随时响应请求,满足可用性(A),同时因为访问的是一个数据库实例,本身已经保证了数据一致性(C)。
所以,比如当你的系统,读取的是mysql的主从这种场景的时候,CAP才有意义
一致性的分类
1、强一致性(Strong Consistency)
在任何时刻所有的用户或者进程查询到的都是最近一次成功更新的数据。强一致性是程度最高一致性要求,也是最难实现的。关系型数据库更新操作就是这个案例。
2, 单调一致性(Monotonic Consistency)
单调一致性会从读写两个角度有各自的定义。
单调读一致性
如果进程已经看到过数据对象的某个值,那么任何后续访问都不会返回该值之前的值。("If a process has seen a particular value for the object any subsequent accesses will never return any previous values")
单调写一致性
系统保证来自同一个进程的写操作顺序执行。(Write operations that must precede other writes are executed before those other writes.)
3、最终一致性(Eventual Consistency)
和强一致性相对,在某一时刻用户或者进程查询到的数据可能都不同,但是最终成功更新的数据都会被所有用户或者进程查询到。当前主流的nosql数据库都是采用这种一致性策略。
常见中间件的一致性
zk提供的是单调一致性,最终一致性,不是强一致性。
mongodb提供单调写一致性。
zookeeper的一致性
因为一致性概念偏理论理解起来比较抽象,所以为了避免歧义本节参考zookeeper官方文档相关内容并加上自己的解读。
ZooKeeper is a high performance, scalable service. Both reads and write operations are designed to be fast, though reads are faster than writes. The reason for this is that in the case of reads, zooKeeper can serve older data, which in turn is due to ZooKeeper's consistency guarantees:
注意我加粗的内容,翻译过来是,zookeeper有可能读到旧数据。
根据这一点我们就可以断定zookeeper不满足线性一致性和强一致性(线性一致性要求所有操作像操作一个副本,满足数据读取就近原则)。下文引用官方文档来进一步说明zookeeper提供的一致性保证。
Sequential Consistency : Updates from a client will be applied in the order that they were sent.
这里明确了zookeeper对于写数据的顺序性的保证。像大多数共识算法一样,Zab算法也是采用单一主节点来接收所客户端发送的数据写入请求,主节点收到的写入请求的顺序即为写操作顺序。
Atomicity : Updates either succeed or fail -- there are no partial results.
此处的原子性和ACID中的原子性为同一概念,都是保证数据写入操作要么全部完成要么全部失败,不允许部分结果出现。原子性模型保证可以简化客户端故障处理流程,可以通过简单的重试完成故障恢复。这个原子性和前文中线性一致性中所要求的原子性不是一个概念,线性一致性的原子性强调操作不能交叉进行,也就是没有并发操作。
Single System Image : A client will see the same view of the service regardless of the server that it connects to. i.e., a client will never see an older view of the system even if the client fails over to a different server with the same session.
单一系统视图并不能意味着读取到最新数据,只保证了一个客户端读取数据的单调性:客户端一旦读取到最新的数据,它就不会再读取到旧数据了,即使它连接的zookeeper节点因为故障致使它不得不连接到另一个节点,它也不会读取到旧数据。(这个很好实现 比如你每个操作号都有个自增的主键id 通过判断id大小就知道)
Reliability : Once an update has been applied, it will persist from that time forward until a client overwrites the update. This guarantee has two corollaries:
1 If a client gets a successful return code, the update will have been applied. On some failures (communication errors, timeouts, etc) the client will not know if the update has applied or not. We take steps to minimize the failures, but the guarantee is only present with successful return codes. (This is called the monotonicity condition in Paxos.)
2 Any updates that are seen by the client, through a read request or successful update, will never be rolled back when recovering from server failures.
可靠性保证和ACID中的持久性保证类似,大家参照原文自行理解即可。
Timeliness : The clients view of the system is guaranteed to be up-to-date within a certain time bound (on the order of tens of seconds). Either system changes will be seen by a client within this bound, or the client will detect a service outage.
及时性强调了在一个十秒为单位的时间窗口内客户端可以看到数据变更或者检测到系统中断,也就相当于给写入数据在zookeeper整个集群内完成复制拟定了一个时间范围。
Note:Sometimes developers mistakenly assume one other guarantee that ZooKeeper does not in fact make. This is: * Simultaneously Consistent Cross-Client Views* : ZooKeeper does not guarantee that at every instance in time, two different clients will have identical views of ZooKeeper data. Due to factors like network delays, one client may perform an update before another client gets notified of the change. Consider the scenario of two clients, A and B. If client A sets the value of a znode /a from 0 to 1, then tells client B to read /a, client B may read the old value of 0, depending on which server it is connected to. If it is important that Client A and Client B read the same value, Client B should call the sync() method from the ZooKeeper API method before it performs its read. So, ZooKeeper by itself doesn't guarantee that changes occur synchronously across all servers, but ZooKeeper primitives can be used to construct higher level functions that provide useful client synchronization.
zookeeper官方文档还特意说明了zookeeper并不保证同步复制,所以不同的客户端可能在从节点上读取到旧数据,打消了zookeeper支持线性一致性读错误理解。
以上内容引自:https://zookeeper.apache.org/doc/r3.9.0/zookeeperProgrammers...
最后我们来总结一下,通过对官方文档一致性保证内容的解读:
- zookeeper支持对于写操作的线性一致性------所有客户端的写操作都按照主节点的顺序操作。
- 而对于读操作并不保证线性一致性.
- 在一个客户端视角保证单调性读------既一个客户端一旦读取到最新值就不会读取到旧值.
- 但是在多个客户端视角更像最终一致性------一个客户端读取到新值,而另一个客户端可能读取到旧值,但是一段时间后也会读取到新值。
非强一致性影响zookeeper的分布式锁实现么?
简单实现
我们利用"同一级节点 key 名称是唯一的"这个特性,让不同的多个客户端使用create()方法创造了一个永久节点(create /lock 0),客户端通过判断请求是否成功来判断自己是否加锁成功。释放锁的方法很简单,就是使用delete命令删除节点即可(delete /lock)。
在这个实现中,由于都是写操作,所以是不存在延迟更新问题的。
但是这样有两个问题:
- 一个客户端释放锁后,其他客户端无法感知这个操作。所以只能通过定期轮询的方式再次请求加锁。
- 客户端进程崩溃、网路故障等原因都可能造成未能调用unlock方法(或者调用失败),此时锁不能被正确被释放阻塞后续其他客户端操作。
所以我们对分布式锁又提出了两个新的要求:
- 锁释放后其他等待锁的客户端可以收到通知并再次请求锁,无需客户端自己轮序操作。
- 无论出现任何异常情况,锁都能被正确的释放。
改进版实现
要满足以上两点新的要求,需要用到zookeeper两个特性,第一个是临时节点,第二个是watch机制。
watch机制类似于观察者模式,watch 流程是客户端向服务端某个节点路径上注册一个watcher,同时客户端也会存储特定的 watcher,当节点数据或子节点发生变化时,服务端通知客户端,客户端进行回调处理。通过watch机制其他待加锁客户端可以监听节点变化以获取锁的信息,避免无效的轮序操作。
zookeeper创建节点的时候可以选择临时节点,临时节点的特点就是一旦session 关闭,临时节点清除。这样一旦加锁的客户端发生故障(或网络异常)造成客户端和zookeeper间session关闭,临时节点就会被释放,规避了锁长期占有的情况。使用两个新的特性后新的加锁操作步骤如下:
- 使用create命令创建一个临时节点(create -e /lock 0),客户端通过命令的返回结果判断自己是否获得了锁
- 所有没有获取锁的客户端使用exists命令判断节点是否存在并监听节点变化(exists -w /lock #w参数是监听的意思)。
- 如果节点不存在重新执行步骤1。
(节点不存在有两种情况,一种是创建节点的数据复制延时读取到旧的数据,一种是节点被另一个客户端释放锁的行为删除了) - 如果节点存在,则客户端等待节点的删除通知,一旦收到节点删除的通知重新执行步骤1。
(节点存在也有两种情况,一种是节点依然被另一个客户端作为锁持有。一种是节点已经被另一个客户端释放锁而删除,但是此处因为复制延时而读取到了旧数据)
- 如果节点不存在重新执行步骤1。
通过以上步骤我们发现,exists命令也有可能读取到旧数据。这样是否会影响到加锁逻辑的正确性,我们详细分析一下。
在步骤2.1中如果客户端读取到了旧数据,也就是添加节点行为的复制延时让客户端误认为节点暂不存在,通过再次执行步骤1就可以避免此问题。
而在步骤3中如果客户端读取到了过期数据,误以为此时锁还没有被释放而进入了等待通知状态,是不是就会一直等待下去呢?答案是否定的,此处就用到了zookeeper中关于watch机制的相关保证:
With regard to watches, ZooKeeper maintains these guarantees:
Watches are ordered with respect to other events, other watches, and asynchronous replies. The ZooKeeper client libraries ensures that everything is dispatched in order.
++A client will see a watch event for a znode it is watching before seeing the new data that corresponds to that znode.++The order of watch events from ZooKeeper corresponds to the order of the updates as seen by the ZooKeeper service.
以上内容引自:https://zookeeper.apache.org/doc/r3.9.0/zookeeperProgrammers...
注意我标注的文字,表明了一个客户端应该先收到它所观察的节点的事件通知然后才会看到这个节点的变化。具体到上面的问题就是即使步骤3中客户端因为读取旧的数据误以为锁未被释放进入了等待通知状态,客户端也一定可以收到节点删除的事件,从等待中被唤醒。因为根据watch机制的保证,如果读取到过期的数据,那么也一定还没有接收到导致数据变化的事件通知。
至此我们已经说明了,无论exists命令读取到了新增节点的延时数据还是删除节点的延时数据,均不影响加锁的正确性。
但是如果你详细阅读过zookeeper的官方文档或者Curator等zookeeper客户端的源码,你就会发现上面的方法并不是zookeeper推荐的方案,而是在这个方案上更近一步规避了"羊群效应"。什么是"羊群效应"呢?以上加锁方法所有客户端都新建一个节点并监听节点变化,一旦这个节点删除所有客户端都会收到通知并同时并发新建这个节点,但依然只有一个节点可以添加成功,客户端少的情况下还好,一旦客户端较多,这种周期性、大量的并发访问加重了集群的负担,也降低了分布式锁的执行效率。zookeeper为我们提供了一种更好的实现方式。
zookeeper官方推荐的分布式锁的实现步骤
- 客户端调用create命令创建一个临时顺序节点(create -s -e locks/lock-)
- 客户端通过getChildren方法查看locks节点下子节点情况:
- 如果locks节点下所有子节点的最小序号节点等于步骤1中创建操作返回的临时顺序节点的序号,说明获取锁成功。
- 如果不等于(不存在或者最小序号小于我们的锁id),就用exists命令判断小于自己创建节点序号的上一个节点是否存在,并watch此节点(上一个节点)变化。
- 如果上一个节点存在,等待节点删除时间的通知,受到通知后跳转到步骤2重新执行。
- 如果上一个节点不存在直接跳转到步骤2执行。
有序节点会在创建节点的时候为自动为节点添加序号后缀,三个客户端执行步骤1后目录示例如下:
bash
+/locks
++lock-01
++lock-02
++lock-03
其中,01、02、03由zookeeper写入数据的顺序一致性保证,可产生一个全局唯一的单调递增的编号,这就是加锁的核心逻辑。
我们不再通过create命令唯一性保证加锁操作的排他性,而是通过判断哪个客户端创建了最小序号的有序节点来判断加锁行为。这样就带来了两个显而易见的好处,第一,未获得锁的客户端重新争抢锁的时候不需要重新创建节点,只需要判断自己已经创建的节点是不是变成了当前最小的节点即可。第二,所有未获取到锁的客户端无需只都监听一个节点的状态来获取锁释放的消息,而是监听比自身序号小的上一个节点状态变化。这样就按照加锁操作的先后顺序形成了一个释放通知队列,避免了所有未获取锁的客户端在锁释放后一拥而上重新争抢锁的羊群效应。这两点好处可以显著提高zookeeper分布式锁执行效率。
你们可以看到,监听,是所有服务都去监听一个节点的,节点的释放也会通知所有的服务器,如果是900个服务器呢?
这对服务器是很大的一个挑战,一个释放的消息,就好像一个牧羊犬进入了羊群,大家都四散而开,随时可能干掉机器,会占用服务资源,网络带宽等等。
这就是羊群效应。
接下来 我们来分析下读操作的延迟问题。
加锁步骤中的getChildren和exists这两个读操作会出现读取到过期数据的问题。
主要是分析两种
- 没有读取到我们刚刚新建的节点数据------延迟问题
- 读取到了已被删除的节点数据------导致一直等待
首先是getChildren,理论上由于客户端一致性,不会出现没有读取到已经新建的节点数据的问题,但是我们还是假设会,分析下:
- 如果是没有读取到我们刚刚新建的节点数据,这时候最小序号:
- 不存在 证明我们是第一个创建的锁或者前面的还没更新过来
- 如果我们刚刚新建的是01 那我们肯定是第一个获取的锁,使用带watch的exits命令后上一个节点一定不存在,跳转到步骤2
- 不是01,会使用带watch的exits命令
- 或者小于我们的锁id,会使用带watch的exits命令
- 不存在 证明我们是第一个创建的锁或者前面的还没更新过来
- 如果读取到了已被删除的节点数据。也就是前面一位的已经被删除了,但是我们还没读取到。此时使用带watch的exits命令
接下来分析exits,发现也和改进版实现中说的差不多。
所以,读取到过期数据的行为不会对加锁逻辑造成影响。
zookeeper还有个sync操作 但是目前不确定能不能解决读的延时问题,文章这里没用到这个也能保证,所以以后再说吧,先不深入看这个命令了
而且 官方文档有提到sync方法也不能完全保证,因为sync不是quorum操作
但是 以下场景中Client1和Client2在窗口时间内可能同时获得锁:
Client 1 创建了 znode 节点/lock,获得了锁。
Client 1 进入了长时间的 GC pause。(或者网络出现问题、或者 zk 服务检测心跳线程出现问题等等)
Client 1 连接到 ZooKeeper 的 Session 过期了。znode 节点/lock 被自动删除。
Client 2 创建了 znode 节点/lock,从而获得了锁。
Client 1 从 GC pause 中恢复过来,它仍然认为自己持有锁。
不过比较少见吧
总结
接下来我们总结一下本文内容。
文章开头我们介绍了zookeeper的一致性模型,zookeeper仅对写操作提供线性一致性保证,无论多少个客户端的写操作在zookeeper集群全局有序。而对于跨客户端的读操作,zookeeper仅能提供类似于最终一致性的保证。
接下来我们介绍了三种锁的实现方式,首先介绍了最简单的实现方式,仅仅使用create命令不能创建同名节点的特性实现加锁逻辑,本质上也是利用zookeeper写线性一致性保证。然后我们引入临时节点和事件监听概念,改进了加锁逻辑以解决释放锁后通知及异常未释放锁这两个问题,并且为了处理exists命令读取到延时数据问题,依赖了zookeeper事件监听机制顺序性保证。最后我们又引入有序节点的概念,规避"羊群效应"。为了正确高效的使用zookeeper实现一个分布式锁,我们依赖于的写数据线性一致性保证、临时节点、有序节点、节点监听机制、监听事件顺序性保证等诸多zookeeper功能及特性,正是依赖于以上众多功能和特性才能保证zookeeper即使并不满足读数据的线性一致性保证,依然可以实现一个分布式锁。
zookeeper java 第三方库有 Curator,Curator提供的InterProcessMutex是分布式锁的实现