原理一、 zookeeper原理
zk service网络结构
zookeeper的工作集群可以简单分成两类,一个是Leader,唯一一个,其余的都是follower,如何确定Leader是通过内部选举确定的。
Leader和各个follower是互相通信的,对于zk系统的数据都是保存在内存里面的,同样也会备份一份在磁盘上。对于每个zk节点而言,可以看做每个zk节点的命名空间是一样的,也就是有同样的数据(下面的树结构)
- 如果Leader挂了,zk集群会重新选举,在毫秒级别就会重新选举出一个Leaer
- 集群中除非有一半以上的zk节点挂了,zk service才不可用
zk命名空间结构
zk的命名空间就是zk应用的文件系统,它和linux的文件系统很像,也是树状,这样就可以确定每个路径都是唯一的,对于命名空间的操作必须都是绝对路径操作。与linux文件系统不同的是,linux文件系统有目录和文件的区别,而zk统一叫做znode,一个znode节点可以包含子znode,同时也可以包含数据。
比如/Nginx/conf,/是一个znode,/Nginx是/的子znode,/Nginx还可以包含数据,数据内容就是所有安装Nginx的机器IP,/Nginx/conf是/Nginx子znode,它也可以包含内容,数据就是Nginx的配置文件内容。在应用中,我们可以通过这样一个路径就可以获得所有安装Nginx的机器IP列表,还可以获得这些机器上Nginx的配置文件。
zk读写数据
- 写数据,一个客户端进行写数据请求时,会指定zk集群中节点,如果是follower接收到写请求,就会把请求转发给Leader,Leader通过内部的Zab协议进行原子广播,直到所有zk节点都成功写了数据后(内存同步以及磁盘更新),这次写请求算是完成,然后zk service就会给client发回响应
- 读数据,因为集群中所有的zk节点都呈现一个同样的命名空间视图(就是结构数据),上面的写请求已经保证了写一次数据必须保证集群所有的zk节点都是同步命名空间的,所以读的时候可以在任意一台zk节点上
- ps:其实写数据的时候不是要保证所有zk节点都写完才响应,而是保证一半以上的节点写完了就把这次变更更新到内存,并且当做最新命名空间的应用。所以在读数据的时候可能会读到不是最新的zk节点,这时候只能通过sync()解决。这里先不考虑了,假设整个zk service都是同步meta信息的,后面的文章再讨论。
zk znode类型
zk中znode的节点创建时候是可以指定类型的,主要有下面几种类型。
- PERSISTENT:持久化znode节点,一旦创建这个znode点存储的数据不会主动消失,除非是客户端主动的delete。
- SEQUENCE:顺序增加编号znode节点,比如ClientA去zk service上建立一个znode名字叫做/Nginx/conf,指定了这种类型的节点后zk会创建/Nginx/conf0000000000,ClientB再去创建就是创建/Nginx/conf0000000001,ClientC是创建/Nginx/conf0000000002,以后任意Client来创建这个znode都会得到一个比当前zk命名空间最大znode编号+1的znode,也就说任意一个Client去创建znode都是保证得到的znode是递增的,而且是唯一的。
- EPHEMERAL:临时znode节点,Client连接到zk service的时候会建立一个session,之后用这个zk连接实例创建该类型的znode,一旦Client关闭了zk的连接,服务器就会清除session,然后这个session建立的znode节点都会从命名空间消失。总结就是,这个类型的znode的生命周期是和Client建立的连接一样的。比如ClientA创建了一个EPHEMERAL的/Nginx/conf0000000011的znode节点,一旦ClientA的zk连接关闭,这个znode节点就会消失。整个zk service命名空间里就会删除这个znode节点。
- PERSISTENT|SEQUENTIAL:顺序自动编号的znode节点,这种znoe节点会根据当前已近存在的znode节点编号自动加 1,而且不会随session断开而消失。
- EPHEMERAL|SEQUENTIAL:临时自动编号节点,znode节点编号会自动增加,但是会随session消失而消失
原理二、zookeeper中Watcher和Notifications
在阅读之前首先明确个概念:
1.什么是znode?
2.什么是客户端?
我们使用znode这个术语来表示ZooKeeper的数据节点。
znode维持一个stat结构,它包含数据变化的版本号、ACL变化和时间戳,以允许cache校验和协调化的更新。每当znode的数据变化时,版本号将增加。一个客户端收到数据时,它也会收到数据的版本号。
保存在每个znode中的数据都是自动读写的。读操作获取znode的所有数据,写操作替换掉znode的所有数据。每个节点有一个访问控制表(ACL)来限制谁能做哪些操作。
传统轮询远程service服务
传统远程的service往往是这样服务的,服务提供者在远程service注册自己的服务,服务调用者不断去远程service轮询看看是否服务提供者有没有提供服务或者更新服务。所以有弊端,就是延时比较高,而且因为很多不必要的空轮询带来高的负载和网络损耗,这种模式到zk里面就应该是这样。
zk中异步回调服务
zk实际上的实现是异步回调来代替polling,引入一种机制是event inotifications:客户端首先注册到zk service从而可以接收znode的改变事件,也就是说一旦watch的znode变更了,客户端就会得到相应的通知,然后处理自己的业务逻辑。
- zk客户端可以通过exists,getChildren,getData可以注册观察者,观察者说白了就是指定一个callback
- 那么观察者什么时候调用呢?一旦监听的znode变化了,zk service就会发送对应znode路径给客户端,客户端调用相应的之前注册的回掉函数处理。对于节点的create,delete,setData都会触发观察者,也就是这个callback()函数。
服务端只存储事件的信息,客户端存储事件的信息和Watcher的执行逻辑。客户端在注册watcher的时候,在客户端本地会维持对应znode path和callback()的对应关系。在服务端会维护对应连接session以及znode path和事件类型。服务端触发了对应的事件类型后,会发送给客户端事件类型和znode path,在客户端会根据映射关系调用相应的callback(),接下来的业务逻辑都是在客户端实现的。
zk中watcher单次触发问题
zk中的Watch是一次触发的,一次变更只会触发一个通知,要想下次还得到通知,就需要重新注册。为什么不是永久注册Watcher呢?这主要是考虑到性能上面的影响吧。看下面的情况
- Client C1对于znode /Task 设置了一个watcher
- Client C2来到然后对 /Task 增加znode
- Client C1接收到了notifications,得知监控的znode变化了
- Client C1在处理这个notifications,这时候Client C3又增加 /task 一个znode
在步骤3的时候C1已经触发了一次watcher,步骤四的时候没有watcher了,除非重新设置watcher,所以这个过过程中就会丢失一个notifications,这就是涉及到了CAP原理了。zookeeper只能保证最终一致性,不能保证强一致性,但是因为zk保证了顺序一致性,所以就能确保最终一致性。
- 强一致性:分布式系统里面一个数据变更后,访问任一个服务都可以得到最新的数据
- 弱一致性:一个数据变更后,其中部分服务可以得到最新数据,部分服务不能
- 最终一致性:在更新某个数据后,可能在开始的时候得不到最新的数据,但是最终是可以呈现最新的数据
- 顺序一致性:更新N份数据,能保证是服务是按照N份数据顺序更新提供服务的。
其实对于上面的case也是有办法解决的,具体就是每次在注册watcher之后都getData,保证数据版本是最新的,但相比较传统的polling优势还是很明显的。
zk中Versions
每个znode一旦数据变化,都会有一个递增的版本号,在zk API执行的时候都需要指定版本号,客户端提供的版本号只有和服务端匹配了才能进行znode操作。在多个客户端都要操作同一个znode的时候版本号就很重要了。看下面的情况。
- 比如Client C1写了一个znode /Nginx/conf的数据,写了一些配置信息,这时候/Nginx/conf版本号就从version1变成version2
- 在上面的同时,Client C2也想写/Nginx/conf,因为C2的客户端版本还是version1,而服务端已经是version2了,此刻就会冲突,这个操作就会以失败告终。所以必须要先更新C2上到version2,然后再提交操作。
在确保zookeeper集群节点安装配置的前提下,假设zk已经对外提供了正常的服务,通过下面的步骤来实现Master竞选
- Client连接到zk上,判断znode /Roles/workers是否存在,不存在则建立,znode的类型是PERSISTENT类型,保证不会随着C1的session断开而消失。
- Client在/Roles/workers下面建立一个SEQUENCE|EPHEMERAL类型的znode,前缀可以是worker,由zk保证znode编号是递增而且是暂时的,EPHEMERAL在前文说了,一旦session断开创建的znode也会消失。
- Client通过getChildren获取所有的/Roles/workers下znode列表,并且设置一个Watcher等待通知,返回值有多少个znode数量就对应Client来竞选。
- 对于步骤4返回的节点列表进行排序,找到最小的worker编号,如果是和自己创建的一致(步骤2返回值),那么就代表自己的编号是最小的,自己就是Master。如果发现自己的编号不是最小,那么就等待通知,一旦Watcher触发,就在Watcher回到步骤3。
上面的机制主要利用了zk的几个特性
- 对于N个客户端同时请求create一个znode,zk能保证顺序的一致性,并且保证每个客户端创建的znode节点是递增并且唯一。
- 因为创建的znode是临时的,一旦session断开,那么znode就会从zk上消失,从而给每个设置Watcher的客户端发送通知,让每个客户端重新竞选Master,编号小的肯定是Master,保证了唯一性。
下图是上面的逻辑图
下面是实现的代码,默认是连接本地的zk服务,端口是2181,zkclient模块位于zookeeper python接口只需要运行多个下面的脚本就会能实现Master的竞选。
- 先后在三个终端上面运行下面的脚本,模拟为c1,c2,c3三个client,创建的节点依次是/Roles/workers/worker0000000000,/Roles/workers/worker0000000001,依次是/Roles/workers/worker0000000002
- 发现c1成功竞选了Master,然后c2和c3都是slave
- 把c1关了从而导致依次是/Roles/workers/worker0000000000消失,一段时间后c2和c3会重新竞选,c2会成为master,c3是slave
- 重新启动c1,发现c1立马加入集群,消息里面变更表示创建了新的znode依次是/Roles/workers/worker0000000003,重新竞选,c2还是master
PS:上面步骤3里面一个客户端关闭后经历了一段时间znode才会删除,原因是这段时间内zk的session还没有被清除,因为关闭是通过ctrl+c关闭的。但是加了一个客户端,znode里面创建,就会通知其余注册了watcher的客户端
在zookeeper应用场景有关于分布式集群配置文件同步问题的描述,设想一下如果有100台机器同时对同一台机器上某个文件进行修改,如何才能保证文本不会被写乱,这就是最简单的分布式锁,本文介绍利用zk实现分布式锁。下面是写锁的实现步骤
分布式写锁
create一个PERSISTENT类型的znode,/Locks/write_lock
- 客户端创建SEQUENCE|EPHEMERAL类型的znode,名字是lockid开头,创建的znode是/Locks/write_lock/lockid0000000001
- 调用getChildren()不要设置Watcher获取/Locks/write_lock下的znode列表
- 判断自己步骤2创建znode是不是znode列表中最小的一个,如果是就代表获得了锁,如果不是往下走
- 调用exists()判断步骤2自己创建的节点编号小1的znode节点(也就是获取的znode节点列表中最小的znode),并且设置Watcher,如果exists()返回false,执行步骤3
- 如果exists()返回true,那么等待zk通知,从而在回掉函数里返回执行步骤3
释放锁就是删除znode节点或者断开连接就行
*注意:上面步骤2中getChildren()不设置Watcher的原因是,防止羊群效应,如果getChildren()设置了Watcher,那么集群一抖动都会收到通知。在整个分布式锁的竞争过程中,大量重复运行,并且绝大多数的运行结果都是判断出自己并非是序号最小的节点,从而继续等待下一次通知—,这个显然看起来不怎么科学。客户端无端的接受到过多的和自己不相关的事件通知,这如果在集群规模大的时候,会对Server造成很大的性能影响,并且如果一旦同一时间有多个节点的客户端断开连接,这个时候,服务器就会像其余客户端发送大量的事件通知——这就是所谓的羊群效应。