ZooKeeper论文阅读笔记

ZooKeeper论文传送门

介绍

ZooKeeper 是一个开源的分布式协调服务,它提供了高可用性和一致性的数据管理和协调功能。它被设计用于构建可靠的分布式系统,并提供了一组简单而强大的 wait-free 原语,使开发人员能够处理分布式应用中的共享配置、命名服务、分布式锁、分布式队列等问题。

在论文中,客户端表示使用 Zookeeper 服务的应用,服务端表示一个运行 Zookeeper 程序的程序,znode 表示内存中的数据节点(Zookeeper 使用树形结构作为存储结构,整体就像一个文件系统)。

Zookeeper服务概述

Snipaste_2023-05-28_22-12-05.jpg

znode 有两种类型:

  • 常规:数据对象正常创建和删除
  • 临时:创建对象的 session 终止之后,对象会被删除

znode 默认最大存储 1MB 数据,这是可配置的。

在创建文件的时候设置 SEQUENTIAL 标志,那么会在文件名后增加一个自动增加的计数器。比如上图创建 /app1 节点时设置了 SEQUENTIAL 标志,那么连续创建三个 /app1/p_ 节点时结果如上图所示。ZooKeeper 实现了观测(watch)机制,能够在数据对象更新后通知客户端(由与客户端连接的服务端通知),观测只会触发一次,如果想继续监测需要重新设置 watch。

ZooKeeper 以库的形式向客户端提供 API,类似于 RPC 中的 client stub,库也负责客户端到 ZooKeeper 服务器的连接,每个连接即是一个 session,它有一个超时时间 s,如果在超时时间内没有收到客户端请求,那么服务器认为客户端出故障,客户端即便没有读写请求也要发送心跳避免超时。为了防止会话超时,ZooKeeper 客户端库在会话空闲 \(\frac{s}{3}\) 后发送心跳,如果在 \(\frac{2s}{3}\) 内没有收到服务器的消息,则切换到新服务器,
其中 s 是以毫秒为单位的会话超时

客户端API

  • create(path, data, flags):创建一个路径为path的 znode,将data[]保存到其中,返回新 znode 的名称,flags用来设置 znode 类型:普通或者临时,以及设置SEQUENTIAL标志。
  • delete(path, version):如果版本匹配,删除path对应的 znode。
  • exists(path, watch):如果path对应的 znode 存在,那么返回真,否则返回假。watch标志让客户端观测这个 znode。
  • getData(path, watch):返回 znode 对应的数据和元数据,watch功能类似。
  • setData(path, data, version):如果版本匹配,将data[]写入到path对应的 znode 中。
  • getChildren(path, watch):返回 znode 的子节点集合。
  • sync(path):等待目前所有未决的更新,path没什么用。

以上全部的方法提供了阻塞版本和非阻塞版本,如果传入版本号为-1,那么不进行版本检查。

Zookeeper保证

Zookeeper 给出了两个保证:

  • 线性写入:所有改变 ZooKeeper 状态的更新都是串行的
  • 客户端先进先出:所有来自客户端的请求按照先进先出顺序执行

Zookeeper 并不完全满足线性一致性,它只支持写请求的线性一致性,以及保证一个客户端的读操作一定可以读取到该客户端最后一个写操作的数据或者在这个写操作之后的其它客户端的写入数据,这是通过为每个写操作分配一个 zxid 来实现,zxid 是单调递增的,写请求的响应包含该写请求对应的 zxid,服务器将该写请求修改的 znode 的最近修改信息更新为该zxid(这句话是我的猜测),之后客户端发出读请求要求服务器读取的 znode 的 zxid 不小于客户端最近一次读/写请求返回的 zxid,如果该服务器在一定时间内还不能满足该要求,那么客户端会连接其它的服务器获取数据。

ZooKeeper 服务包含一组使用复制来实现高可用性和性能的服务器。ZooKeeper 使用简单的流水线架构实现,该架构允许 ZooKeeper 处理成百上千个未完成的请求,同时仍能实现低延迟。 这样的管道自然能够以 FIFO 顺序从单个客户端执行操作。 保证 FIFO 客户端顺序使客户端能够异步提交操作。 通过异步操作,客户端可以同时进行多个未完成的操作。

为了满足线性写入,Zookeeper 实现了一个 leader-based 的原子广播协议 ZAB,该协议改良自 Paxos 协议。

对于写入请求,如果与客户端连接的服务器是 Follower 节点,那么它会把请求转发给 Leader 服务器,如果是同步写,那么当写请求应用到超过半数服务器时返回响应给客户端,如果是异步写,那么立即返回成功响应给客户端。

对于读操作,不用转发给 Leader,直接返回服务器本地存储的数据即可。这样可能造成返回的数据不是最新的,但这是 ZooKeeper 可以容忍的。如果客户端想要最新的数据,那么它可以先发送一个 Sync 请求,该请求可以看作是一个空的写请求,由于 Zookeeper 的两个保证,在执行完 Sync 后再执行读操作就可以读取到所有在 Sync 之前执行的写请求数据了。

现在举个例子演示这两个保证如何保障系统运行。假设一个系统选举主节点管理其他节点,主节点随后需要更新一些配置,然后通知其他节点,要求:

  • 主节点在修改配置过程,不希望其他节点访问正在被修改的配置
  • 主节点在更新完成前崩溃,不希望其他节点访问这些破碎的配置

可以设置一个readyznode解决,主节点可以在配置前删除,完成后重新建立。当其他节点看到ready不存在时就不读取配置。

但是还会存在问题:如果其他节点看到ready后读取配置,但是主节点随即删除ready节点并开始修改配置,那么其他节点将得到过时的配置。这个问题可以采用 watch 机制来解决,ready删除后会及时通知其他节点,这样客户端就会放弃读取的数据,然后重新设置 watch,当配置文件更新完毕后通知客户端发送读取请求。

原语示例:

  • 配置管理:只需要将配置保存在一个 znode 中,各个进程可以通过 watch 机制来获取配置更新通知。

  • 组成员关系:组成员进程上线之后可以在组对应的 znode 之下创建对应的临时子 znode,成员进程退出之后临时znode也被删除,因此可以通过组 znode 的子znode获取组成员状态。

  • 简单锁:应用试图创建同一个文件,如果 znode 创建成功,那么获取锁。如果 znode 已经存在,那么需要等待锁被释放(znode被删除)后才能继续尝试获取锁(创建znode)。

  • 无惊群效应的简单锁:简单锁会出现大量进程竞争的情况,因为每次锁被释放后所有等待的进程都会被通知唤醒,但最终只有一个进程可以获取锁,可以将锁请求排序后,按次序分配锁,实现公平锁,如下面的算法所示:

    Lock  (l是 znode 路径)
    1   n = create(l + “/lock-”, EPHEMERAL|SEQUENTIAL)
    2   C = getChildren(l, false)
    3   if n is lowest znode in C, exit
    4   p = znode in C ordered just before n
    5   if exists(p, true) wait for watch event
    6   goto 2
    
    Unlock
    1   delete(n)
    
  • 读写锁:写锁和普通锁类似,和其他的锁互斥。读锁之间可以互相兼容,和写锁互斥。

    Write Lock
    1   n = create(l + “/write-”, EPHEMERAL|SEQUENTIAL)
    2   C = getChildren(l, false)
    3   if n is lowest znode in C, exit
    4   p = znode in C ordered just before n
    5   if exists(p, true) wait for event
    6   goto 2
    
    Read Lock
    1   n = create(l + “/read-”, EPHEMERAL|SEQUENTIAL)
    2   C = getChildren(l, false)
    3   if no write znodes lower than n in C, exit
    4   p = write znode in C ordered just before n
    5   if exists(p, true) wait for event
    6   goto 3
    
  • 双栅栏:双栅栏用来保证多个客户端的计算同时开始和同时结束。客户端开始计算之前添加 znode 到栅栏对应的 znode 之下,结束计算之后删除 znode。客户端需要等待栅栏 znode 的子 znode 数量到达一定阈值后才能开始计算,客户端可以等待一个特殊的ready的 znode 的创建,当数量到达阈值后创建。客户端退出的时候需要等待子 znode 全部被删除,同样可以通过删除ready删除。

Zookeeper实现

ZooKeeper 的组件如下图所示,ZooKeeper 的数据副本保存在每一个服务器的内存中,写操作需要通过一致性协议提交到数据库,而读取请求可以直接访问服务器本地数据库获得。ZooKeeper 在写请求修改到数据库之前会先将其写入日志(WAL)中,并在适当的时机持久化当前数据块的快照,故障后采用快照加日志的方式进行恢复。根据一致协议,写入请求会转发到领导(leader)节点。

Snipaste_2023-05-28_23-10-46.jpg

请求处理器

请求处理器收到写入请求之后,会将其转换为事务,与客户端发送的请求不同,事务是幂等的。 当领导者收到写入请求时,它会计算应用写入时系统的状态,并将其转换为捕获此新状态的事务。 必须计算未来状态,因为可能有尚未应用到数据库的未完成事务。 例如,如果客户端执行条件 setData 并且请求中的版本号与正在更新的 znode 的未来版本号相匹配,则该服务会生成一个包含新数据、新版本号和更新时间戳的 setDataTXN。 如果发生错误,例如版本号不匹配或要更新的 znode 不存在,则会生成 errorTXN。

原子广播(Atomic Broadcast)

所有更新 ZooKeeper 状态的请求都被转发给领导者。 领导者执行请求并通过原子广播协议 Zab 广播 ZooKeeper 的状态变化。 接收到客户端请求的服务器在接收到相应的状态变化时响应客户端。 Zab 默认使用 majority quorums 来决定提案,因此 Zab 和 ZooKeeper 只能在大多数服务器正确的情况下工作。Zab 保证领导者广播的更改按照发送的顺序进行交付,并且在广播自己的更改之前,先前领导者的所有更改都已交付给现在的领导者

ZAB 使用 TCP 协议传输数据,而 TCP 协议可以保证传输的数据按照发送顺序到达。

在正常操作期间,Zab 确实按顺序传递所有消息,并且只传递一次,但由于 Zab 不会持久记录传递的每条消息的 ID,因此 Zab 可能会在恢复期间重新传递消息。 因为使用的是幂等事务,多次投递是可以接受的,只要按顺序投递即可。 事实上,ZooKeeper 要求 Zab 至少重新投递上次快照开始后投递的所有消息

多副本数据库

当服务器故障后,使用周期性的快照和快照之后的日志恢复。创建快照的时候并不需要锁定数据库,相反,Zookeeper 通过优先深度搜索遍历 data-tree 中znode 里的数据并持久化到磁盘中,由于生成的快照可能应用了快照生成期间交付的状态更改的某些子集,因此结果可能与 ZooKeeper 在任何时间点的状态都不对应。 但是,由于状态更改是幂等的,只要我们按顺序应用状态更改,我们就可以应用它们两次。

例如,假设现在有两个 znode 节点分别为 /foo 和 /goo,这两个节点分别存储了数据 f1 和 g1,且版本号都为 1。此时开始生成快照,然后按序执行以下三个操作,操作序列含义为<transactionType,path,value,new-version>,此时快照检测点的位置不包含下面的操作序列。

  • <SetFDataTrx,/foo,f2,2>
  • <SetFDataTrx,/goo,g2,2>
  • <SetFDataTrx,/foo,f3,3>

处理完这些状态更改后,/foo 和 /goo 的值分别为版本 3 和版本 2 的 f3 和 g2。 然而,快照可能记录了 /foo 和 /goo 分别具有版本 3 和 1 的值 f3 和 g1,这不是 ZooKeeper 数据树的有效状态。 但如果服务器崩溃并使用此快照恢复,然后 Zab执行检查点后的日志重新提交上面的三个操纵,则生成的状态可以正确对应崩溃前的服务状态。

参考博客:https://juejin.cn/post/6844903891146915848

posted @ 2023-05-29 00:03  のNice  阅读(33)  评论(0编辑  收藏  举报