最近在准备面试,看到简章中 的要求,熟悉zookeeper
,扪心自问一下,发现对 zookeeper 的了解知之甚少,只知道Zookeeper 可以被用作注册中心。 Zookeeper 是 Hadoop 生态系统的一员;Zookeeper 集群有高性能和高容错性,以及简单对 CAP 的引申,然而对于 zookeeper 更多的了解就不知道了。
在说介绍 zookeeper 之前,先说下他的核心 Zab 协议
Zookeeper 的 Zab 协议是为了解决分布式一致性而设计出的一种协议,它的全称是 Zookeeper 原子广播协议,它能够在发生崩溃时快速恢复服务,达到高可用性。
客户端在使用 Zookeeper 服务时会随机连接到集群中的一个节点,所有的读请求都会由当前节点处理,而写请求会被路由给主节点并由主节点向其他节点广播事务,与 2PC 非常相似,如果在所有的节点中超过一半都返回成功,那么当前写请求就会被提交。
当主节点崩溃时,其他的 Replica 节点会进入崩溃恢复模式并重新进行选举,Zab 协议必须确保提交已经被 Leader 提交的事务提案,同时舍弃被跳过的提案,这也就是说当前集群中最新 ZXID 最大的服务器会被选举成为 Leader 节点;但是在正式对外提供服务之前,新的 Leader 也需要先与 Follower 中的数据进行同步,确保所有节点拥有完全相同的提案列表。
在上面提到 ZXID 其实就是 Zab 协议中设计的事务编号,它是一个 64 位的整数,其中最低的 32 位是一个计数器,每当客户端修改 Zookeeper 集群状态时,Leader 都会以当前 ZXID 值作为提案的编号创建一个新的事务,在这之后会将当前计数器加一;ZXID 中高的 32 位表示当前 Leader 的任期,每当发生崩溃进入恢复模式,集群的 Leader 重新选举之后都会将 epoch 加一。
ZooKeeper 是一个典型的分布式数据一致性解决方案,分布式应用程序可以基于 ZooKeeper 实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。
高可用,以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。
高吞吐量和低延迟,ZooKeeper 将数据保存在内存中(但是内存限制了能够存储的容量不太大,单节点最多 1M)。
高性能, 在“读”多于“写”的应用程序中尤其地高性能,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景。)
ZooKeeper 底层其实只提供了两个功能:①管理(存储、读取)用户程序提交的数据;②为用户程序提交数据节点监听服务。
每当客户端与服务端建立连接时,其实创建了一个新的会话,在每一个会话的生命周期中,Zookeeper 会在不同的会话状态之间进行切换,比如说:CONNECTING
、CONNECTED
、RECONNECTING
、RECONNECTED
和 CLOSE
等
每一个 Session 都包含四个基本属性,会话的唯一 ID、会话超时时间、下次会话的超时时间点和表示会话是否被关闭的标记。
sessionTimeout
值用来设置一个客户端会话的超时时间,只要在sessionTimeout
规定的时间内,断开连接的客户端能够重新连接上集群中任意一台服务器,那么之前创建的会话仍然有效。Zookeeper 中使用文件系统组织系统中存储的资源。
Zookeeper将所有数据存储在内存中,数据模型是一棵树(Znode Tree),由斜杠(/)的进行分割的路径,就是一个Znode, Znode 既能作为容器存储数据,也可以持有其他的 Znode 形成父子关系。
Znode 其实有 PERSISTENT
、PERSISTENT_SEQUENTIAL
、**EPHEMERAL
**和 **EPHEMERAL_SEQUENTIAL
**四种类型,它们是临时与持久、顺序与非顺序两个不同的方向组合成的四种类型。
Znode可以分为持久节点和临时节点两类。
Zookeeper 的每个 ZNode 上都会存储数据,对应于每个ZNode,Zookeeper 都会为其维护一个叫作 Stat 的数据结构,Stat中记录了这个 ZNode 的三个数据版本,分别是**version
(当前ZNode的版本)、cversion
**(当前ZNode子节点的版本)和 cversion
(当前ZNode的ACL版本)。
Zookeeper允许用户在指定节点上注册一些Watcher,并且在一些特定事件触发的时候,ZooKeeper服务端会将事件通知到感兴趣的客户端上去,该机制是Zookeeper实现分布式协调服务的重要特性。
Zookeeper采用ACL(AccessControlLists)策略来进行权限控制,类似于 UNIX 文件系统的权限控制。Zookeeper 定义了如下5种权限。
注意:CREATE和DELETE这两种权限都是针对子节点的权限控制。
作为分布式协调服务,Zookeeper 能够为集群提供分布式一致性的保证,我们可以通过 Zookeeper 提供的最基本的 API:
public class Zookeeper {
public String create(final String path, byte data[], List<ACL> acl, CreateMode createMode)
public void delete(final String path, int version) throws InterruptedException, KeeperException
public Stat exists(final String path, Watcher watcher) throws KeeperException, InterruptedException
public byte[] getData(final String path, Watcher watcher, Stat stat) throws KeeperException, InterruptedException
public Stat setData(final String path, byte data[], int version) throws KeeperException, InterruptedException
public void sync(final String path, VoidCallback cb, Object ctx)
}
在这一节中,我们将介绍如何在生产环境中使用 Zookeeper 实现发布订阅、命名服务、分布式协调以及分布式锁等功能。
通过 Zookeeper 进行数据的发布与订阅其实可以说是它提供的最基本功能,它能够允许多个客户端同时订阅某一个节点的变更并在变更发生时执行我们预先设置好的回调函数,在运行时改变服务的配置和行为:
ZooKeeper zk = new ZooKeeper("localhost", 3000, null);
zk.getData("/config", new Watcher() {
public void process(WatchedEvent watchedEvent) {
System.out.println(watchedEvent.toString());
}
}, null);
zk.setData("/config", "draven".getBytes(), 0);
发布与订阅的使用非常的简单,可以在 getData
中传入实现 process
方法的 Watcher
对象,在每次改变节点的状态时,process
方法都会被调用,在这个方法中就可以对变更进行响应动态修改一些行为。
通过 Zookeeper 这个中枢,每一个客户端对节点状态的改变都能够推送给节点的订阅者,在发布订阅模型中,Zookeeper 的每一个节点都可以被理解成一个主题,每一个客户端都可以向这个主题推送详细,同时也可以订阅这个主题中的消息;只是 Zookeeper 引入了文件系统的父子层级的概念将发布订阅功能实现得更加复杂。
public static enum EventType {
None(-1),
NodeCreated(1),
NodeDeleted(2),
NodeDataChanged(3),
NodeChildrenChanged(4);
}
如果我们订阅了一个节点的变更信息,那么该节点的子节点出现数量变更时就会调用 process
方法通知观察者,这也意味着更复杂的实现,同时和专门做发布订阅的中间件相比也没有性能优势,在海量推送的应用场景下,消息队列更能胜任,而 Zookeeper 更适合做一些类似服务配置的动态下发的工作。
Zookeeper 能帮助分布式系统实现命名服务,在每一个分布式系统中,客户端应用都有根据指定名字获取资源、服务器地址的需求,在这时就要求整个集群中的全部服务有着唯一的名字。
在大型分布式系统中,有两件事情非常常见,一是不同服务之间的可能拥有相同的名字,另一个是同一个服务可能会在集群中部署很多的节点,Zookeeper 就可以通过文件系统和顺序节点解决这两个问题。
在上图中,我们创建了两个命名空间,/infrastructure
和 /business
分别代表架构和业务部门,两个部门中都拥有名为 metrics
的服务,而业务部门的 metrics
服务也部署了两个节点,在这里使用了命名空间和顺序节点解决唯一标志符的问题。
ZooKeeper zk = new ZooKeeper("localhost", 3000, null);
zk.create("/metrics", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL);
zk.create("/metrics", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL);
List children = zk.getChildren("/", null);
System.out.println(children);
// [metrics0000000001, metrics0000000002]
使用上面的代码就能在 Zookeeper 中创建两个带序号的 metrics
节点,分别是 metrics0000000001
和 metrics0000000002
,也就是说 Zookeeper 帮助我们保证了节点的唯一性,让我们能通过唯一的 ID 查找到对应服务的地址等信息。
Zookeeper 的另一个作用就是担任分布式事务中的协调者角色,在这篇介绍 分布式事务 的文章中介绍了分布式事务本质上都是通过 2PC 来实现的,在两阶段提交中就需要一个协调者负责协调分布式事务的执行。
ZooKeeper zk = new ZooKeeper("localhost", 3000, null);
String path = zk.create("/transfer/tx", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL);
List ops = Arrays.asList(
Op.create(path + "/cohort", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL),
Op.create(path + "/cohort", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL),
Op.create(path + "/cohort", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL)
);
zk.multi(ops);
当前节点作为协调者在每次发起分布式事务时都会创建一个 /transfer/tx
的持久顺序节点,然后为几个事务的参与者创建几个空白的节点,事务的参与者在收到事务时会向这些空白的节点中写入信息并监听这些节点中的内容。
所有的事务参与者会向当前节点中写入提交或者终止,一旦当前的节点改变了事务的状态,其他节点就会得到通知,如果出现一个写入终止的节点,所有的节点就会回滚对分布式事务进行回滚。
使用 Zookeeper 实现强一致性的分布式事务其实还是一件比较困难的事情,一方面是因为强一致性的分布式事务本身就有一定的复杂性,另一方面就是 Zookeeper 为了给客户端提供更多的自由,对外暴露的都是比较基础的 API,对它们进行组装实现复杂的分布式事务还是比较麻烦的,对于如何使用 Zookeeper 实现分布式事务,我们可以在 ZooKeeper Recipes and Solutions 一文中找到更为详细的内容。
在数据库中,锁的概念其实是非常重要的,常见的关系型数据库就会对排他锁和共享锁进行支持,而 Zookeeper 提供的 API 也可以让我们非常简单的实现分布式锁。
ZooKeeper zk = new ZooKeeper("localhost", 3000, null);
final String resource = "/resource";
final String lockNumber = zk
.create("/resource/lock-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
List<String> locks = zk.getChildren(resource, false, null);
Collections.sort(locks);
if (locks.get(0).equals(lockNumber.replace("/resource/", ""))) {
System.out.println("Acquire Lock");
zk.delete(lockNumber, 0);
} else {
zk.getChildren(resource, new Watcher() {
public void process(WatchedEvent watchedEvent) {
try {
ZooKeeper zk = new ZooKeeper("localhost", 3000, null);
List locks = zk.getChildren(resource, null, null);
Collections.sort(locks);
if (locks.get(0).equals(lockNumber.replace("/resource/", ""))) {
System.out.println("Acquire Lock");
zk.delete(lockNumber, 0);
}
} catch (Exception e) {
}
}
}, null);
}
如果多个服务同时要对某个资源进行修改,就可以使用上述的代码来实现分布式锁,假设集群中存在一个资源 /resource
,几个服务需要通过分布式锁保证资源只能同时被一个节点使用,我们可以用创建临时顺序节点的方式实现分布式锁;当我们创建临时节点后,通过 getChildren
获取当前等待锁的全部节点,如果当前节点是所有节点中序号最小的就得到了当前资源的使用权限,在对资源进行处理后,就可以通过删除 /resource/lock-00000000x
来释放锁,如果当前节点不是最小值,就会注册一个 Watcher
等待 /resource
子节点的变化直到当前节点的序列号成为最小值。
上述代码在集群中争夺同一资源的服务器特别多的情况下会出现羊群效应,每次子节点改变时都会通知当前节点,造成资源的浪费,我们其实可以将 getChildren
换成 getData
,让当前节点只监听前一个节点的删除事件:
Integer number = Integer.parseInt(lockNumber.replace("/resource/lock-", "")) - 1;
String previousLock = "/resource/lock-" + String.format("%010d", number);
zk.getData(previousLock, new Watcher() {
public void process(WatchedEvent watchedEvent) {
try {
if (watchedEvent.getType() == Event.EventType.NodeDeleted) {
System.out.println("Acquire Lock");
ZooKeeper zk = new ZooKeeper("localhost", 3000, null);
zk.delete(lockNumber, 0);
}
} catch (Exception e) {
}
}
}, null);
在新的分布式锁实现中,我们减少了每一个服务需要关注的事情,只让它们监听需要关心的数据变更,减少 Zookeeper 发送不必要的通知影响效率。
分布式锁作为分布式系统中比较重要的一个工具,确实有着比较多的应用,同时也有非常多的实现方式,除了 Zookeeper 之外,其他服务例如 Redis 和 etcd 也能够实现分布式锁,为分布式系统的构建提供支持,不过在这篇文章中就不展开介绍了。
Zookeeper 的核心是 Zab 协议(Zookeeper 原子广播协议)
集群模式的 Zookeeper 实现了高可用
基于内存存储实现高性能,高吞吐量,低延迟
客户端与服务端连接时,会创建一个会话,会话的状态有CONNECTING
、CONNECTED
、RECONNECTING
、RECONNECTED
和 CLOSE
会话(session)包含四个基本属性,会话的唯一 ID、会话超时时间、下次会话的超时时间点和表示会话是否被关闭的标记。
Zookeeper将所有数据存储在内存中,数据模型是一棵树(Znode Tree),由斜杠(/)的进行分割的路径,就是一个Znode
Znode 既能作为容器存储数据,也可以持有其他的 Znode 形成父子关系。
Znode可以分为持久节点和临时节点两类。
生产环境中可以使用 Zookeeper 实现发布订阅、命名服务、分布式协调以及分布式锁等功能
可能是全网把 ZooKeeper 概念讲的最清楚的一篇文章
详解分布式协调服务 ZooKeeper