文章目录
Zookeeper的典型应用场景
基于对zab算法的实现,该框架能够很好地保证分布式环境中数据的一致性。也正是由于这样的特性,使得ZooKeeper成为了解决分布式一致性问题的利器。
数据发布/订阅
数据发布订阅系统也就是我们常说的配置中心,就是发布者将数据发布到zk的一个或者一系列节点上,供订阅者进行数据订阅,达到了动态获取数据的目的,实现配置信息的集中式管理和数据的动态更新。
发布订阅系统一般分为推拉模式,而zk采用的是推拉相结合的方式:客户端向服务端注册自己关注的节点,一旦该节点的数据发生了变更,服务端就会向相应的客户端发送watcher事件通知,客户端接收到之后,需要主动到服务端获取最新的数据。
实际案例——配置管理
配置存储
在开始之前,我们需要将初始化配置存储到zk中,选取一个节点用于存储配置。例如/app1/database_config。
配置获取
每台机器在启动初始化时,都会从zk的配置节点上读取配置信息,同时注册一个数据变更的watcher监听。
配置变更
因为我们注册了watcher监听,所以当需要变更配置时,我们只需要更新zk配置数据节点上的数据即可。zk就会将数据变更通知到每个订阅了的客户端,并自行获取最新数据。
命名服务
命名服务是分布式系统中最基本的公共服务之一,被命名的实体可以是集群中的机器、提供的服务地址或者远程对象。
在一些分布式服务框架(如rpc、rmi)中的服务地址列表,通过使用命名服务,客户端应用能够根据指定名字来获取资源的实体、服务地址和提供者的信息等。
Java语言中的JNDI是一种典型的命名服务。
实际案例——zk实现一套全局唯一ID机制
生成唯一ID通常的几种做法:
- 数据库的auto_increment属性,但该属性依赖于单表,在分库分表之后不再适用。
- UUID,非常不错的全局唯一ID生成方式。但它有两个缺点:1.id太长,存储空间变大;2. 含义不明,开发者无法根据UUID判断出其表达的含义,降低了问题排查调试效率。
- 利用zk的创建一个顺序节点,zk会自动以后缀的形式在其子节点上添加一个序号,API的返回值中会返回这个节点的完整名字。
使用zk解决方案的步骤:
- 客户端使用create()接口创建顺序节点。
- 节点创建完毕后,create()接口会返回一个完整的节点名,例如”job-0000000003“
- 客户端拿到这个返回值,拼接上生成节点的父节点名,例如“type2-job-0000000003”,这就得到了一个全局唯一的ID了。
分布式协调/通知
在分布式系统中,一般会需要一个协调者来控制整个系统的运行流程,例如分布式事务处理,机器间相互协调。
zk中特有的watcher注册与异步通知机制,能够很好地实现不同机器,不同系统间的协调与通知。
通常做法是注册一个znode节点,并在该节点上注册一个watcher事件监听,如果数据发生了变化,那么所有订阅这个事件的客户端都能够接受到相应的watcher通知,并作出相应处理。
实际案例——主备切换功能
在分布式系统中,单点问题是很常见的。单点问题是指当数据流的传递路径经过的各个节点中,如果一个节点发生宕机问题,则整个系统都没法用的问题。为了解决单点问题,一般的手段就是在可能发生单点问题的节点设置一个该节点的备用节点,如果主机发生错误,则备用机器自动启动替代主机向外提供服务。
可以利用Zookeeper的临时节点特性实现上述功能。
任务注册
在主机和从机未启动前,我们先对应特定任务注册一个任务节点,比如/tasks/task1。
热备份
这一步是对于主机防止出现宕机而引发的单点问题的“热备份”的容灾方式。
主、备机器都有相同的逻辑代码,即可以向外提供相同的服务,主、备机器通过ZooKeeper的心跳检测互相检查运行健康状况。
主、备机会在/tasks/task1/instances节点下注册各自的主机名节点,这里的节点是一个临时的顺序节点,注册完的节点为/tasks/task1/instances/[Host1]-1。这里的最后面的序列号就是临时顺序节点的精华所在。
小序号优先策略:主、备机在将自己的主机名注册成节点后,会获得其他所有子节点列表,通过比较最后面的序号,判断自己是否是最小的,如果是的话则自己为主机,设置自己状态为RUNNING,否则设置自己状态为STANDBY。
热备切换
标记为STANDBY的备机将会在/tasks/task1/instances节点上注册一个子节点变更的watcher监听,这样就可以订阅所有机器的运行情况。
一旦RUNNING的主机发生宕机等故障后,zk与主机client之间的心跳检测就会检测到该节点挂了,该节点就会消失,于是其他机器也就收到了这个事件,从而开始新的一轮RUNNING选举。
记录执行状态
在热备切换中,一个很现实的问题就是:一个任务,如果主机执行到50%就宕机了,备机被切换上去之后,如果再重复执行那50%,可能会带来一些坏的影响。所以我们在主、备机执行任务时需要记录任务执行的进度。
具体做法是在/tasks/task1注册一个/tasks/task1/lastCommit节点,在该节点上做执行进度的存储,RUNNING的机器会定时向这个节点写入当前的执行进度。
冷备切换
热备份有一个缺点,对于同一个任务,它会消耗至少两台机器(一台主机一台备用),有些浪费。
所以我们介绍一下冷备份,它与热备份最大的不同是,对所有的任务进行分组。/task-groups/group1/task1。
主机备机启动后都会被分到一个group中,然后遍历该group下的所有task,对于每一个task先遍历其/instances,如果没有子节点,则会创建一个临时的顺序节点,因为在这个过程中其他备机可能也在创建了一个临时的顺序节点,这时候就会使用“小序号优先”策略选举出RUNNING机器,即主机。未选举上的机器会删掉自己的临时节点,然后继续遍历下一个Task节点。
就这样,在一个扫描周期中所有机器不断地对对应的group下面的task进行冷备份扫描。
冷热备份对比
热备份方案中,针对一个任务使用了两台机器进行热备份容灾,借助zk的watcher通知机制和临时顺序节点的特性,能够非常实时的主备切换,但是机器浪费资源大。在冷备份方案中,采用了扫描机制,虽然降低了任务协调的实时性,但是提升了机器的利用率,节省了机器资源。
zk是一种通用的分布式系统机器间通信方式
包括心跳检测、工作进度汇报、系统调度。
心跳检测
传统的方式,是主机之间是否可以ping通来判断,更复杂的,则会通过在机器之间建立长连接,通过tcp连接固有的心跳检测机制来实现上层机器的心跳检测。
先解释一下zk中服务器与客户端之间维持的长连接的过程:
客户端如何正确处理CONNECTIONLOSS(连接断开) 和 SESSIONEXPIRED(Session 过期)两类连接异常?
在ZooKeeper中,服务器和客户端之间维持的是一个长连接,在 SESSION_TIMEOUT 时间内,服务器会确定客户端是否正常连接(客户端会定时向服务器发送heart_beat),服务器重置下次SESSION_TIMEOUT时间。因此,在正常情况下,Session一直有效,并且zk集群所有机器上都保存这个Session信息。在出现问题情况下,客户端与服务器之间连接断了(客户端所连接的那台zk机器挂了,或是其它原因的网络闪断),这个时候客户端会主动在地址列表(初始化的时候传入构造方法的那个参数connectString)中选择新的地址进行连接。——阿里云中间件技术博客
我们基于zk的临时节点特性,让不同的机器都在zk的一个指定节点下创建各自的临时子节点,不同的机器之间可以根据这个临时节点来判断对应的客户端机器是否存活。通过这种方式,解耦了主机们之间的关系,通过zk间接地关联。
工作进度汇报
一般是创建一个临时节点,有两个作用:
- 根据这个临时节点判断该机器是否存活。
- 各个任务机器会实时的将自己的任务执行进度写到这个临时节点上去,以便于中心系统能够实时的获取到任务执行的进度。
系统调度
使用zk,可以实现这样一种系统调度模式:一个分布式系统由控制台和一些客户端组成,控制台的职责就是将一些指令信息发送给所有的客户端,以控制他们相应的业务逻辑。
实际上就是更改zk中某些节点数据,zk把这些数据变更以事件通知的形式发送给了对应的订阅客户端。
集群管理
Master选举
在分布式系统中,经常会有一些具有相同功能的机器组成的集群。
当有一个计算量大的任务到达这个集群时,如果集群中所有机器都去计算后存储到自身,会造成计算资源的浪费;所以我们一般需要选举出一个”老大“,即master。
master机器会处理这个计算量大的请求,然后存入内存/数据库中,通知其他机器来读取这个值并存储到自身,达到了共享的目的,节省了计算资源。
这时候就需要一个Master选举方案。
我们可以使用关系型数据库的主键唯一性这个特性,在每天定时的master选举中,让各个机器插入一条同样的主键(比如当前的日期)的记录,肯定只有一个客户端能够插入成功,插入成功的那个称为master。
但这种方式有一个问题,就是master挂掉,其他的机器并不知道。
所以我们引进zk来实现,利用zk创建节点的api接口,其中提到了一个重要特性——利用zk的强一致性,能够很好地保证在分布式高并发情况下节点的创建一定保证全局唯一性,即zk将会保证客户端无法重复创建一个已经存在的数据节点。
也就是说,我们要求多个机器同时创建一个数据节点(比如当前的日期),那么最终一定只有一个机器成功。
成功的那个就成为master,没成功的机器在该节点上注册一个watcher事件监听(子节点变更的),一旦发现当前master挂掉之后,其余客户端回重新进行选举master。
分布式锁
排他锁
又称为写锁或者独占锁,是一种基本的所类型。
如果事务t1对o1加了排他锁,则在整个加锁期间,只允许事务t1对o1进行读取和更新操作,其他任何事务都不能对这个资源进行任何类型的操作——直到锁释放。
定义锁
在zk中,我们通常会创建一个节点,让此节点代表一个锁。
获取锁
跟master选举类似,通过create接口在此节点下创建相同的临时的数据节点,创建成功的客户端为获取锁的客户端。同时,没有获取锁成功的客户端就会在该节点上注册一个子节点变更的watcher监听,一边实时监听到锁节点的变更情况。
释放锁
通过创建节点的方式来获取锁,有两种情况下回释放锁:
- 当获取锁的客户端发生了宕机,那么zk会将这个临时节点移除这个临时的数据节点。
- 正常的执行完逻辑后,客户端就会主动删除自己这个临时的数据节点。
只要是移除了临时的数据节点,子节点变更的事件就会通知给其他等待锁的客户端,它们又会重新发起分布式锁的获取。
基本上就是master选举的过程。
共享锁-利用了生成节点后的序号
又称为读锁,是一种基本的锁类型。如果事务t1对资源o1加上了共享锁,那么当前事务只能对o1进行读取操作,其他事物也只能对这个数据对象加共享锁——直到该数据对象的所有的共享锁都释放。
二者的区别
- 加上排他锁之后,数据对象只对一个事务可见,而加上共享锁之后,数据对所有事物都可见。
定义锁
同样使用一个数据节点表示一个锁,是一个类似"/shared_lock/[HOSTNAME]-请求类型-序号"的临时顺序节点,例如"/shared_lock/192.168.0.1-R-0000000001"。
获取锁
在获取共享锁时,所有客户端都会到/shared_lock这个节点下创建一个临时顺序节点,如果当前是读请求,则创建"/shared_lock/192.168.0.1-R-0000000001"的节点,如果是写请求,那么就创建"/shared_lock/192.168.0.1-W-0000000001"的节点。
判断读写顺序
根据共享锁的定义,不同的事务都可以同时对同一个数据对象进行读取操作,而更新操作必须在当前没有任何事务进行读写的情况下进行。
-
创建完节点后,获取/shared_lock节点下的所有子节点,并对该节点注册子节点变更的watcher监听。
-
确定自己的节点序号在所有子节点中的顺序。
-
对于读请求:
如果没有比自己序号小的子节点,或是所有比自己小的子节点都是读请求,那么表示自己成功获取了共享锁,接着开始执行代码逻辑。
如果比自己序号小的子节点中有写请求,那么就需要进入等待。
对于写请求:
如果自己不是序号最小的子节点,那么就需要进入等待。
-
接收到watcher通知后,重复执行步骤1。
释放锁
与排他锁一致。
羊群效应
共享锁是依赖于大量的“watcher通知”和“子节点列表获取”两个操作重复运行的,但我们不难发现,当一个节点完成自己的操作并删除自己创建的临时节点后,所有节点都会收到watcher通知并进行获取子节点列表,但最终绝大部分节点还是会继续等待。
客户端无端的接收到过多和自己并不相关的事件通知,如果在集群规模比较大时,不仅会对zk服务器造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个节点对应额客户端完成事务或者事务中断引起节点消失,zk服务器就会在短时间内发出大量的事件通知——这就是所谓的羊群效应。
解决方案:
每个节点对应的客户端只需要关注比自己序号小的那个相关节点的变更情况就可以了——而不需要关注全局的子列表变更情况。
改进后的分布式锁实现
-
客户端调用create()方法创建一个"/shared_lock/[Hostname]-请求类型-序号"的临时顺序节点。
-
客户端调用getChildren()接口来获取所有已经创建的子节点列表,注意,这里不注册任何watcher。
-
对于读请求:
对比自己序号小的最后一个写请求注册watcher监听。
对于写请求:
对比自己序号小的最后一个请求注册watcher监听。
-
等待watcher通知,进入步骤二。