概述:

与 Eureka 有所不同,Apache Zookeeper 在设计时就紧遵CP原则,即任何时候对 Zookeeper 的访问请求能得到一致的数据结果,同时系统对网络分割具备容错性,但是 Zookeeper 不能保证每次服务请求都是可达的。

从 Zookeeper 的实际应用情况来看,在使用 Zookeeper 获取服务列表时,如果此时的 Zookeeper 集群中的 Leader 宕机了,该集群就要进行 Leader 的选举,又或者 Zookeeper 集群中半数以上服务器节点不可用(例如有三个节点,如果节点一检测到节点三挂了 ,节点二也检测到节点三挂了,那这个节点才算是真的挂了),那么将无法处理该请求。所以说,Zookeeper 不能保证服务可用性。

当然,在大多数分布式环境中,尤其是涉及到数据存储的场景,数据一致性应该是首先被保证的,这也是 Zookeeper 设计紧遵CP原则的另一个原因。

但是对于服务发现来说,情况就不太一样了,针对同一个服务,即使注册中心的不同节点保存的服务提供者信息不尽相同,也并不会造成灾难性的后果。

因为对于服务消费者来说,能消费才是最重要的,消费者虽然拿到可能不正确的服务实例信息后尝试消费一下,也要胜过因为无法获取实例信息而不去消费,导致系统异常要好(淘宝的双十一,京东的618就是紧遵AP的最好参照)。

当master节点因为网络故障与其他节点失去联系时,剩余节点会重新进行leader选举。问题在于,选举leader的时间太长,30~120s,而且选举期间整个zk集群都是不可用的,这就导致在选举期间注册服务瘫痪。

在云部署环境下, 因为网络问题使得zk集群失去master节点是大概率事件,虽然服务能最终恢复,但是漫长的选举事件导致注册长期不可用是不能容忍的。
image.png

过半选举:

三个核心选举原则:

(1)Zookeeper集群中只有超过半数以上的服务器启动,集群才能正常工作;

(2)在集群正常工作之前,myid小的服务器给myid大的服务器投票,直到集群正常工作,选出Leader;

(3)选出Leader之后,之前的服务器状态由Looking改变为Following,以后的服务器都是Follower。

下面以一个简单的例子来说明整个选举的过程:

image.png

假设有五台服务器组成的Zookeeper集群,它们的id从1-5,同时它们都是最新启动的,也就是没有历史数据,在存放数据量这一点上,都是一样的。

假设这些服务器从id1-5,依序启动:

因为一共5台服务器,只有超过半数以上,即最少启动3台服务器,集群才能正常工作。

(1)服务器1启动,发起一次选举。

  1. 服务器1投自己一票。此时服务器1票数一票,不够半数以上(3票),选举无法完成;
  2. 服务器1状态保持为LOOKING

(2)服务器2启动,再发起一次选举。

1
2
3
4
5
     服务器12分别投自己一票,此时服务器1发现服务器2id比自己大,更改选票投给服务器2;

     此时服务器1票数0票,服务器2票数2票,不够半数以上(3票),选举无法完成;

     服务器12状态保持LOOKING;

(3)服务器3启动,发起一次选举。

1
2
3
4
5
     与上面过程一样,服务器12先投自己一票,然后因为服务器3id最大,两者更改选票投给为服务器3;

     此次投票结果:服务器10票,服务器20票,服务器33票。此时服务器3的票数已经超过半数(3票),服务器3当选Leader。

     服务器12更改状态为FOLLOWING,服务器3更改状态为LEADING;

(4)服务器4启动,发起一次选举。

1
2
3
4
5
     此时服务器1,2,3已经不是LOOKING状态,不会更改选票信息。交换选票信息结果:服务器3为3票,服务器4为1票。

     此时服务器4服从多数,更改选票信息为服务器3;

    服务器4并更改状态为FOLLOWING;

(5)服务器5启动,同4一样投票给3,此时服务器3一共5票,服务器5为0票;

    服务器5并更改状态为FOLLOWING;

最终Leader是服务器3,状态为LEADING;

其余服务器是Follower,状态为FOLLOWING。

image.png
客户端每次写操作都有事务id(zkid)
SID: 服务器id,用来唯一标识一台Zookeeper集群中的机器,每台机器不能重复,和myId一致
ZXID:事务ID。ZXID是一个事务ID,用来标识一次服务器状态的变更。在某一时刻,集群中的每台机器的ZXID值不一定完全一致,这和ZooKeeper服务器对于客户端“更新请求”的处理逻辑有关。
由leader服务器负责分配对事务请求进行定序,是8字节的long类型,由两部分组成:前4字节代表epoch,后4字节代表counter,即zxid=epoch+counter。
Epoch:每个Leader任期的代号。Leader编号,每一次重新选举出一个新Leader时,都会为该Leader分配一个epoch,该值也是一个递增的,可以防止旧Leader活过来后继续广播之前旧提议造成状态不一致问题,只有当前最大的epoch也就是最新的Leader的提议才会被Follower处理。Leader没有进行选举期间,epoch是一致不会变化的。
counter:ZooKeeper状态的每一次改变, counter就会递增加1.
zxid=epoch+counter,其中epoch不会改变,counter每次递增1,,这样zxid就具有递增性质, 如果zxid1小于zxid2, 那么zxid1肯定先于zxid2发生。

有两种情况需要进行选举:

  1. 集群中已经有一个leader 直接建立连接,同步状态
  2. 集群中没有leader

Zookeeper的选举算法:

在3.4.0后的Zookeeper版本中只保留了TCP版本的FastLeaderElection 选举算法
投票的结构:

  1. 选举规则

① EPOCH(Leader任期代号)大的胜出
② EPOCH相同,事务id大的胜出
③ 事务id相同,服务器id大的胜出

选举Leader分情况:

  1. 第一次启动的时候选举
  2. 在运行过程中由于leader宕机了,需要 重新选举

假如这个 时候我们有5个服务器,然后 23 宕机了 ,2 是leader,所以我们需要从 145 中选出一个leader

  1. 他们都会分别给自己投票: (1,zxid) (4,zxid) (5,zxid)
  2. 他们不光要发送投票还要接收来自其他服务的投票

    会根据自己的zxid 和 sid 和 接收到的投票进行对比,如果自己的投票zxid 就是大,那么就不需要变更投票,这样,其他服务器
    在和自己的做对比的时候就会把最大的投票出来,

  3. 确定leader

Zookeeper集群节点数量为什么要是奇数个?

首先需要明确zookeeper选举的规则:leader选举,要求 可用节点数量 > 总节点数量/2 。注意 是 > , 不是 ≥。

1.防止脑裂造成的集群不可用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
首先,什么是脑裂?集群的脑裂通常是发生在节点之间通信不可达的情况下,集群会分裂成不同的小集群,小集群各自选出自己的master节点,导致原有的集群出现多个master节点的情况,这就是脑裂。

下面举例说一下为什么采用奇数台节点,就可以防止由于脑裂造成的服务不可用:

(1) 假如zookeeper集群有 5 个节点,发生了脑裂,脑裂成了A、B两个小集群: 

     (a) A : 1个节点 ,B :4个节点 , 或 A、B互换

     (b) A : 2个节点, B :3个节点  , 或 A、B互换

    可以看出,上面这两种情况下,A、B中总会只有有一个小集群满足 可用节点数量 > 总节点数量/2 。所以zookeeper集群仍然能够选举出leader , 仍然能对外提供服务,只不过是有一部分节点失效了而已。

(2) 假如zookeeper集群有4个节点,同样发生脑裂,脑裂成了A、B两个小集群:

    (a) A:1个节点 ,  B:3个节点,   或 A、B互换 

    (b) A:2个节点 , B:2个节点

    可以看出,情况(a) 是满足选举条件的,与(1)中的例子相同。 但是情况(b) 就不同了,因为A和B都是2个节点,都不满足 可用节点数量 > 总节点数量/2 的选举条件, 所以此时zookeeper就彻底不能提供服务了。

综合上面两个例子可以看出: 在节点数量是奇数个的情况下, zookeeper集群总能对外提供服务(即使损失了一部分节点);如果节点数量是偶数个,会存在zookeeper集群不能用的可能性(脑裂成两个均等的子集群的时候)。

在生产环境中,如果zookeeper集群不能提供服务,那将是致命的 , 所以zookeeper集群的节点数一般采用奇数个。

什么是脑裂:
image.png
UserA和UserB分别将自己的信息注册在RouterA和RouterB中。RouterA和RouterB使用数据同步(2PC),来同步信息。那么当UserA想要向UserB发送一个消息的时候,需要现在RouterA中查询出UserA到UserB的消息路由路径,然后再交付给相应的路径进行路由。
当脑裂发生的时候,相当RouterA和RouterB直接的联系丢失了,RouterA认为整个系统中只有它一个Router,RouterB也是这样认为的。那么相当于RouterA中没有UserB的信息,RouterB中没有UserA的信息了,此时UserA再发送消息给UserB的时候,RouterA会认为UserB已经离线了,然后将该信息进行离线持久化,这样整个网络的路由是不是就乱掉了。

2.在容错能力相同下,奇数太更加节省资源

1
2
3
4
5
6
7
8
9
leader选举,要求 可用节点数量 > 总节点数量/2  。注意 是 > , 不是 ≥。

举两个例子:

(1) 假如zookeeper集群1 ,有3个节点,3/2=1.5 ,  即zookeeper想要正常对外提供服务(即leader选举成功),至少需要2个节点是正常的。换句话说,3个节点的zookeeper集群,允许有一个节点宕机。

(2) 假如zookeeper集群2,有4个节点,4/2=2 , 即zookeeper想要正常对外提供服务(即leader选举成功),至少需要3个节点是正常的。换句话说,4个节点的zookeeper集群,也允许有一个节点宕机。

那么问题就来了, 集群1与集群2都有 允许1个节点宕机 的容错能力,但是集群2比集群1多了1个节点。在相同容错能力的情况下,本着节约资源的原则,zookeeper集群的节点数维持奇数个更好一些。

什么是脑裂,以及如何解决的脑裂:

https://www.cnblogs.com/kevingrace/p/12433503.html
https://www.huaweicloud.com/articles/3deb68618270517a54970908e0e817fa.html
2.2、zookeeper是如何解决的?
要解决Split-Brain的问题,一般有3种方式:

  1. Quorums(ˈkwôrəm 法定人数):

比如3个节点的集群,Quorums = 2, 也就是说集群可以容忍1个节点失效,这时候还能选举出1个lead,集群还可用。比如4个节点的集群,它的Quorums = 3,Quorums要超过3,相当于集群的容忍度还是1,如果2个节点失效,那么整个集群还是无效的

  1. 采用Redundant communications,冗余通信的方式,集群中采用多种通信方式,防止一种通信方式失效导致集群中的节点无法通信。
  2. Fencing, 共享资源的方式,比如能看到共享资源就表示在集群中,能够获得共享资源的锁的就是Leader,看不到共享资源的,就不在集群中。

ZooKeeper默认采用了Quorums这种方式,即只有集群中超过半数节点投票才能选举出Leader。这样的方式可以确保leader的唯一性,要么选出唯一的一个leader,要么选举失败。

在ZooKeeper中Quorums有2个作用:

集群中最少的节点数用来选举Leader保证集群可用
通知客户端数据已经安全保存前集群中最少数量的节点数已经保存了该数据。一旦这些节点保存了该数据,客户端将被通知已经安全保存了,可以继续其他任务。而集群中剩余的节点将会最终也保存了该数据

假设某个leader假死,其余的followers选举出了一个新的leader。这时,旧的leader复活并且仍然认为自己是leader,这个时候它向其他followers发出写请求也是会被拒绝的。因为每当新leader产生时,会生成一个epoch,这个epoch是递增的,followers如果确认了新的leader存在,知道其epoch,就会拒绝epoch小于现任leader epoch的所有请求。那有没有follower不知道新的leader存在呢,有可能,但肯定不是大多数,否则新leader无法产生。Zookeeper的写也遵循quorum机制,因此,得不到大多数支持的写是无效的,旧leader即使各种认为自己是leader,依然没有什么作用。

非全新集群选举: 逻辑时钟 数据id 服务器id

https://www.cnblogs.com/veblen/p/10992103.html

数据结构:

zookeeper 3.4 - 图5

节点的类型:

  1. 持久性节点 持久顺序节点
  2. 临时性节点 临时顺序节点
  3. 顺序性节点

客户端写入机制:

一、写数据到Leader节点:
image.png

  1. client向客户端发起写请求
  2. Leader将数据写入本节点,并将数据发送到所有的follower节点;
  3. 等待follower节点返回ack
  4. 当Leader接收到一半以上节点就返回ack给client表示成功

二、 写数据到follower节点:
zookeeper 3.4 - 图7

  1. client向从节点发出写请求,但是从节点是没有写的功能
  2. 从节点将写请求转发给了 主节点 这个时候 主节点写入 再 写入到从节点中
  3. 从节点写入成功返回主节点ack 这个时候 已经过半了 所以可以返回给client客户端 ack确认
  4. 但是返回给ack确认需要从机返回,这个时候主机告诉从机已经过半了,
  5. 从机给client发送ack确认

zookeeper默认是持久节点:
image.png

zookeeper实现分布式锁:

zookeeper 做分布式锁的原理就是 同一个路径下的节点名称不能重复 ,而且可以创建有序临时节点,那么zookeeper是如何保证了节点的唯一性?(他只能保证在同一路径下的节点名称是不能重复的) 看源码发现在创建节点的时候是用synchronized(parentNode)修饰的

https://my.oschina.net/aidelingyu/blog/1600979

分类:

排它锁:

排它锁的核心是:保证当前有且仅有一个事务获得锁,并且锁释放之后,所有正在等待获取锁的事务都能够被通知到

实现排他锁的机制:Zookeeper 的强一致性特性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即Zookeeper将会保证客户端无法重复创建一个已经存在的数据节点。可以利用Zookeeper这个特性,实现排他锁。
每个客户端对某个方法加锁时,在 Zookeeper 上与该方法对应的指定节点的目录下,生成一个唯一的临时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个临时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题
image.png
1,首先假如第一个客户端来获取共享资源也就是获取锁时,zookeeper客户端会创建持久根节点/locks
zookeeper 3.4 - 图10

2,这个时候,客户端Client1就会查询/locks节点下面所有子节点,然后判断自己的节点是不是排序最小的那个,此时,如果是最小的则会获得锁,就能够对共享资源进行操作。
zookeeper 3.4 - 图11

3,如果,这个时候又来个个客户端Client2也来尝试获取锁,那么它也会在zookeeper的/locks节点下创建一个节点。
zookeeper 3.4 - 图12

4,Client2同样也会查询zookeeper中/locks节点下所有节点,判断自己编号是不是最小的,此时,发现自己并不是最小的,所以获取锁失败,然后就像它的前面一位节点0001注册Watcher事件来监控0001节点是否存在。
zookeeper 3.4 - 图13

5,此时,又来一个客户端Client3来尝试获取锁,就会在/locks下创建自己的节点
zookeeper 3.4 - 图14

6,同样,客户端Client3查询/locks下所有节点,判断自己是不是编号最小的节点,此时,发现自己并不是最小的,就会获取所失败,接着就会像它前面一位0002的节点注册Watcher事件,来监听0002节点是否存在。
zookeeper 3.4 - 图15

所以,我们现在能发现获得锁的是客户端Client1,客户端Client2则监听着Client1的锁啥时候释放,而Client3就监听着Client2的锁释放。
共享锁

  1. 使用zk的有序节点和临时节点,每个线程获取锁就在zk创建一个临时节点,
  2. 节点创建完成后,获取当前目录下的所有临时节点,判断当前线程创建的节点是不是最小的节点,如果是就获取锁成功
  3. 如果不是最小的节点,就对节点序号的前一个节点添加一个事件监听

共享锁:

共享锁的流程:
定义锁: 和排他锁一样会定义一个锁节点 。
客户端如果是读请求,就会在锁节点下创建子节点,为 R-序号节点,如何是写节点就会创建 W-序号节点
判断读写顺序:

  1. 如果是读请求,首先判断有没有比自己序号更小的节点,或者比自己小的节点都是读节点,那么就加锁成功,如果有一个写请求,就等待
  2. 如果是写请求,首先判断自己是不是序号最小的,如果是就加锁,如果不是就等待

羊群效应就是会watch所有的节点,判断自己是否是所有节点中最小的
改进后的分布式共享锁:
下面是改进后的分布式锁实现,和之前的实现方式唯一不同之处在于,这里设计成每个锁竞争者,只需要关注”locknode”节点下序号比自己小的那个节点是否存在即可。实现如下:

  1. 客户端调用create()方法创建名为“locknode/guid-lock-”的节点,需要注意的是,这里节点的创建类型需要设置为EPHEMERAL_SEQUENTIAL。
  2. 客户端调用getChildren(“locknode”)方法来获取所有已经创建的子节点,注意,这里不注册任何Watcher。
  3. 客户端获取到所有子节点path之后,如果发现自己在步骤1中创建的节点序号最小,那么就认为这个客户端获得了锁。
  4. 如果在步骤3中发现自己并非所有子节点中最小的,说明自己还没有获取到锁。此时客户端需要找到比自己小的那个节点,然后对其调用exist()方法,同时注册事件监听。
  5. 之后当这个被关注的节点被移除了,客户端会收到相应的通知。这个时候客户端需要再次调用getChildren(“locknode”)方法来获取所有已经创建的子节点,确保自己确实是最小的节点了,然后进入步骤3。

image.png

zk做分布式锁和redisson做分布式锁的区别

  1. redis 如果获取不到锁就会一直重复的去获取锁,比较消耗性能
  2. redis 的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。锁的模型不够健壮
  3. 但是 redis 的好处就是读取数据的性能高
  4. zk 有强一致性,安全
  5. 可以不需要一直轮询,性能消耗低

ZAB协议: 分布式协调协议

zookeeper中的节点有三种状态:

  1. looking 选举状态
  2. leading leader
  3. following follower 从机的状态

ZAB协议包含两种基本模式,分别是:
1》崩溃恢复之数据恢复
2》消息广播之原子广播

消息广播:

image.png
在zookeeper集群中,数据副本的传递策略,
其实也就是一个两阶段提交的过程:

过程:
1》leader接收到消息请求后,将消息赋予一个全局唯一的64位自增id,叫:zxid,通过zxid的大小比较就可以实现因果有序这个特征。
ZAB协议会首先为这个事务分配一个全局单递增的唯一ID,由于ZAB协议需要保证每一个消息的严格的顺序关系,因此必须将每一个proposal按照zxid的先后顺序进行处理

2》leader为每个follower准备了一个FIFO队列(通过TCP协议来实现,以实现全局有序这一个特点)将带有zxid的消息作为一个提案(proposal)分发给所有的 follower。
3》当follower接收到proposal,先把proposal写到磁盘,写入成功以后再向leader回复一个ack。
4》当leader接收到合法数量(超过半数节点)的ack后,leader就会向这些follower发送commit命令,同时会在本地执行该消息。
5》当follower收到消息的commit命令以后,会提交该消息。

崩溃恢复

当整个服务框架启动过程中网络中断, zab协议就会进入到恢复模式并且选举出新的leader服务器
有四个阶段:

  1. 选举 选举出最新的leader 根据 ZXID 和 myid
  2. 发现 选出leader之后,从节点中寻找最新的 事务id 接收所有follower发来的各自最新的epoch,leader从中选出一个最大的,基于此值做+1操作,
  3. 同步
  4. 广播

ZAB协议的这个基于原子广播协议的消息广播过程,在正常情况下是没有任何问题的,但是一旦Leader节点崩溃,或者由于网络问题导致Leader服务器失去了过半的Follower节点的联系(leader失去与过半follower节点联系,可能是leader节点和 follower节点之间产生了网络分区,那么此时的leader不再是合法的leader了),那么就会进入到崩溃恢复模式。在ZAB协议中,为了保证程序的正确运行,整个恢复过程结束后需要选举出一个新的Leader。
为了使leader挂了后系统能正常工作,需要解决以下两个问题:

1》已经被处理的消息不能丢失
当leader收到合法数量follower的ack后,就向各个follower广播commit命令,同时也会在本地执行commit并向连接的客户端返回「成功」。但是如果各个follower在收到commit命令前leader就挂了,导致剩下的服务器并没有执行到这条消息。
leader对事务消息发起commit操作,该消息在follower1上执行了,但是follower2还没有收到commit,leader就已经挂了,而实际上客户端已经收到该事务消息处理成功的回执了。所以在zab协议下需要保证所有机器都要执行这个事务消息,必须满足已经被处理的消息不能丢失。

image.png

leader对事务消息发起commit操作,该消息在follower1上执行了,但是follower2还没有收到commit,leader就已经挂了,而实际上客户端已经收到该事务消息处理成功的回执了。所以在zab协议下需要保证所有机器都要执行这个事务消息,必须满足已经被处理的消息不能丢失。

使用了什么策略来解决的?
通过选举拥有proposal 最大值的节点作为新的leader,因为 能够执行commit就说明之前有合法数量的follow 回应了 ack,也就是保证了多半以上的数量的服务器事务日志上有该 proposal , 因此这些合法的节点中一定会有一个节点保存了所有被commit的proposal状态
这个时候新的leader会和follow建立连接,将自身有而follwer没有的proposal发给follow

2》被丢弃的消息不能再次出现
当leader接收到消息请求生成proposal后就挂了,其他follower并没有收到此proposal,因此经过恢复模式重新选了leader后,这条消息是被跳过的。 此时,之前挂了的leader重新启动并注册成了follower,他保留了被跳过消息的proposal状态,与整个系统的状态是不一致的,需要将其删除。(leader都换代了,所以以前leader的proposal失效了)

针对崩溃恢复的两种情况分析
ZAB协议需要满足上面两种情况,就必须要设计一个leader选举算法,能够确保已经被leader提交的事务Proposal能够提交、同时丢弃已经被跳过的事务Proposal。
针对这个要求:
如果leader选举算法能够保证新选举出来的Leader服务器拥有集群中所有机器最高编号(ZXID 最大)的事务Proposal,那么就可以保证这个新选举出来的leader一定具有已经提交的提案。因为所有提案被commit之前必须有超过半数的follower ack,即必须有超过半数节点的服务器的事务日志上有该提案的proposal,因此只要有合法数量的节点正常工作,就必然有一个节点保存了所有被commit消息的proposal状态。

另外一个,zxid是64位,高32位是epoch编号,每经过一次Leader选举产生一个新的leader,新的leader会将epoch号+1,低32位是消息计数器,每接收到一条消息这个值+1,新leader选举后这个值重置为0。这样设计的好处在于老的leader挂了以后重启,它不会被选举为leader,因此此时它的zxid肯定小于当前新的leader。当老的leader作为follower接入新的leader后,新的leader会让它将所有的拥有旧的epoch号的未被commit的proposal清除。

image.pngimage.pngimage.png