此文知识来自于:《从Paxos到Zookeeper分布式一致性原理与实践》第六章
随着分布式系统规模的日益扩大,集群中的机器规模也随之变大,因此,如何更好地进行集群管理也显得越来越重要了。
所谓集群管理,包括集群监控与集群控制两大块、前者侧重对集群运行时状态的收集,后者则是对集群进行操作与控制。在日常开发和运维过程中,我们经常会有
类似于如下的需求。
在传统的基于Agent的分布式集群管理体系中,都是通过在集群中的每台机器上部署一个Agent,由这个Agent负责主动向指定的一个监控中心系统(监控中心
系统负责将所有数据进行集中处理,形成一系列报表,并负责实时报警,以下简称“监控中心”)汇报自己所在机器的状态。在集群规模适中的场景下,这确实
是一种在生产实践中广泛使用的解决方案,能够快速有效地实现分布式环境集群监控,但是一旦系统的业务场景增多,集群规模变大,该解决方案的弊端也就显现出来了:
ZooKeeper具有以下两大特性:
利用ZooKeeper的这两大特性,就可以实现另一种集群机器存活性监控的系统。例如,监控系统在/clusterServers
节点上注册一个Watcher监听,
那么但凡进行动态添加机器的操作,就会在/clusterServers
节点下创建一个临时节点/clusterServers/[Hostname]
。这样一来监控系统就能够实时
检测到机器的变动情况,至于后续处理就是监控系统的业务了。下面我们就通过分布式日志收集系统和在线云主机管理这两个典型例子来看看如何使用ZooKeeper
实现集群管理。
分布式日志收集系统的核心工作就是收集分布在不同机器上的系统日志,在这里我们重点来看分布式日志系统的收集器模块。
在一个典型的日志系统的架构设计中,整个日志系统会把所有需要收集的日志机器(下文以“日志源机器”代表此类机器)分为多个组别,每个组别对应一个收集器,
这个收集器其实就是一个后台机器(下文以“收集器机器”代表此类机器),用于收集日志。对于大规模的分布式日志收集系统场景,通常需要解决如下两个问题。
上面两个问题,无论是日志源机器还是收集器机器的变更,最终都归结为一点:如何快速、合理、动态地为每个收集器分配对应的日志源机器,这也成为了整个
日志系统正确稳定运转的前提,也是日志收集过程中最大的技术挑战。在这种情况下,引入ZooKeeper是个不错的选择,下面我们来看ZooKeeper在这个
场景中的使用。
.1 注册收集器机器
使用ZooKeeper来进行日志系统收集器的注册、典型做法是在ZooKeeper上创建一个节点作为收集器的根节点,例如/logs/collector
(下文我们以“收集器
节点”代表该数据节点),每个收集器机器在启动的时候,都会在收集器节点下创建自己的节点,例如logs/collector/[Hostname]
。
.2 任务分发
待所有收集器机器都创建好自己对应的节点后,系统根据收集器节点下子节点的个数,将所有日志源机器分成对应的若干组,然后将分组后的机器列表分别写到
这些收集器机器创建的子节点(例如/logs/collector/host1
)上去。这样一来,每个收集器机器都能够从自己对应的收集器节点获取日志源机器列表,
进而开始进行日志收集工作。
.3 状态汇报
完成收集器机器的注册以及任务分发后,我们还要考虑到这些机器随时都有挂掉的可能。因此,针对这个问题,我们需要有一个收集器的状态汇报机制:
每个收集器机器在创建完自己的专属节点后,还需要在对应的子节点上创建一个状态子节点,例如/logs/collector/host1/status
,每个收集器都需要定期向
该节点写入自己的状态信息。我们可以把这种策略看作是一种检测机制,通常收集器机器都会在这个节点写入日志收集进度信息。日志系统根据该状态子节点的最后更新时间
来判断对应的收集器机器是否存活。
.4 动态分配
如果收集器机器挂掉或是扩容了,就需要动态地进行收集任务的分配。在运行过程中,日志系统始终关注着/logs/collector
这个节点下所有子节点的变更,
一旦检测到有收集器机器停止汇报或是有新的收集器机器加入,就要开始进行任务的重新分配。无论是针对收集器机器停止汇报还是新机器加入的情况,
日志系统都需要将之前分配给该收集器的所有任务转移。为了解决这个问题,通常有两种做法。
.4.1 全局动态分配
这是一种简单粗暴的做法,在出现收集器机器挂掉或是新机器加入的时候,日志系统需要根据新的收集器机器列表,立即对所有的日志源机器重新进行一次分组,
然后将其分配给剩下的收集器机器。
.4.2 局部动态分配
全局动态分配方式虽然策略简单,但是存在一个问题:一个或部分收集器机器的变更,就会导致全局动态任务的分配,影响面比较大,因此风险也就比较大。
所谓局部动态分配,顾名思义就是在小范围内进行任务的动态分配。在这种策略中,每个收集器机器在汇报自己日志收集状态的同时,也会把自己的负载汇报上去。
请注意,这里提到的负载并不仅仅只是简单地指机器CPU负载(Load),而是一个对当前收集器任务执行的综合评估。
在这种策略中,如果一个收集器机器挂了,那么日志系统就会把之前分配给这个机器的任务重新分配到那些负载较低的机器上去。同样,如果有新的收集器机器加入,
会从那些负载高的机器上转移部分任务给这个新加入的机器。
.5 注意事项
.5.1 节点类型
首先看/logs/collector
这个节点下面子节点的节点类型。这个节点下面的所有子节点都代表了每个收集器机器,那么初步认为这些子节点必须选择临时节点,
原因是日志系统可以根据这些临时节点来判断收集器机器的存活性。但是,同时还需要注意的一点是:在分布式日志收集这个场景中,收集器节点上还会存放所有
已经分配给该收集器机器的日志源机器列表,如果只是简单地依靠ZooKeeper自身的临时节点机制,那么当一个收集器挂掉或是当这个收集器机器中断“心跳汇报”
的时候,待该收集器节点的会话失效后,ZooKeeper就会立即删除该节点,于是,记录在该节点上的所有日志源机器列表也就随之被清除掉了。
从上面的描述中可以知道,临时节点显然无法满足这里的业务需求,所以我们选择了使用持久节点来标识每一个收集器机器,同时在这个持久节点下面分别创建/logs/collector/[Hostname]/status
节点来表征每一个收集器机器的状态。这样一来,既能实现日志系统对所有收集器的监控,同时在收集器机器挂掉
后,依然能够准确地将分配于其中的任务还原。
.5.2 日志系统节点监听
在实际生产运行过程中,每一个收集器机器更改自己状态节点的频率可能非常高(如每秒1次或更短),而且收集器的数量可能非常大,如果日志系统监听所有
这些节点变化,那么通知的消息量可能会非常大。另一方面,在收集器机器正常工作的情况下,日志系统没有必要去实时地接收每次节点状态变更,因此大部分
这些变更通知都是无用的。因此我们考虑放弃监听设置,而是采用日志系统主动轮询收集器节点的策略,这样就节省了不少网卡流量,唯一的缺陷就是有
一定的延时(考虑到分布式日志收集系统的定位,这个延时是可以接受的)。
在线云主机管理通常出现在那些虚拟主机提供商的应用场景中。在这类集群管理中,有很重要的一块就是集群机器的监控。这个场景通常对于集群中的机器状态,
尤其是机器在线率的统计有较高的要求,同时需要能够快速地对集群中机器的变更做出响应。
在传统的实现方案中,监控系统通过某种手段(比如检测主机的指定端口)来对每台机器进行定时检测,或者每台机器自己定时向监控系统汇报“我还活着”。
但是这种方式需要每个业务系统的开发人员自己来处理网络通信、协议设计、调度和容灾等诸多琐碎的问题。下面来看看使用ZooKeeper实现的另一种集群机器
存活性监控系统。针对这个系统,我们的需求点通常如下。
.1 机器上/下线
为了实现自动化的线上运维,我们必须对机器的上/下线情况有一个全局的监控。通常在新增机器的时候,需要首先将指定的Agent部署到这些机器上去。
Agent部署启动之后,会首先向ZooKeeper的指定节点进行注册,具体的做法就是在机器列表节点下面创建一个临时子节点,例如/XAE/machine/[Hostname]
(下文以“主机节点”代表这个节点),如下图:
当Agent在ZooKeeper上创建完这个临时子节点后,对/XAE/machines
节点关注的监控中心就会接收到“子节点变更”事件,即上线通知,于是就可以对这个
新加入的机器开启相应的后台管理逻辑。另一方面,监控中心同样可以获取到机器下线的通知,这样便实现了对机器上/下线的检测,同时能够很容易地获取
到在线的机器列表,对于大规模的扩容和容量评估都有很大的帮助。
.2 机器监控
对于一个在线云主机系统,不仅要对机器的在线状态进行检测,还需要对机器的运行时状态进行监控。在运行的过程中,Agent会定时将主机的运行状态信息
写入ZooKeeper上的主机节点,监控中心通过订阅这些节点的数据变更通知来间接地获取主机的运行时信息。
随着分布式系统规模变得越来越庞大,对集群机器的监控和管理显得越来越重要。上面提到的这种借助ZooKeeper来实现的方式,不仅能够实时地检测到集群
中机器的上/下线情况,而且能够实时地获取到主机的运行时信息,从而能够构建出一个大规模集群的主机图谱。
Master选举是一个在分布式系统中非常常见的应用场景。分布式最核心的特性就是能够将具有独立计算能力的系统单元部署在不同的机器上,构成一个完整的
分布式系统。而与此同时,实际场景中往往也需要在这些分布在不同机器上的独立系统单元中选出一个所谓的“老大”,在计算机科学中,我们称之为“Master”。
在分布式系统中,Master往往用来协调集群中其他系统单元,具有对分布式系统状态变更的决定权。例如,在一些读写分离的应用场景中,客户端的写请求往往
是由Master来处理的;而在另一些场景中,Master则常常负责处理一些复杂的逻辑,并将处理结果同步给集群中其它系统单元。Master选举可以说是ZooKeeper
最典型的应用场景了,在本节中,我们就结合“一种海量数据处理与共享模型”这个具体例子来看看ZooKeeper在集群Master选举中的应用场景。
在分布式环境中,经常会碰到这样的应用场景:集群中的所有系统单元需要对前端业务提供数据,比如一个商品ID,或者是一个网站轮播广告的广告ID(通常
出现在一些广告投放系统中)等,而这些商品ID或是广告ID往往需要从一系列的海量数据处理中计算得到————这通常是一个非常耗费I/O和CPU资源的过程。
鉴于该计算过程的复杂性,如果让集群中的所有机器都执行这个计算逻辑的话,那么将耗费非常多的资源。一种比较好的方法就是只让集群中的部分,甚至只
让其中的一台机器去处理数据计算,一旦计算出数据结果,就可以共享给整个集群中的其他所有客户端机器,这样可以大大减少重复劳动,提升性能。
这里我们以一个简单的广告投放系统后台场景为例来讲解这个模型。整个系统大体上可以分成客户端集群、分布式缓存系统、海量数据处理总线和ZooKeeper
四个部分,如下图:
Client集群每天定时会通过ZooKeeper来实现Master选举。选举产生Master客户端之后,这个Master就会负责进行一系列的海量数据处理,最终计算得到
一个数据结果,并将其放置在一个内存/数据库中。同时,Master还需要通知集群中其它所有的客户端从这个内存/数据库中共享计算结果。
接下去,我们将重点来看Master选举的过程,首先来明确下Master选举的需求:在集群的所有机器中选举出一台机器作为Master。针对这个需求,通常情况
下,我们可以选择常见的关系型数据库中的主键特性来实现:集群中的所有机器都向数据库中插入一条相同主键ID的记录,数据库会帮助我们自动进行主键冲突
检查,也就是说,所有进行插入操作的客户端机器中,只有一台机器能够成功————那么,我们就认为向数据库中成功插入数据的客户端机器成为Master。
乍一看,这个方案确实可行,依靠关系型数据库的主键特性能够很好地保证在集群中选举出唯一的一个Master。但是我们需要考虑的另一个问题是,如果当前
选举出的Master挂了,那么该如何处理?谁来告诉我Master挂了呢?显然,关系型数据库没法通知我们这个事件。
ZooKeeper的强一致性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即ZooKeeper将会保证客户端无法重复创建一个已经存在
的数据节点。也就是说,如果同时有多个客户端请求创建同一个节点,那么最终一定只有一个客户端请求能够创建成功。利用这个特性,就能很容易地在分布式
环境中进行Master选举了。
在这个系统中,首先会在ZooKeeper上创建一个日期节点,如下图:
客户端集群每天都会定时往ZooKeeper上创建一个临时节点,例如/master_election/2017-09-03/binding
。在这个过程中,只有一个客户端能够成功
创建这个节点,那么这个客户端所在机器就称为了Master。同时,其他没有在ZooKeeper上成功创建节点的客户端,都会在节点/master_ecection/2017-09-03
上注册一个子节点变更的Watcher,用于监控当前的Master机器是否存活,一旦发现当前的Master挂了,那么其余的客户端将会重新进行Master选举。
从上面的讲解中,我们可以看到,如果仅仅只是想实现Master选举的话,那么其实只需要有一个能够保证唯一性的组件即可,例如关系型数据库的主键模型
就是不错的选择。但是,如果希望能够快速地进行集群Master动态选举,那么基于ZooKeeper来实现是一个不错的新思路。
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的
时候,往往需要通过一些互斥手段来防止彼此之间的干扰,以保证一致性,在这种情况下,就需要使用分布式锁了。
在平时的实际项目开发中,我们往往很少会去在意分布式锁,而是依赖于关系型数据库固有的排他性来实现不同进程之间的互斥。这确实是一种非常简便且被
广泛使用的分布式锁实现方式。然而有一个不争的事实是,目前绝大多数大型分布式系统的性能瓶颈都集中在数据库操作上。因此,如果上层业务再给数据库
添加一些额外的锁,例如行锁、表锁甚至是繁重的事务处理,那么是不是会让数据库更加不堪重负呢?下面我们来看看使用ZooKeeper如何实现分布式锁,
这里主要讲解排他锁和共享锁两类分布式锁。
排他锁(Exclusive Locks,简称X锁),又称为写锁或独占锁,是一种基本的锁类型。如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许
事务T1对O1进行读取和更新操作,其他任何事务都不能再对这个数据对象进行任何类型的操作————直到T1释放了排他锁。
从上面讲解的排他锁的基本概念中,我们可以看到,排他锁的核心是如何保证当前有且仅有一个事务获得锁,并且锁被释放后,所有正在等待获取锁的事务都
能够被通知到。下面我们就看看如何借助ZooKeeper实现排他锁。
.1 定义锁
有两种常见的方式可以用来定义锁,分别是synchronized机制和JDK5提供的ReentrantLock。然而,在ZooKeeper中,没有类似于这样的API可以直接使用,
而是通过ZooKeeper上的数据节点来表示一个锁,例如/exclusive_lock/lock
节点就可以被定义为一个锁,如下图:
.2 获取锁
在需要获取排他锁时,所有的客户端都会试图通过调用create()接口,在/exclusive_lock
节点下创建临时子节点/exclusive_lock/lock
。而ZooKeeper
会保证在所有的客户端中,最终只有一个客户端能够创建成功,那么就可以认为该客户端获取了锁。同时,所有没有获取到锁的客户端就需要到/exclusive_lock
节点上注册一个子节点变更的Watcher监听,以便实时监听到lock节点的变更情况。
.3 释放锁
由于是临时节点,有下面两种情况,可能释放锁:
无论在上面情况下移除了lock节点,ZooKeeper都会通知所有在/exclusive_lock
节点上注册了子节点变更Watcher监听的客户端。这些客户端在接收到通知后,
再次重新发起分布式锁获取,即重复“获取锁”过程。如下图:
共享锁(Shared Locks,简称S锁),又称读锁,同样是一种基本的锁类型。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,
其他事务也只能对这个数据对象加共享锁————直到该数据对象上的所有共享锁都被释放。
共享锁和排他锁最根本的区别在于,加上排他锁后,数据对象只对一个事务可见,而加上共享锁后,数据对所有事务都可见。
.1 定义锁
和排他锁一样,同样是通过ZooKeeper上的数据节点来表示一个锁,是一个类似于/shared_lock/[Hostname]-请求类型-序号
的临时顺序节点,例如/shared_lock/192.168.0.1-R-0000000001
,那么,这个节点就代表了一个共享锁,如下图:
.2 获取锁
在需要获取共享锁时,所有客户端都会到/shared_lock
这个节点下面创建一个临时顺序节点,如果当前是读请求,那么就创建例如/shared_lock/192.168.0.1-R-000000001/
的节点;如果是写请求,那么就创建例如/shared_lock/192.168.0.1-W-000000001
的节点。
.3 判断读写顺序
根据共享锁的定义,不同的事务都可以同时对同一数据对象进行读取操作,而更新操作必须在当前没有任何事务进行读写操作的情况下进行。基于这个原则,
我们来看看如何通过ZooKeeper的节点来确定分布式读写顺序,大致可以分为如下4个步骤。
/shared_lock
节点下的所有子节点,并对该节点注册子节点变更的Watcher监听。.4 释放锁
释放锁的逻辑和排他锁是一致的。
.5 羊群效应
上面讲解的这个共享锁实现,大体上能够满足一般的分布式集群竞争锁的需求,并且性能都还可以————这里说的一般场景是指集群规模不是特别大,一般是在
10台机器以内。但是如果机器规模扩大之后,会有什么问题呢?我们着重来看上面“判断读写顺序”过程的步骤3,如下图,看看实际运行中的情况。
/192.168.0.1-R-000000001
删除。/shared_lock/
节点上获取一份新的子节点列表。上面这个过程就是共享锁在实际运行中最主要的步骤了,我们着重看下上面步骤3中提到的:“而余下的其他机器发现没有轮到自己进行读取或更新操作,于是继续等待。”
很明显,我们看到,192.168.0.1这个客户端在移除自己的共享锁后,ZooKeeper发送了子节点变更Watcher通知给所有机器,然而这个通知除了给192.168.0.2
这台机器产生实际影响外,对于余下的其他所有机器都没有任何作用。
相信读者也已经意思到了,在这整个分布式锁的竞争过程中,大量的“Watcher通知”和“子节点列表获取”两个操作重复运行,并且绝大多数的运行结果都是
判断出自己并非是序号最小的节点,从而继续等待下一次通知————这个看起来显然不怎么科学。客户端无端地接收到过多和自己并不相关的事件通知,如果在集群
规模比较大的情况下,不仅会对ZooKeeper服务器造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个节点对应的客户端完成事务或是事务
中断引起节点消息,ZooKeeper服务器就会在短时间内向其余客户端发送大量的事件通知————这就是所谓的羊群效应。
上面这个ZooKeeper分布式共享锁实现中出现羊群效应的根源在于,没有找准客户端真正的关注点。我们再来回顾一下上面的分布式锁竞争过程,它和核心
逻辑在于:判断自己是否是所有子节点中序号最小的。于是,很容易可以联想到,每个节点对应的客户端只需要关注比自己序号小的那个相关节点的变更情况
就可以了————而不需要关注全局的子列表变更情况。
.6 改进后的分布式锁实现
现在我们来看看如何改进上面的分布式锁实现。首先,我们需要肯定的一点是,上面提到的共享锁实现,从整体思路上来说完全正确。这里主要的改动在于:
每个锁竞争者,只需要关注/shared_lock/
节点下序号比自己小的那个节点是否存在即可,具体实现如下:
/shared_lock/[Hostname]-请求类型-序号
的临时顺序节点。流程图如下:
.7 注意
看到这里,相信很多读者都会觉得改进后的分布式锁实现相对来说比较麻烦。确实如此,如同在多线程并发编程实践中,我们会去尽量缩小锁的范围————对于
分布式锁实现的改进其实也是同样的思路。那么对于开发人员来说,是否必须按照改进后的思路来设计实现自己的分布式锁呢?答案是否定的。在具体的实际开发
过程中,我们提倡根据具体的业务场景和集群规模来选择适合自己的分布式锁实现:在集群规模不大、网络资源丰富的情况下,第一种分布式锁实现方式是
简单实用的选择;而如果集群规模达到一定程度,并且希望能够精细化地控制分布式锁机制,那么不妨试试改进版的分布式锁实现。
业界有不少分布式队列产品,不过绝大多数都是类似于ActiveMQ、Kafka等的消息中间件。在本节中,我们主要介绍基于ZooKeeper实现的分布式队列。
分布式队列,简单地讲分为两大类,一种是常规的先入先出队列,另一种则是要等到队列元素集聚之后才统一安排执行的Barrier模型。
使用ZooKeeper实现FIFO队列,和共享锁的实现非常类似。FIFO队列就类似于一个全写的共享锁模型,大体的设计思想其实非常简单:所有客户端都会到/queue_fifo
这个节点下面创建一个临时顺序节点,例如/queue_fifo/192.168.0.1-0000000001
,如下图:
创建完节点之后,根据如下4个步骤来确定执行顺序。
/queue_fifo
节点下的所有子节点,即获取队列中所有的元素。整个FIFO队列的工作流程,如下图:
Barrier原意是指障碍物、屏障,而在分布式系统中,特指系统之间的一个协调条件,规定了一个队列的元素必须都集聚后才能统一进行安排,否则一直等待。
这往往出现在那些大规模分布式并行计算的应用场景了:最终的合并计算需要基于很多并行计算的子结果来进行。这些队列其实是FIFO队列的基础上进行了
增强,大致的设计思想如下:开始时,/queue_barrier
节点是一个已经存在的默认节点,并且将其节点的数据内容赋值为一个数字n来代表Barrier值,
例如n=10表示只有当/queue_barrier
节点下的子节点个数达到10后,才会打开Barrier。之后,所有的客户端都会到/queue_barrier
节点下创建一个
临时节点,例如/queue_barrier/192.168.0.1
,如下图:
创建完节点之后,根据如下5个步骤来确定执行顺序。
/queue_barrier
节点的数据内容:10。/queue_barrier
节点下的所有子节点,即获取队列中所有元素,同时注册对子节点列表变更的Watcher监听。
作者:李文文丶
链接:https://www.jianshu.com/p/bd01abf2eaae
来源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。