zookeeper 的精要设计

747 阅读13分钟

集群概览

集群角色分为 Leader、Follower、Observer

Leader

Leader 负责处理事务请求和非事务请求、保证事务请求处理的顺序性

事务请求:增删改数据节点以及创建会话

Follower

Follower 负责处理非事务请求、Leader 转发过来的事务请求并且在处理完毕之后分别响应 proposal ack 和 commit ack

Follower 还会参与集群宕机 leader 选举

当 Follower 收到事务请求后会将请求转发给 Leader 节点进行处理

Observer

Observer 负责处理非事务请求,当收到事务请求后会将请求转发给 Leader 节点进行处理

Observer 不参与 Leader proposal 消息过半写入 commit 过程,也不参与集群宕机故障恢复选举过程

Observer 的作用仅仅是用来提升系统读的吞吐量

集群启动

创建关键组件

解析 zoo.cfg 为 QuorumPeerConfig

启动一个线程定时清理日志文件和快照文件的组件 DatadirCleanupManager

创建一个用户处理客户端请求的组件 NIOServerCnxnFactory

创建一个用于管理磁盘的组件 FileTxnSnapLog

创建一个内存数据库 ZKDatabase 树形挂载节点

节点启动

创建关键组件完成之后调用 QuorumPeer#start() 启动一个节点,QuorumPeer 就代表了一个 zookeeper 节点

调用 ZKDatabase#zkDb.loadDataBase() 基于快照文件还原内存数据库,然后基于日志文件将快照文件之后创建的数据同步到内存中

启动 NIOServerCnxnFactory 组件可以准备跟客户端进行通信

开启 leader 选举

leader 选举过程

zookeper 会创建一个 QuorumCnxManager 组件负责创建根其它 zookeeper 的长连接,同时会为每个连接创建一个 SendWorker、RecWorker 分别用于发送和接收选票

zookeeper 的集群规模最小是2台,在服务器启动时候的选举过程

  1. 每个 server 都发出选票:每个服务器在启动的时候会读取本地的最大事务ID zxid 和 myid 然后发起选票(myid,zxid),一开始都会先投递给自己
服务器1:(1, 0) 投递给自己,然后再投递给服务器 2
服务器2:(2, 0) 投递给自己,然后再投递给服务器 1
  1. 接收各个服务器的选票:服务器收到对方选票信息,进行 PK 规则如下,谁优先就投递给谁

(1)zxid 大的优先

(2)myid 大的优先(myid 作为 zookeeper 的唯一标识肯定是不一样的)

服务器1:收到选票 (2,0) pk 后更新本地投票为 (2,0),然后将本次 pk 结果广播出去
服务器2:收到选票 (1,0) pk 后发现本地选票优先级更高不用更新,然后将本次 pk 结果广播出去
  1. 统计选票:每次投票完成之后检查本地是否收到了过半机器相同的选票信息,比如服务器 1 和 2 最终的选票归档都为 (2,0) (2,0) 那么服务器 2 就会作为 Leader

如果 leader 宕机了,服务器会启动故障恢复模式,此时 zookeeper 集群不可用,follower 进入选举模式处于 following 状态,依然会按照上述规则进行选举,只不过此时的 zxid 就不是 0 了而是当前节点同步的最新的 zxid

每个节点基于归档的选票信息就能确定自身是 Leader 还是 follower

leader 和 follower 各自启动运行

启动 Leader

创建一个 Leader 组件代表了这个 Leader

在 Leader 中创建 LearnerCnxAcceptor 组件用于处理 follower 的连接建立请求,当 follower 发起与 Leader 的连接之后,LearnerCnxAcceptor 会取出当前 socket 绑定到 LearnerHandler 线程组件去处理,每个 follower 维护一个 LearnerHandler 用于读写数据

同时 Leader 会将 epoch 加 1,这样 zxid 的高 32 位就是最新一个纪元,同时将低 32 位置 0

后续当 Leader 收到事务请求后都需要通过 LearnerHandler 组件将数据发送给 follower

启动 Follower

创建一个 Follower 组件用于接收来自于 leader 的事务请求,首先 Follower 启动后需要基于本地的 zxid 去请求 Leader 同步最新的事务消息

基于当前的 zxid 去 leader 的 commited log(一个 lru cache 同步记录最近的一部分增量变更的事务日志,在启动后也会记录最新事务日志数据和快照数据的差异最新值)如果在 commitd log 中招到了直接进行同步,如果找不到的话会将创建一个磁盘快照然后发送给 follower 然后再基于 committed log 进行同步(有点类似 redis slave 同步 master 的味道)

选举过程源码图解

集群是如何启动的 (2).jpg

创建会话

zookeeper 服务端需要为每一个连接的客户端创建一个 session 用于唯一标识当前这次会话

之所以要创建 session 原因是临时节点等机制是跟当前会话绑定的,当会话消失的时候临时节点也需要被删除

同时创建 session 通过存储在 leader 中保证当客户端连接的 zookeeper 宕机后,能重新选择另外一个 zookeeper 建立连接基于客户端中的 sessionId 还是能找到会话信息,不至于临时节点等被删除

创建过程如下

1. 协商 sessionTimeout

基于客户端指定的超时时间用服务端限制的超时时间 2 - 20 个 tickTime 之间做限制如果 sessionTimeout 大于 20 * tickTime 那么 sessionTimeout = 20 * tickTime(tickTime 默认为 2S)

2. 为客户端生成一个全局递增的 sessionId

mysql 会根据算法保证在每一台 zookeeper 启动的时候根据当前时间戳、当前 zookeper 的 myid来生成一个全局唯一的 sessionId 然后在每一次创建 session 的时候给方法增加 synchronized 表示,每调用一次 sessionId++ 即可

3. 创建 session

session 只会在 Leader 节点通过 SessionTrackerImpl 创建

当 Follower 收到创建会话请求之后,会通过 LearnerSessionTracker 创建一个 sessionId 随后将请求转发给 Leader 节点

SessionTrackerImpl 会提供增删改查 session 的方法

创建一个 session 主要有如下的一些关键属性

(1)sessionWithTimeout: 存储 sessionId 和 sessionTimeout(设置的超时时间)

(2)sessionById: 存储 sessionId、SessionImpl

(3)sessionSets: 存储 tickTime(计算出来的下一次超时的具体时间点 currentTime + sessionTimeout)、SessionSet(在这一刻有多少 session 会超时)

客户端每一次 crud 动作都会刷新 session 主要是就是更新几个数据结构的内容,移除 sessionSets 中的数据,重新计算 tickTime 然后放入

SessionTrackerImpl 会有定时任务检查 session 是否过期,当检查到过期之后会是否当前 session 端口长连接,同步发送断开 session 请求到其它 zookeeper 节点

只会在 leader 节点维护集群所有的 session 并且定时检查是否过期

follower 如何处理创建会话

Leader 和 Follower 处理事务请求都是通过责任链模式来进行处理

Follower 处理事务请求的链条为 FollowerRequestProcessor -> CommitProcessor -> FinalRequestProcessor

每个 processor 都是一个线程通过一个队列来实现解耦

在 FollowerRequestProcessor 中将创建会话请求提交到 CommitProcessor 在 CommitProcessor 中就开始等待收到 leader 过半 ack 才会调用 FinalRequestProcessor 响应给客户端创建会话成功

由于每个 processor 都是通过线程执行的,所以当 FollowerRequestProcessor 提交请求到 CommitProcessor 的任务队列之后,就将请求通过 Follower 组件发送给 Leader 进行处理

当 Leader 收到请求之后,又会将事务请求同步发送到各个 follower 节点,当 leader 收到过半 follower ack 后会响应给之前请求的 follower 节点成功

follower 收到响应后请求转交给 FinalRequestProcessor 维护当前连接,组装响应信息返回给客户端

leader 如何处理创建会话

leader 处理事务请求的链条为 PrepRequestProcessor->ProposalRequestProcessor->CommitProcessor->Leader.ToBeAppliedRequestProcessor->FinalRequestProcessor

请求先由 PrepRequestProcessor 进行处理,在这里会通过 sessionTracker 创建 session 原理为(步骤 3 创建 session 说明了 leader 创建 session 的方式)

session 创建完毕之后,转交给 ProposalRequestProcessor 处理,会将请求提交到 CommitProcessor 中挂起等待等待这个请求在 follower 中处理完成,由于是异步调用随后 ProposalRequestProcessor 会调用 SyncRequestProcessor(这个 Processor 是跟 ProposalRequestProcessor 绑定在一起的没有在处理链条中画出来)

ProposalRequestProcessor 非常的关键

(1)首先会顺序写入一条事务日志(如果一个文件日志量过大会创建一个新的文件)

(2)检查如果已经写入了日志数量达到了阈值(默认 5W - 10W 之间是一个随机值)则会创建一个线程去持久化一个快照数据,快照数据创建完毕之后之前写入的事务日志就可以清除了

(3)通过 Leader 组件将事务请求转发给所有的 follower 组件,并且将当前请求加入未决协议队 map 中(没有收到的过半 ack 的请求)

(4)每当写入的事务日志数量达到了 1000 条任务队列里面没有请求之后就刷刷新数据到磁盘中

在 CommitProcessor 中会等待接收到 follower 响应后才会继续处理下一步

下面又会涉及到 follower 和 leader 之间的请求同步

Leader 为每个 Follower 创建一个 LearnerHandler 负责与其通信,当 LearnerHandler 收到客户端的 ack 请求之后会进行归档统计,如果检测到已经收到了过半 ack ,那么就会向所有的 follower 发送 commit 请求(加入对应follower的发送队列就返回是一个异步操作),随后就将请求加入 committedRequest 队列中,同时唤醒阻塞的 CommitProcessor

这里还会通知所有的 observer 接收消息,即 observer 只有在 follower 过半 ack 后 Leader 才会通知它可以接收消息了,Observer 不参与消息的过半 ack

CommitProcessor 最后会调用到 FinalRequestProcessor 中进行处理

FinalRequestProcessor

(1)刷新 session 过期时间(静态源码看的,不能 100% 确定)

(2)将 committed 事务日志加入 committedLog 这个 cache 中,并且满了的话就从头部开始移除

(3)组装响应然后通过 NIOServerCnxn 进行发送数据(NIOServerCnxn 就是一个对应一个客户端的 socket)将数据放入 outgoingBuffers 中并且关注 OP_WRITE 事件开始发送数据

默认的实现机制是 nio 情况下 zookeeper 采用单 reactor 模式,即建立连接,连接读写都注册到一个 selector 中由一个线程处理,因为每一层的 processor 调用都是通过队列实现的异步调用,所以在处理客户端请求没有阻塞依然性能很高(虽然在 commit processor 会阻塞)

写请求

写请求和创建 session 一样是一个事务请求,他们的处理方式都类似

leader 可以直接处理创建事务请求

follower 收到事务请求后会通过 processor 链条发送到 leader 并且等待 leader 响应成功

leader 收到请求后也会通过 processor 链条进行处理,跟创建 session 区别是

(1)不会创建和刷新 session 只会检查 session 是否有效(session 的刷新是由 ping 心跳进行刷新的如果我静态源码没有看错的话)

(2)在 FinalProcessor 中处理的时候会创建一个节点信息在内存中(session 不会创建)

主要的区别就是这 2 点

其它事务请求大都类似就不一一说明

读请求

Leader 和 Follower 都可以负责处理读请求

请求都会进行前置 session 检查最终都会到达 FinalProcessor 中进行处理,就是根据 path 去查询内存数据库,返回对应的节点数据信息

zab 协议

zab 协议的核心是定义了那些会改变 zookeeper 服务器数据状态的事务请求的处理方式

zab 协议包括了两种基本模式

(1)崩溃恢复模式

(2)消息广播模式

崩溃恢复模式的选举过程在 leader 选举中已经说明了

消息广播模式在事务请求也已经说明了,再简单总结一下

(1)Leader 接收事务请求,Follower 收到的事务请求会转发给 Leader

(2)通过 processor 开始链式处理,检查 session 有效后顺序写入事务日志(当日志写入一定量后会创建一个线程持久化为快照),然后将请求转发给所有的 follower(每个 follower 有一个队列按照顺序处理保证顺序一致性,follower 中的请求也是按照入队的优先级进行处理),开始等待 follower 的 ack

(3)当收到过半 follower 的 ack 后,通知 follower commit、通过 observer 写入事务消息

(4)将数据写入到内存树中,组装响应数据响应回客户端

watcher 的工作原理

添加 watcher

客户端通过 getData 等传入 watcher

服务端在 FinnalRequestProcessor 中处理有如下数据结构

Map<String, HashSet<Watcher>> watchTable

维护每一个 path 对应了哪些 watcher,此处如果当前 path 已经有了监听器列表那么直接添加进去,如果没有那么久创建一个集合再添加进去

触发 watcher

每当执行增删改的时候在 FinalRequestProcessor 阶段就会取出 watchTable 对应 path 下的所有 watcher 遍历执行,同时移除这些 watcher

所有说 watcher 都是一次性的,触发后就会移除

高性能

创建一个 selector,采用单 reactor 模式

ServerSocketChannel 注册到 selector 关注 OP_ACCEPT 事件

当 OP_ACCEPT 准备就绪后从内核全连接队列中取出 socket 将其包装到 NioServerCnxn 再次将其注册到 selector 中关注 OP_READ 事件

当 OP_READ 事件准备就绪后通过 processor 链条进行处理,最终到达 finalProcessor 中

FinalProcessor 组装响应数据写入到对应的 NioServerCnxn 的发送队列缓冲区中,并且将当前 socket 注册一个 OP_WRITE 事件到 selector 中

然后 reactor 线程感知到 selector 中的 OP_WRITE 后调用对应的 NioServerCnxn 进行发送数据

高性能的几个关键点

(1)网络 reactor 模式

(2)事务日志顺序写入 Page Cache 并且是批量刷入磁盘,不会有过于频繁的 IO

(3)过半 folllower 响应 ack 后就返回成功

由于 zookeeper 所有的事务请求都是由 Leader 执行的所以存在单点性能瓶颈,不能支持过高的写入请求,但是可以挂载 Follower 和 Observer 来提升读的 QPS

Follower 会参与过半决策,集群不适合加入太多 Follower 否则会导致延迟过高,当需要提升读 QPS 的时候可以增加 Observer 节点

高可靠

zookeeper 是个 CP 系统,不保证可用性 A

为了保证数据一致性当 Leader 宕机后基于 zab 的崩溃恢复模式选举出来新的 leader

当 follower 宕机之后,只要集群满足大于 2 台机器的情况下就能继续工作