zookeeper 使用场景
ZooKeeper 是一个高可用的分布式数据管理与协调框架。基于对 ZAB 算法的实现,该框架能够很好地保证分布式环境中数据的一致性。
ZAB 算法、Paxos 算法、2PC、3PC、一致性hash算法 等
ZooKeeper 可以保证以下几点分布式一致性的特性:
顺序一致性
从同一个客户端发起的事务请求,最终将会严格地按照其发起顺序被应用到 ZooKeeper 中去。
原子性
所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的。
单一视图
无论客户端连接哪个 ZooKeeper 服务器,看到的服务端数据模型都是一致的。
可靠性
一旦服务端成功地应用了一个事务,并完成对客户端的响应,那么该事务所引起的服务端状态变更将会被一直保留下来,除非有另一个事务又对其进行了变更。
实时性
在一定的时间段内,客户端最终一定能够从服务器上读到最新的数据状态。
应用场景:
数据发布/订阅
数据发布/订阅( Publish / subscribc )系统,即所谓的配置中心,顾名思义就是发布者将数据发布到 ZooKeeper 的一个或一系列节点上,供订阅者进行数据订阅,进而达到动态获取数据的目的,实现配置信息的集中式管理和数据的动态更新。
利用特性:zookeeper会维护一个目录结点树,每个节点znode可以被监控,包括监控某个目录中存储的数据变化,子目录节点的变化,一旦变化可以通知设置监控的客户端。
方式一: 将配置信息存放到 ZooKeeper 上进行集中管理,那么通常情况下,应用在启动的时候都会主动到 ZooKecper 服务端上进行一次配置信息的获取,同时,在指定节点上注册一个 watcher 监听,这样一来,但凡配置信息发生变更,服务端都会实时通知到所有订阅的客户端,从而达到实时获取最新配置信息的目的。
方式二: 将配置信息存放在数据库,那么通常情况下,应用在启动的时候都会主动查询数据库进行一次配置信息的获取,同时,在指定节点上注册一个 watcher 监听,当配置信息发生变更的时候,同时变更指定节点数据,服务端就会实时通知到所有订阅的客户端,然后客户端再去查询数据库,从而达到实时获取最新配置信息的目的。
负载均衡
这个我觉得和配置管理比较类似,就是把域名配置放在一个特定的节点上,各个server 去这个节点获取域名解析配置,当配置信息变动的时候,去通知各个server再次获取新的配置。
命名服务
ZooKeeper 提供的命名服务功能与 JNDI 技术有相似的地方,都能够帮助应用系统通过一个资源引用的方式来实现对资源的定位与使用。另外,广义上命名服务的资源定位都不是正真意义的实体资源——在分布式环境中,上层应用仅仅需要一个全局唯一的名字,类似于数据库中的唯一主键,也就是唯一ID。
在过去的单库单表型系统中,通常可以使用数据库字段自带的 auto_increment 属性来自动为每条数据库记录生成一个唯一的ID,数据库会保证生成的这个 ID 在全局唯一。但是随着数据库规模的不断增大,分库分表随之出现,而自增长属性仅能针对单一表中的记录自动生成 ID ,因此就需要寻找一种能够在分布式环境下生成全局唯一 ID 的方法。
一说起全局唯一 ID,都会想到 UUID,UUID 是通用标识符码(Universally Unique Identifier)的简称,是一种在分布式系统环境中广泛使用的用于唯一标示元素的标准,最典型的实现是 GUID (Globally Unique Identifier)全局唯一标识符。
UUID 是一个非常不错的全局唯一 ID 生成方式,能够非常简便地保证分布式环境中的唯一性。可,也有那么几个缺点:
长度过长
UUID 最大的问题就在于生成的字符串过长。显然,和数据库中的INT 类型相比,存储一个 UUID 需要花费更多的空间。
语义不明
开发人员从字面上基本看不出任何其表达的含义,将会大大影响问题排查和开发调试的效率。
下面来看如何使用 ZooKeeper 来实现这类全局唯一 ID 的生成。
利用特性:通过调用 ZooKeeper 节点创建的 API 接口可以创建一个顺序节点,并且在 API 返回值中会返回这个节点的完整名字。在ZooKeeper 中,每一个数据节点都能够维护一份子节点的顺序序列,当客户端对其创建一个顺序子节点的时候,ZooKeeper 会自动以后缀的形式在其子节点上添加一个序号。
步骤:
所有客户端都会根据自己的任务类型,在指定类型的任务下面通过调用create()接口来创建一个顺序节点,例如创建“job-”节点。
节点创建完毕后,create() 接口会返回一个完整的节点名,例如“job-000002”。
客户端拿到这个返回值后,拼接上 type 类型,例如 “type5-job-000002”,这个就可以作为一个全局唯一的 ID 了。
分布式协调/通知
在绝大部分的分布式系统中,系统机器间的通信无外乎心跳检测、工作进度汇报和系统调度这三种类型。
利用特性
客户端如果对 ZooKeeper 的一个数据节点注册 Watcher 监听,那么当该数据节点的内容或是其子节点列表发生变更时,ZooKeeper 服务器就会向订阅的客户端发送变更通知。
对在 ZooKeeper 上创建的临时节点,一旦客户端与服务器之间的会话失效,那么该临时节点也就被自动清除。
心跳检测
传统开发中,通常是通过主机之间是否可以相互 ping 通来判断,更复杂一点的话,则会通过在机器之间建立长连接,通过 TCP 连接固有的心跳检测机制来实现上层机器的心跳检测。
基于 ZooKeeper 的临时节点特性,可以让不同的机器都在 ZooKeeper 的一个指定节点下创建临时子节点,不同的机器之间可以根据这个临时节点来判断对应的客户端机器是否存活。当某台机器宕机后那么它所创建的临时节点也随之消失,通过这种方式,检测系统和被检测系统之间并不需要直接相关联,而是通过 ZooKeeper 上的某个节点进行关联,大大减少了系统耦合。
工作进度报告
在 ZooKeeper 上选择一个节点,每个任务客户端都在这个节点下面创建临时子节点,这样便可以实现两个功能:
通过判断临时节点是否存在来确定任务机器是否存活;
各个任务机器会实时地将自己的任务执行进度写到这个临时节点上去,以便中心系统能够实时地获取到任务的执行进度。
系统调度
使用 ZooKeeper,能够实现另一种系统调度模式:一个分布式系统由控制台和一些客户端系统两部分组成,控制台的职责就是需要将一些指令信息发送给所有的客户端,以控制它们进行相应的业务逻辑。后台管理人员在控制台上做的一些操作,实际上就是修改了 ZooKeeper 上的某些节点的数据,而 ZooKeeper 进一步把这些数据变更以事件通知的形式发送给对应的订阅客户端。这个实际上就是上面说过的数据发布订阅的场景。
总之,使用 ZooKeeper 来实现分布式系统之间的通信,不仅能省去大量底层网络通信和协议设计上重复的工作,更为重要的一点是大大降低了系统之间的耦合,能够非常方便地实现异构系统之间的灵活通信。
集群管理
所谓集群管理,包括集群监控与集群控制两大块,前者侧重对集群运行时状态的收集,后者则是对集群进行操作与控制。在日常工作中,经常会有类似于如下的需求。
希望知道当前集群中究竟有多少机器在工作。
对集群中每台机器的运行时状态进行数据收集。
对集群中机器进行上下线操作。
这里,还是利用 ZooKeeper 的两大特性:
客户端如果对 ZooKeeper 的一个数据节点注册 Watcher 监听,那么当该数据节点的内容或是其子节点列表发生变更时,ZooKeeper 服务器就会向订阅的客户端发送变更通知。
对在 ZooKeeper 上创建的临时节点,一旦客户端与服务器之间的会话失效,那么该临时节点也就被自动清除。
然后就可以实现一种集群机器存活性监控的系统。例如,监控系统在 /clusterServers 节点下创建一个临时节点:/clusterServers/[Hostname]。这样一来,监控系统就能够实时监测到机器的变动情况,至于后续处理就是监控系统的业务了。下面就通过分布式日志收集系统和在线云主机管理这两个典型例子来看看如何使用 ZooKeeper 实现集群管理。
分布式日志收集系统
分布式日志收集系统的核心工作就是收集分布式在不同机器上的系统日志,下面来看分布式日志系统的收集器模块。
在一个典型的日志系统的架构设计中,整个日志系统会把所有需要收集的日志机器(日志机器源)分为多个组别,每个组别对应一个收集器,这个收集器其实就是一个后台机器(收集器机器),用于收集日志。对于大规模的分布式日志收集系统场景,通常需要解决如下两个问题。
变化的日志源机器
变化的收集器机器
上面两个问题,最终都归结为一点:如何快速、合理、动态地为每个收集器分配对应的日志源机器,这也成为了整个日志系统正确稳定运转的前提,也是日志收集过程中最大的技术挑战之一。
注册收集器机器
使用 ZooKeeper 来进行收集器的注册,先创建一个根节点,例如 /logs/collector,每个收集器在启动的时候,都会在收集器节点下创建自己的节点(临时节点),例如 /logs/collector/[hostname]。
任务分发
系统根据收集器节点下子节点的个数,将所有日志源机器分成对应的若干组,然后将分组后的机器列表分别写到这些收集器机器创建的子节点上去。这样,每个收集器都能够从自己对应的收集器节点上获取日志源机器列表,进而开始进行日志收集工作。
状态报告
针对这些机器随时挂掉的问题,还需要有一个收集器的状态汇报机制:每个收集器机器在创建完自己的专属节点后,还需要在对应的子节点上创建一个状态子节点,例如 /logs/collector/[hostname]/status,每个收集器机器都需要定期向该节点写入自己的状态信息。可以把这种策略看作是一种心跳监测机制,通常,收集器也应该在这个节点中写入日志收集进度信息。
动态分配
如果收集器挂掉或是扩容了,就需要动态地进行收集任务的分配了。
分布式锁
在平时的项目开发中,往往很少会去在意分布式锁,而是依赖于关系型数据库固有的排他性来实现不同进程之间的互斥。这是一种非常简便且被广泛使用的分布式锁实现方式。然而,目前大多数大型分布式系统的性能瓶颈都集中在数据库操作上。因此,如果上层业务再给数据库添加一些额外的锁,例如行锁、表锁甚至是繁重的事务处理,那么就会让数据库更加的不堪重负。有个原则,我们要让尽量少的操作落在数据库层面上,要尽量为数据库减负。下面说一下如何使用 ZooKeeper 实现分布式锁,这里主要说一下排它锁和共享锁两类分布式锁。
排他锁
排他锁(Exclusive Locks,简称 X 锁),又称为写锁或独占锁,是一种基本的锁类型。如果事务 T1 对数据对象 O1 加上了排他锁,那么在整个加锁期间,只允许事务 T1 对 O1 进行读取和更新操作,其他任何事务都不能再对这个数据对象进行任何类型的操作——直到 T1 释放了排他锁。
从上面排他锁的基本概念中,可以看到,排他锁的核心是如何保证当前有且仅有一个事务获得锁,并且锁被释放后,所有正在等待获取锁的事务都能够被通知到。在 ZooKeeper 中,并没有类似于 synchronized 或者 ReentrantLock 这样的 API 可以直接使用,可以通过 ZooKeeper 上的数据节点来表示一个锁,例如 /exclusive_lock/lock 节点就可以被定义为一个锁。
定义锁
首先,在 ZooKeeper 上创建一个根节点 /exclusive_lock。
获取锁
然后,在需要获取排他锁时,所有的客户端都会试图通过调用 create() 接口,在 /exclusive_lock 节点下创建临时子节点 /exclusive_lock/lock。ZooKeeper 会保证在所有的客户端中,最终只有一个客户端能够创建成功,那么就可以认为该客户端获取了锁。同时,所有没有获取到锁的客户端就需要到 /exclusive_lock 节点上注册一个子节点变更的 Watcher 监听,以便实时监听到 lock 节点的变更情况。
释放锁
由于 /exclusive_lock/lock 是一个临时节点,因此在以下两种情况下,都有可能释放锁。
当前获取锁的客户端机器发生宕机。
正常执行完业务逻辑后主动删除。
无论什么情况下移除了 lock 节点,ZooKeeper 都会通知所有在/exclusive_lock 上注册了子节点变更 Watcher 监听的客户端。这些客户端在接收到通知后,再次重新发起分布式锁获取,即重复“获取锁”过程。
共享锁
共享锁(Shared Locks,简称 S 锁),又称为读锁,同样是一种基本的锁类型。如果事务 T1 对数据对象 O1 加上了共享锁,那么当前事务只能对 O1 进行读取操作,其他事务也只能对这个数据对象加共享锁——直到该数据对象上的所有共享锁都被释放。
共享锁和排他锁最根本的区别在于,加上排他锁后,数据对象只对一个事务可见,而加上共享锁后,数据对所有事务都可见。
定义锁
和排他锁一样,同样是通过 ZooKeeper 上的数据节点来表示一个锁,是一个类似于“/shared_lock/[hostname]-请求类型-序号”的临时顺序节点,例如 /shared_lock/192.168.0.1-R-00000001,那么,这个节点就代表了一个共享锁。
获取锁
在需要获取共享锁时,所有客户端都会到/shared_lock 这个节点下面创建一个临时顺序节点,如果当前是读请求,那么就创建例如/shared_lock/192.168.0.1-R-00000001 的节点;如果是写请求,那么就创建例如 /shared_lock/192.168.0.1-W-00000001 的节点。
判断读写顺序
根据共享锁的定义,不同的事务都可以同时对同一个数据对象进行读操作,而更新操作必须在当前没有任何事务进行读写操作的情况下进行。基于这个原则,如何通过节点来确定读写顺序,大致分为如下的 4 个步骤。
创建完节点后,获取/shared_lock 节点下的所有子节点,并对该节点注册子节点变更的 Watcher 监听。
确定自己的节点序号在所有子节点中的顺序。
对于读请求:(可能会产生羊群效应)
如果没有比自己序号小的子节点,或者是所有比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑。如果比自己序号小的子节点中有写请求,那么就需要进入等待。
对于写请求:
如果自己不是序号最小的子节点,那么就需要进入等待。
接收到 Watcher 通知后,重复步骤 1。
释放锁
和排他锁的逻辑一样。
分布式队列
业界已经有了不少分布式队列产品,类似于 ActiveMQ、Kafka、RocketMQ等,大部分的场景我觉得还是应用这些为了MQ而生的工具比较好,但是 ZOOKeeper 也可以实现简单的 FIFO 队列功能。
FIFO 队列类似于一个全写的共享锁模型,大体的思路:所有客户端都会到 /queue 这个节点下面创建一个临时顺序节点,例如 /queue/192.168.0.1-000001,创建完节点之后,根据如下4个步骤来确定执行顺序。
通过调用 getChildren() 接口来获取 /queue 节点下的所有子节点,即获取队列中所有的元素。
确定自己的节点序号在所有子节点中的顺序。
如果自己不是序号最小的子节点,那么就进入等待,同时向比自己序号小的最后一个节点注册 Watcher 监听。
接收到 Watcher 通知后,重复步骤 1。
小结
可以看出,那么多场景,其实用到的特性并不多:
客户端如果对 ZooKeeper 的一个数据节点注册 Watcher 监听,那么当该数据节点的内容或是其子节点列表发生变更时,ZooKeeper 服务器就会向订阅的客户端发送变更通知。
对在 ZooKeeper 上创建的临时节点,一旦客户端与服务器之间的会话失效,那么该临时节点也就被自动清除。
通过调用 ZooKeeper 节点创建的 API 接口可以创建一个顺序节点,并且在 API 返回值中会返回这个节点的完整名字。在ZooKeeper 中,每一个数据节点都能够维护一份子节点的顺序序列,当客户端对其创建一个顺序子节点的时候,ZooKeeper 会自动以后缀的形式在其子节点上添加一个序号。