Zookeeper 总结
1. Zookeeper 概述
Zookeeper 是一个开源的、分布式的、为分布式应用提供协调服务的 Apache 项目。
从设计模式角度来理解:Zookeeper 是一个基于观察者模式设计的分布式服务管理框架,它负责存储和管理大家都关心的数据,然后接受观察者的注册,一旦这些数据的状态发生变化,Zookeeper 就将负责通知已经在Zookeeper 上注册的那些观察者做出相应的反应。
2. Zookeeper 数据模型
Zookeeper 数据模型的结构与 Unix 文件系统很类似,整体上可以看作是一棵树,每个节点称做一个 ZNode。每一个 ZNode 默认能够存储 1MB 的数据,每个 ZNode 都可以通过其路径唯一标识。
3. Zookeeper 基本概念
3.1 集群角色
- Leader:事务请求的唯一调度者和协调者,保证事务处理的顺序。
- Follower:处理客户端的非事务请求,转发事务请求给Leader,参与Leader的选举和Proposal的提议。
- Observer:处理客户端的非事务请求,转发事务请求给Leader,不参与任何选举,主要为了提高读性能。
3.2 Session (会话)
Session 是指客户端会话,在讲解会话之前,我们首先来了解一下客户端连接。
在 Zookeeper 中,一个客户端连接是指客户端和服务器之间的一个TCP长连接。Zookeeper 对外的服务端口默认是2181,客户端启动的时候,首先会与服务器建立一个 TCP连接,从第一次连接建立开始,客户端会话的生命周期也开始了,通过这个连接,客户端能够通过心跳检测与服务器保持有效的会话,也能够向Zookeeper服务器发送请求并接受响应,同时还能够通过该连接接收来自服务器的 Watch事件通知。
Session 的 sessionTimeout 值(通过编程语言 API 传入此参数)用来设置一个客户端会话的超时时间。当由于服务器压力太大、网络故障或是客户端主动断开连接等各种原因导致客户端连接断开时,只要在sessionTimeout 规定的时间内能够重新连接上集群中任意一台服务器,那么之前创建的会话仍然有效,仍然算作同一次会话。
3.3 节点类型
在 Zookeeper 中,每个数据节点都是有生命周期的,其生命周期的长短取决于数据节点的节点类型。在Zookeeper 中,节点类型可以分为持久节点 (PERSISTENT)、临时节点 (EPHEMERAL) 和顺序节点 (SEQUENTIAL) 三大类,具体在节点创建过程中,通过组合使用,可以生成以下四种组合型节点类型,这四种类型可以在使用编程语言的 API 创建节点的时候指定(传参指定)。
持久节点(PERSISTENT)
持久节点是Zookeeper中最常见的一种节点类型。所谓持久节点,是指该数据节点被创建后,就会一直存在于Zookeeper 服务器上,直到有删除操作来主动清除这个节点。
持久顺序节点(PERSISTENT_SEQUENTIAL)
持久顺序节点的基本特性和持久节点是一致的,额外的特性表现在顺序性上。在Zookeeper中,每个父节点都会为它的第一级子节点维护一份顺序,用于记录下每个子节点创建的先后顺序。基于这个顺序特性,在创建子节点的时候,可以设置这个标记,那么在创建节点过程中,Zookeeper 会自动为给定节点名加上一个数字后缀,作为一个新的、完整的节点名。另外需要注意的是,这个数字后缀的上限是整型的最大值。
临时节点(EPHEMERAL)
和持久节点不同的是,临时节点的生命周期和客户端的会话绑定在一起,也就是说,如果客户端会话失效,那么这个节点就会被自动清理掉。注意,这里提到的是客户端会话失效,而非 TCP连接断开,只要在sessionTimeout 规定的时间内能够重新连接上集群中任意一台服务器,那么之前创建的会话仍然有效,仍然算作同一次会话。另外,Zookeeper规定了不能基于临时节点来创建子节点,即临时节点只能作为叶子节点。
临时顺序节点(EPHEMERAL_SEQUENTIAL)
临时顺序节点的基本特性和临时节点也是一致的,同样是在临时节点的基础上,添加了顺序的特性。
3.4 状态信息
事实上,每个数据节点除了存储了数据内容之外,还存储了数据节点本身的一些状态信息。
4. Zookeeper 特点
- 集群中只要有半数以上节点存活,Zookeeper集群就能正常服务。
- 全局数据一致:每个Server保存一份相同的数据副本,Client无论连接到哪个Server,数据都是一致的。
- 更新请求顺序进行,对于来自客户端的每个更新请求,Zookeeper 都会分配一个全局唯一的递增编号,这个编号反映了所有事务操作的先后顺序。
- 数据更新原子性,一次数据更新要么成功,要么失败。(事务特点)
- 实时性,在一定时间范围内,Client能读到最新数据。
Zookeeper 一般不用于存储所有数据,而是用于存储一些配置文件、核心信息,所以数据量相对来说很少,因此数据同步得很快。
5. 使用 Zookeeper
ZooKeeper 提供了简单的分布式原语,并且对多种编程语言提供了API。
5.1 创建会话
注意,Zookeeper 客户端和服务端会话的建立是一个异步的过程,也就是说在程序中,构造方法会在处理完客户端初始化工作后立即返回,在大多数情况下,此时并没有真正建立好一个可用的会话,在会话的生命周期中处于“CONNECTING”的状态。
当该会话真正创建完毕后,Zookeeper服务端会向会话对应的客户端发送一个事件通知,以告知客户端,客户端只有在获取这个通知之后,才算真正建立了会话。
5.2 创建节点
可以以同步或者异步的方式创建节点,但不论是哪种方式 Zookeeper 都不支持递归创建,即无法在父节点不存在的情况下创建一个子节点。
利用 Zookeeper 的强一致性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即 Zookeeper 将会保证客户端无法重复创建一个已经存在的数据节点。也就是说,如果同时有多个客户端请求创建同一个节点,那么最终一定只有一个客户端请求能够创建成功。创建同名节点的时候会抛出异常。
目前,Zookeeper 的节点内容只支持字节数组(byte[])类型,也就是说,Zookeeper 不负责为节点内容进行序列化,开发人员需要自己使用序列化工具将节点内容进行序列化和反序列化。对于字符串,可以简单地使用string.getBytes() 来生成一个字节数组;对于其他复杂对象,可以使用Hessian或是Kryo等专门的序列化工具来进行序列化。
5.3 删除节点
在节点删除过程中,只允许删除叶子节点。也就是说,如果一个节点存在至少一个子节点的话,那么该节点将无法被直接删除,必须先删除掉其所有子节点。
5.4 读取数据
在节点读取过程中,如果节点数据发生变化,在服务端发送给客户端的事件通知中,是不包含最新的节点列表的,客户端必须主动重新进行获取。通常客户端在收到这个事件通知后,就可以再次获取最新的子节点列表了。
5.5 更新数据
具体来说,假如一个客户端试图进行更新操作,它会携带上次获取到的 version 值进行更新。而如果在这段时间内,Zookeeper 服务器上该节点的数据恰好已经被其他客户端更新了,那么其数据版本一定也发生了变化,因此肯定与客户端携带的 version 无法匹配,于是便无法更新成功——因此可以有效地避免一些分布式更新的并发问题,Zookeeper 的客户端就可以利用该特性构建更复杂的应用场景,例如分布式锁服务等。(这个过程相当于一次 CAS 操作)
6. Zookeeper 应用场景举例
6.1 配置中心
6.1.1 背景
在我们平常的应用系统开发中,经常会碰到这样的需求:系统中需要使用一些通用的配置信息,例如机器列表信息、运行时的开关配置、数据库配置信息等。这些全局配置信息通常具备以下3个特性:
- 数据量通常比较小。
- 数据内容在运行时会发生动态变化。
- 集群中各机器共享,配置一致。
对于这类配置信息,一般的做法通常可以选择将其存储在本地配置文件或是内存变量中,当我们需要对配置信息进行更新的时候,只要在相应的配置文件中进行修改,等到系统再次读取这些配置文件的时候,就可以读取到最新的配置信息,并更新到系统中去。但是,一旦机器规模变大,并且配置信息变更越来越频繁后,使用这种方式解决配置管理就变得越来越困难了,因此必须寻求一种更为分布式化的解决方案。
6.1.2 案例:数据库切换
配置存储
在进行配置管理之前,首先我们需要将初始化配置存储到 Zookeeper 上去。一般情况下,我们可以在Zookeeper上选取一个数据节点用于配置的存储,例如 /app1/database_config (以下简称“配置节点”),如图所示。
我们将需要集中管理的配置信息写入到该节点中去,例如:
jdbc.driver=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/mybatis?useUnicode=true&characterEncoding=gbk
jdbc.username=root
jdbc.password=123456
配置获取
集群中每台机器在启动初始化阶段,首先会从上面提到的Zookeeper配置节点上读取数据库信息,同时,客户端还需要在该配置节点上注册一个数据变更的 Watcher监听,一旦发生节点数据变更,所有订阅的客户端都能够获取到数据变更通知。
配置变更
在系统运行过程中,可能会出现需要进行数据库切换的情况,这个时候就需要进行配置变更。借助Zookeeper,我们只需要对Zookeeper上配置节点的内容进行更新,Zookeeper就能够帮我们将数据变更的通知发送到各个客户端,每个客户端在接收到这个变更通知后,就可以重新进行最新数据的获取。
6.2 分布式日志收集系统
6.2.1 概述
分布式日志收集系统的核心工作就是收集分布在不同机器上的系统日志。
在一个分布式日志系统中,有两种角色:
- 日志源机器:需要被收集日志的机器;
- 收集器机器:用于收集日志;
所有日志源机器会被分为多个组别,每个组对应一个收集器。
6.2.2 背景
变化的日志源机器:在生产环境中,每个应用的机器几乎每天都是在变化的,比如机器硬件问题、扩容、机房迁移或者是网络问题等都会导致一个应用的机器变化,也就是说每个组中的日志源机器通常都是在不断变化的;
变化的收集器机器:日志收集系统自身也会有机器的变更或者扩容,于是会出现新的收集器机器加入或者是老的收集器退出的情况。
6.2.3 Zookeeper 日志收集器分配
以上的问题都可以归结为一点:如何快速、合理、动态地为每个收集器分配对应的日志源机器,是日志收集过程中最大的技术挑战之一。在这种情况下,引入 Zookeeper 是个不错的选择。
注册收集器
使用 Zookeeper 来进行日志系统收集器的注册,典型做法是在 Zookeeper 上创建一个节点作为收集器的根节点,例如 /logs/collector (下文我们以“收集器节点”代表该数据节点),每个收集器在启动的时候,都会在收集器根节点下创建自己的节点,例如/logs/collector/[Hostname],如图所示。
任务分发
待所有收集器机器都创建好自己对应的节点后,系统根据收集器节点下子节点的个数,将所有日志源机器分成对应的若干组,然后将分组后的机器列表分别写到这些收集器机器创建的子节点(例如/logs/collector/host1)上去,收集器节点上会存放所有已经分配给该收集器机器的日志源机器列表。这样一来,每个收集器机器都能够从自己对应的收集器节点上获取日志源机器列表,进而开始进行日志收集工作。
状态汇报
完成收集器的注册以及任务分发后,我们还要考虑到这些机器随时都有挂掉的可能。因此,针对这个问题,我们需要有一个收集器的状态汇报机制:每个收集器机器在创建完自己的专属节点后,还需要在对应的子节点上创建一个状态子节点,例如 /logs/collector/host1/status,每个收集器都需要定期向该节点写入自己的状态信息。我们可以把这种策略看作是一种心跳检测机制,通常收集器都会在这个节点中写入日志收集进度信息。日志系统根据该状态子节点的最后更新时间来判断对应的收集器是否存活。
动态分配
如果收集器机器挂掉或是扩容了,就需要动态地进行收集任务的重新分配。在运行过程中,日志系统始终关注着 /logs/collector 这个节点下所有子节点的变更,一旦检测到有收集器机器停止汇报或是有新的收集器机器加入,就要开始进行任务的重新分配。无论是针对收集器机器停止汇报还是新机器加入的情况,日志系统都需要将之前分配给该收集器的所有任务进行转移。为了解决这个问题,通常有两种做法。
- 全局动态分配
这是一种简单粗暴的做法,在出现收集器机器挂掉或是新机器加入的时候,日志系统需要根据新的收集器机器列表,立即对所有的日志源机器重新进行一次分组,然后将其分配给剩下的收集器机器。
- 局部动态分配
全局动态分配方式虽然策略简单,但是存在一个问题:一个或部分收集器机器的变更,就会导致全局动态任务的分配,影响面比较大,因此风险也就比较大。
所谓局部动态分配,顾名思义就是在小范围内进行任务的动态分配。在这种策略中,每个收集器机器在汇报自己日志收集状态的同时,也会把自己的负载汇报上去。请注意,这里提到的负载并不仅仅只是简单地指机器CPU负载(Load),而是一个对当前收集器任务执行的综合评估,这个评估算法和Zookeeper本身并没有太大的关系,这里不再赘述。
在这种策略中,如果一个收集器机器挂了,那么日志系统就会把之前分配给这个机器的任务重新分配到那些负载较低的机器上去。同样,如果有新的收集器机器加入,会从那些负载高的机器上转移部分任务给这个新加入的机器。
6.3 分布式锁
6.3.1 基于数据库实现分布式锁
要实现分布式锁,最简单的方式就是创建一张锁表,然后通过操作该表中的数据来实现。
当我们要锁住某个资源时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。数据库对共享资源做了唯一性约束,如果有多个请求被同时提交到数据库的话,数据库会保证只有一个操作可以成功,操作成功的那个线程就获得了访问共享资源的锁,可以进行操作。
基于数据库实现的分布式锁,是最容易理解的。但是,因为数据库需要落到硬盘上,频繁读取数据库会导致 IO 开销大,因此这种分布式锁适用于并发量低,对性能要求低的场景。对于双 11、双 12 等需求量激增的场景,数据库锁是无法满足其性能要求的。而在平日的购物中,我们可以在局部场景中使用数据库锁实现对资源的互斥访问。
优缺点
可以看出,基于数据库实现分布式锁比较简单,绝招在于创建一张锁表,为申请者在锁表里建立一条记录,记录建立成功则获得锁,消除记录则释放锁。该方法依赖于数据库,主要有两个缺点:
- 单点故障问题。一旦数据库不可用,会导致整个系统崩溃。
- 死锁问题。数据库锁没有失效时间,未获得锁的进程只能一直等待已获得锁的进程主动释放锁。一旦已获得锁的进程挂掉或者解锁操作失败,会导致锁记录一直存在数据库中,其他进程无法获得锁。
6.3.2 基于缓存实现分布式锁
数据库的性能限制了业务的并发量,那么对于双 11、双 12 等需求量激增的场景是否有解决方法呢?
基于缓存实现分布式锁的方式,非常适合解决这种场景下的问题。所谓基于缓存,也就是说把数据存放在计算机内存中,不需要写入磁盘,减少了 IO 读写。接下来,我以 Redis 为例与你展开这部分内容。
Redis 通常可以使用 setnx(key, value) 函数来实现分布式锁。key 和 value 就是基于缓存的分布式锁的两个属性,其中 key 表示锁 id,value = currentTime + timeOut,表示当前时间 + 超时时间。也就是说,某个进程获得 key 这把锁后,如果在 value 的时间内未释放锁,系统就会主动释放锁。
setnx 函数的返回值有 0 和 1:
- 返回 1,说明该服务器获得锁,setnx 将 key 对应的 value 设置为当前时间 + 锁的有效时间。
- 返回 0,说明其他服务器已经获得了锁,进程不能进入临界区。该服务器可以不断尝试 setnx 操作,以获得锁。
总结来说,Redis 通过队列来维持进程访问共享资源的先后顺序。Redis 锁主要基于 setnx 函数实现分布式锁,当进程通过 setnx<key,value> 函数返回 1 时,表示已经获得锁。排在后面的进程只能等待前面的进程主动释放锁,或者等到时间超时才能获得锁。
优缺点
相对于基于数据库实现分布式锁的方案来说,基于缓存实现的分布式锁的优势表现在以下几个方面:
- 性能更好。数据被存放在内存,而不是磁盘,避免了频繁的 IO 操作。
- 很多缓存可以跨集群部署,避免了单点故障问题。
- 很多缓存服务都提供了可以用来实现分布式锁的方法,比如 Redis 的 setnx 方法等。
- 可以直接设置超时时间来控制锁的释放,因为这些缓存服务器一般支持自动删除过期数据。
这个方案的不足是,通过超时时间来控制锁的失效时间,并不是十分靠谱,因为一个进程执行时间可能比较长,或受系统进程做内存回收等影响,导致时间超时,从而不正确地释放了锁。
6.3.3 基于 ZooKeeper 实现分布式锁
定义锁
ZooKeeper 通过一个数据节点来表示一个锁,类似于“/shared_lock/[Hostname]-请求类型-序号” 的临时顺序节点,例如 /shared_lock/192.168.0.1-R-0000000001,这个节点就代表了一个锁,如图所示。
获取锁
在需要获取锁时,所有客户端都会到/shared_lock这个节点下面创建一个临时顺序节点:
-
如果当前是读请求,那么就创建如 /shared_lock/192.168.0.1-R-0000000001 的节点;
-
如果当前是写请求,那么就创建如 /shared_lock/192.168.0.1-W-0000000001 的节点。
判断读写顺序
由于不同的事务都可以同时对同一个数据对象进行读取操作,而更新操作必须在当前没有任何事务进行读写操作的情况下进行。基于这个原则,大致可以分为如下4个步骤:
-
创建完节点后,获取/shared_lock节点下的所有子节点,并对该节点注册所有子节点变更的 Watcher 监听。
-
确定自己的节点序号在所有子节点中的顺序。
-
对于读请求:
-
如果没有比自己序号小的子节点,或是所有比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了读锁,同时开始执行读取逻辑。
-
如果比自己序号小的子节点中有写请求,那么就需要进入等待。
对于写请求:
-
如果自己不是序号最小的子节点,那么就需要进入等待。
-
-
接收到 Watcher 通知后,重复步骤1。
释放锁
/exclusive_lock/lock 是一个临时节点,在以下两种情况下可能释放锁:
- 当前获取锁的客户端机器发生宕机,那么ZooKeeper 上的这个临时节点就会被移除。
- 正常执行完业务逻辑后,客户端就会主动将自己创建的临时节点删除。
无论在什么情况下移除了lock节点,ZooKeeper 都会通知所有在 /exclusive_lock 节点上注册了子节点变更Watcher 监听的客户端。这些客户端在接收到通知后,再次重新发起分布式锁获取,即重复“获取锁”过程。
羊群效应
上面讲解的这个锁实现,大体上能够满足一般的分布式集群竞争锁的需求,并且性能都还可以——这里说的一般场景是指集群规模不是特别大,一般是在10台机器以内。但是如果机器规模扩大之后,会有什么问题呢?我们着重来看上面“判断读写顺序”过程的步骤3,结合下图给出的实例,看看实际运行中的情况。
针对图中的实际情况,我们看看会发生什么事情。
- 192.168.0.1 这台机器首先进行读操作,完成读操作后将节点/192.168.0.1- R-0000000001删除。
- 余下的4台机器均收到了这个节点被移除的通知,然后重新从/shared_lock节点上获取一份新的子节点列表。
- 每个机器判断自己的读写顺序。其中192.168.0.2这台机器检测到自己已经是序号最小的机器了,于是开始进行写操作,而余下的其他机器发现没有轮到自己进行读取或更新操作,于是继续等待。
- 继续……
很明显,我们看到,192.168.0.1这个客户端在移除自己的共享锁后,ZooKeeper 发送了子节点变更Watcher通知给所有机器,然而这个通知除了给192.168.0.2这台机器产生实际影响外,对于余下的其他所有机器都没有任何作用。
在这整个分布式锁的竞争过程中,大量的“Watcher通知”和“子节点列表获取”两个操作重复运行,并且绝大多数的运行结果都是判断出自己并非是序号最小的节点,从而继续等待下一次通知——这个看起来显然不怎么科学。
客户端无端地接收到过多和自己并不相关的事件通知,如果在集群规模比较大的情况下,不仅会对ZooKeeper服务器造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个节点对应的客户端完成事务或是事务中断引起节点消失,ZooKeeper服务器就会在短时间内向其余客户端发送大量的事件通知——这就是所谓的羊群效应。
上面这个ZooKeeper分布式共享锁实现中出现羊群效应的根源在于,没有找准客户端真正的关注点。我们再来回顾一下上面的分布式锁竞争过程,它的核心逻辑在于:判断自己是否是所有子节点中序号最小的。于是,很容易可以联想到,每个节点对应的客户端只需要关注比自己序号小的那个相关节点的变更情况就可以了——而不需要关注全局的子列表变更情况。
改进后的分布式锁实现
现在我们来看看如何改进上面的分布式锁实现。首先,我们需要肯定的一点是,上面提到的锁实现,从整体思路上来说完全正确。这里主要的改动在于:每个锁竞争者,只需要关注 /shared_lock 节点下序号比自己小的那个节点是否存在即可,具体实现如下。
-
客户端调用 create() 方法创建一个类似于“/shared_lock/[Hostname]-请求类型-序号”的临时顺序节点。
-
客户端调用 getChildren() 接口来获取所有已经创建的子节点列表,注意,这里不注册任何Watcher。
-
如果无法获取共享锁,那么就调用exist()来对比自己小的那个节点注册Watcher。注意,这里“比自己小的节点”只是一个笼统的说法,具体对于读请求和写请求不一样。
-
读请求:向比自己序号小的最后一个写请求节点注册 Watcher 监听。
-
写请求:向比自己序号小的最后一个节点注册 Watcher 监听。
-
-
等待Watcher通知,继续进入步骤2。
注意
看到这里,相信很多读者都会觉得改进后的分布式锁实现相对来说比较麻烦。确实如此,如同在多线程并发编程实践中,我们会去尽量缩小锁的范围——对于分布式锁实现的改进其实也是同样的思路。那么对于开发人员来说,是否必须按照改进后的思路来设计实现自己的分布式锁呢?答案是否定的。在具体的实际开发过程中,我们提倡根据具体的业务场景和集群规模来选择适合自己的分布式锁实现:在集群规模不大、网络资源丰富的情况下,第一种分布式锁实现方式是简单实用的选择;而如果集群规模达到一定程度,并且希望能够精细化地控制分布式锁机制,那么不妨试试改进版的分布式锁实现。
优缺点
可以看到,使用 ZooKeeper 可以完美解决设计分布式锁时遇到的各种问题,比如单点故障、不可重入、死锁等问题。虽然 ZooKeeper 实现的分布式锁,几乎能涵盖所有分布式锁的特性,且易于实现,但需要频繁地添加和删除节点,所以性能不如基于缓存实现的分布式锁。 Zookeeper实现的分布式锁在中小型公司的普及率不高,尤其是非 Java 技术栈的公司使用的较少,如果只是为了实现分布式锁而重新搭建一套 ZooKeeper 集群,显然实现成本和维护成本太高。
6.3.4 三种实现方式对比
7. Zookeeper 的选举算法与数据一致性
Zookeeper使用了一种称为Zookeeper Atomic Broadcast (ZAB,Zookeeper原子消息广播协议)的协议作为其数据一致性的核心算法。ZAB是一种强一致性协议,从CAP定理的角度来说,ZAB属于CP协议。
ZAB 协议的核心内容
ZAB 协议的核心是定义了对于那些会改变Zookeeper服务器数据状态的事务请求的处理方式,即:
所有事务请求必须由一个全局唯一的服务器来协调处理,这样的服务器被称为 Leader 服务器,而余下的其他服务器则成为 Follower 服务器。Leader 服务器负责将一个客户端事务请求转换成一个事务 Proposal(提议),并将该Proposal 分发给集群中所有的 Follower 服务器。之后 Leader 服务器需要等待所有 Follower 服务器的反馈,一旦超过半数的 Follower 服务器进行了正确的反馈后,那么 Leader 就会再次向所有的 Follower 服务器分发Commit 消息,要求其将前一个 Proposal 进行提交。
ZAB协议分为下面三个阶段:
- 发现(Discover阶段):即选举Leader过程。
- 同步阶段:即选举完成后,Follower或者Observer同步最新Leader的最新数据。
- 广播阶段:当同步完成之后,接收客户端的请求,广播给所有的服务器,实现数据在集群中的多副本存储。
7.1 Zookeeper 的选举过程
在 Zookeeper 的选举过程中,主要有下面四种状态,由org.apache.zookeeper.server.quorum. ServerState这个枚举类维护这四种状态,源码如下:
public enum ServerState {
LOOKING, FOLLOWING, LEADING, OBSERVING;
}
- Looking:选举Leader过程
- Following:跟随状态,表明当前节点是Follower
- Leader:领导状态,表明当前节点是Leader
- Observing:观察者状态,表明当前节点是Oberver
Zookeeper 主要有三种选举算法 LeaderElection,FastLeaderElection,AuthFastLeaderElection。在Zookeeper3.4.5 之后,默认的使用 FastLeaderElection 选举算法。这里只讨论 FastLeaderElection 选举算法。
FastLeaderElection 选举算法
在了解其算法之前先理解以下词的意思:
- zxid:64位,高32位代表主进程周期epoch,每选举一次,则主进程周期加1,低32在选举完成之后,表示事务单调递增的计数器,从0开始。
- myid:每个server的id
它基于TCP协议进行通信,为了两个节点之间重复的建立TCP连接,ZK会按照myid小的节点去连接myid大的节点,比如myid为1的节点向myid为2的节点发起tcp连接。
在配置时,我们会发现需要配置两个端口号,第一个端口号2888,是通信和数据同步的端口号,第二个端口号3888,是进行选举的端口号。
该算法的实现在 Zookeeper 的源码的FastLeaderElection类中可以查看到。
算法描述
其算法描述如下:
server在启动或者恢复加入集群中时(此时没有leader,服务器处于looking状态),每个server都会选举自己为leader,然后server会发送自己的(server id,zxid)到其他server中,这里会先比较epoch,即zxid的高32位,然后比较server id的大小,具体比较规则如下:
- 如果接收到的epoch大于自己的epoch,则将刷新自己的epoch,更新为最大的epoch,然后将该消息广播到所有server中
- 如果接收的epoch小于自己的epoch,则将自己的epoch发送给对方
- 如果接收到的epoch和自己的相等,则比较server id,id值大的胜出,广播该消息。
接着所有的server根据广播的消息做出选举,即使没有所有的server的消息,只要有半数以上的server支持某个server,那么该server就会称为leader,接着会进行同步操作,选举结束。
举例
举例1:一个集群三个节点启动,myid分别是1,2,3。每个节点都没有数据。
- 节点1:启动,zxid为0,myid为1,读取自己的zxid和myid。
- 节点2:启动,zxid为0,myid为2,读取自己的zxid和myid并且进行广播,此时节点1和节 点2的zxid都为0,比较myid,此时节点2的myid大,所有节点1支持节点2,节点2支持自己,因此节点2胜出,成为leader,节点1称为follower。
- 节点3:此时集群中已有leader,加入集群中,成为follower。
举例2:三台server,server2为leader,此时server2宕机,server1和server3成为looking状态
- server1发起投票,选举自己成为leader,给自己投一票,然后广播自己的消息(server id:1 , zxid:100)
- server3发起投票,给自己投一票,广播自己的消息(server id :3 , zxid:102)。
- 此时server1收到server3的消息,server3的zxid大于自己的,改变自己的投票给server3。
- server3收到server1的消息,zxid小于自己的,因此不改变自己的投票。
- 统计投票结果,server3胜出,成为leader,server1成为follower。
提问:为什么要求半数以上的server投票支持才能成为leader?
这个问题很少有人能说出其具体原因(至少我碰到的人中几乎都不知道),要说原因,我们都知道zookeeper是一主多备的集群架构,如果得不到半数以上的server的选举,当集群发生脑裂时,可能会产生多个主节点,这样不符合ZK的设计初衷,数据的一致性也得不到保证,(好比一个人不能同时接收到两个大脑的控制,那不是乱了套了?),比如,有7台server,server1-server4和server5~server7之间发生脑裂,那么各自选举出leader,那么集群中就会出现两个Leader。而要求半数以上的投票结果则防止了这种现象的发生。
7.2 Zookeeper 的同步过程
Leader端
Leader 需要告知其他服务器当前的最新数据,即最大zxid是什么,此时leader会构建 一个NEWLEADER的数据包,包括当前最大的zxid,发送给follower或者observer,此时leader会启动一个leanerHandler的线程来处理所有follower的同步请求,同时阻塞主线程,只有半数以上的folower同步完毕之后,leader才成为真正的leader,退出选举同步过程。
Follower端
首先与leader建立连接,如果连接超时失败,则重新进入选举状态选举leader,如果连接成功,则会将自己的最新zxid封装为FOLLOWERINFO发送给leader
同步算法:
- 直接差异化同步(DIFF同步)
- 仅回滚同步,即删除多余的事务日志(TRUNC)
- 先回滚再差异化同步(TRUNC+DIFF)
- 全量同步(SNAP同步)
差异化同步(DIFF)
条件:MinCommitedLog < peerLastZxid < MaxCommitedLog
举例:leader的未 proposal 的队列中有0X50001,0X50002,0X50003,0X50004,0X50005,此时follower的peerLastZxid为0X50003,因此需要使用差异化同步将0X50004和0X50005同步给follower。同步顺序如下:
0X50004 -> Proposal -> Commit
0X50005 -> Proposal -> Commit
TRUNC+DIFF同步
假设此时leaderB发送proposal并且提交了0X50001,0X50002,但没有提交0X50003,但是没有发送commit命令宕机,如果server C成为leader,经过同步后其自大MaxCommitedLog为0X60002,此时server B重新加入集群,由于Leader C中没有proposal 0X50003的提交记录,因此,发送TRUNC回滚数据,回滚完成之后,C向B发送确认消息,确认当前B的最新zxid为0X50002,然后发送DIFF进行差异化同步,此时B发送ACK给C,接着C会差异化同步相应的Proposal,然后提交,接着通知B,B在同步完成之后会发送确认ACK消息给C,同步结束。
全量同步(SNAP)
使用与当一个节点宕机太久,中间已经生成了大量的文件,此时集群的MinCommitedLog比宕机节点的最大zxid还要大,此时需要进行全量同步。
首先leader会发送SNAP命令给follower,follower接收到命令后进入同步阶段,leader会将所有的数据全量发送给follower,follower处理完毕之后leader还会将同步期间发生变化的数据增量发送给follower进行同步。
8. Zookeeper 与 Nacos 的区别
Zookeeper
CP设计(强一致性),目标是一个分布式的协调系统,用于进行资源的统一管理。
当节点crash后,需要进行leader的选举,在这个期间内,Zookeeper服务是不可用的。
Nacos
AP设计(高可用),目标是一个服务注册发现系统,专门用于微服务的服务发现注册。
nacos 各个节点都是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务。而 nacos 的客户端在向某个 nacos 注册时如果发现连接失败,会自动切换至其他节点,只要有一台 nacos 还在,就能保证注册服务可用(保证可用性),只不过查到的信息可能不是最新的(不保证强一致性)。
参考文章
《从 Paxos 到 Zookeeper 分布式一致性原理与实践》,作者: 倪超 (本文绝大部分参考此书籍)
共识、线性一致性与顺序一致性 - SegmentFault 思否
《极客时间 - 分布式技术原理与算法解析》 讲师: 聂鹏程 章节:分布式锁:关键重地,非请勿入