zookeeper学习总结
- zookeeper是一个服务于分布式应用程序的中间件协调服务
单体架构到微服务架构
- 单体架构下,一个完整的流程比如"购物",可以在单体架构中完成整个流程。
- 分布式架构下,服务进行细化,拆分,涉及到服务之间的通信。
SpringBoot+RestTemplate
- SpringBoot提供RestTemplate支持远程通信,其简化了http服务的通信
- 使用RestTemplate,只需要使用者提供URL
- RestTemplate本质上是对现有技术进行了封装,如JDK、Apache HttpClient、OkHttp等
分布式一致性问题
- 在一个分布式系统中,有多个节点,每个节点都会提出一个请求,
- 但是所有节点中只有一个请求会被通过
- 被通过的请求是所有节点达成一致的结果
- 所谓一致性:提出的所有请求中能够选出最终一个确定的请求,并且选出之后,所有节点都要知道
分布式锁服务
- zookeeper是作为分布式锁服务的一设计
- 注册中心是zookeeper是其能够实现的功能之一
zookeeper集群部署
- 进入zk安装的conf目录下,复制zoo.sample.cfg文件为zoo.cfg文件,并配置dirData目录存储相关数据
- 在配置的dirData路径下创建myid文件,文件内容自定义,为每一台zk服务器的标识
- 在conf目录下的zoo.cfg配置文件下配置集群
- zk启动的时候会根据myid的内容与配置文件中的配置相匹配
- 集群启动后,会进行leader选举,选出leader节点
#2181:访问zookeeper的端口 3888:重新选举后leadeer的端口
server.1=192.168.1.1:2181:3888
server.2=192.168.1.1:2181:3888
server.3=192.168.1.1:2181:3888
#解包
tar -zxvf z..
#启动ZK服务
./zkServer.sh start
#查看zk服务状态
./zkServer.sh status
#停止zk服务
./zkServer.sh stop
#重启zk服务
./zkServer.sh restart
#连接zk
sh zkCli.sh
问题记录
- 记录创建zookeeper集群时遇到的坑
- 本地虚拟机同时启动三台总有一台无法运行——暂未解决
- zk启动之后查看启动状态和日志发现,节点之间未建立连接
- 通过修改服务器上的hosts文件,去掉ip解决
zookeeper集群角色
- 在zookeeper集群中一共有三种角色:Leader、follower、Observer
- leader节点是被选举出的主节点,这里称为leader选举
- follow节点参与leader选举,选举失败为follower节点
- Observer为观察者节点,不参与选举
zookeeper数据结构
- zk的数据结构类似于标准的文件系统,其内部维护了一系列的节点称为zNode
- 每个节点上都可以保存数据,数据形式是以key-value的形式存储的
- 节点上都可以保存数据以及挂载子节点,构成层次化的树形结构
节点类型
- 持久化节点:PERSISTENT 创建完成后一直存在zk服务器上,直到主动删除
- 持久有序节点:PERSISTENT_SEQUENTIAL 会为其子节点维护一个顺序
- 临时节点:EPHEMERAL 生命周期与会话绑定在一起,当客户端失效,自动清理
- 临时有序节点:在临时节点的基础上多了一个有序性
zookeeper会话
-
初始化或连接未建立的状态是connecting
-
建立连接的状态时connected
-
关闭连接的状态是closed
zookeeper节点状态信息
- zk的每个节点除了存储数据内容之外,还存储了节点本身的状态信息,通过get命令可获得详细内容
状态属性 | 解释说明 |
---|---|
czxid | Create ZXID 表示该节点被创建时的事务ID |
mzxid | Modify ZXID 表示该节点最后一次被更新时的事务ID |
ctime | Create Time 表示节点被创建的时间 |
mtime | Modify Time 节点最后一次被更新的时间 |
version | 数据节点的版本号 |
cversion | 子节点的版本号 |
aversion | 节点的ACL版本号 |
ephemeralOwner | 创建临时节点的会话sessionID若为持久化节点,该属性值为0 |
dataLength | 数据内容的长度 |
numChildren | 当前节点的子节点个数 |
pzxid | 表示当前节点的子节点列表最后一次被修改时的事务ID(这里特指子节点列表变更) |
zk-分布式数据的原子性
- zk为数据节点引入了版本的概念,每个数据节点对应三类版本信息
- 对数据节点的任何更新操作都会引起版本号的变化
- 通过版本实现分布式数据的原子性类似于悲观锁和乐观锁的概念
- 悲观锁、乐观锁概念回顾
- 悲观锁是数据库中一种并发控制策略,当一个事务A正在对数据进行处理
- 数据将会处于锁定状态,这期间其它事务无法对数据进行操作
- 乐观锁,多个事务在处理过程中不受影响,在同时对一个数据进行修改时
- 在更新请求提交之前,会对数据是否冲突进行检测,若发生冲突则提交被拒绝
- zk就是通过version来实现乐观锁机制的——”写入校验“
watcher
- zk提供了分布式数据的发布/订阅功能
- zk允许客户端向服务端注册一个watcher监听,当服务端一些事件触发了watcher
- 服务端向客户端发送一个事件通知
- watcher通知是一次性的,一旦触发一次通知后,watcher就会失效,可以通过循环注册实现持续监听
zk重试策略
- 在和zk服务器建立连接的时候,有提供三种连接策略 retryPolicy
RetryOneTime | 仅仅重试一次 |
---|---|
RetryUntilElapsed | 一直重试直到规定时间结束 |
RetryNTimes | 指定最大重试次数 |
zk节点权限控制
- zk提供ACL权限控制机制(Access Control List)来保证数据的安全性
权限模式 | 授权对象 |
---|---|
IP | IP地址或IP段,被赋予权限的用户可在指定IP或IP段进行操作 |
Digest | 最常用控制模式,设置的时候需要DigestAuthenticationProvider.generateDigest() SHA- 加 密和 base64 编码 |
World | 最开放的控制模式,数据访问权限对所用用户开放 |
Surper | 超级用户,可对节点进行任何操作 |
zk——watcher机制
- zk提供watcher监听机制,可对节点数据变更、删除、子节点状态变更等事件进行监听
- 通过监听机制,可以基于zookeeper实现分布式锁、集群管理等功能
zookeeper事件 | 事件含义 |
---|---|
EventType.NodeCreated | 当节点被创建,事件触发 |
EventType.NodeChildrenChanged | 监听当前节点的直接子节点 |
EventType.NodeDataChanged | 节点数据发生变更,事件触发 |
EventType.NodeDeleted | 节点删除,事件触发 |
EventType.None | 客户端连接状态变更,事件触发 |
-
watcher监听是一次性的,可通过循环注册实现永久监听
-
Curator是对zookeeper的封装,提供了三种watcher来监听节点的变化
-
PathChildCache:监视一个路径下目标子结点的创建、删除、更新
-
NodeCache:监视当前结点的创建、更新、删除,并将结点的数据缓存在本地
-
TreeCache:PathChildCache 和 NodeCache 的“合体”,监视路径下的创建、更新、删除事件,
并缓存路径下所有孩子结点的数据
-
zk实现分布式锁
- 基于Curator实现分布式锁
- 在分布式架构下,涉及到多个进程访问同一个共享资源的情况
- 这个时候没有办法使用synchronized和lock之类的锁实现数据安全
- zookeeper实现分布式锁
- zk的同级节点具有唯一性,可利用此特性实现独占锁
- 即多个进程在zk的指定节点下创建一个名称相同的节点,只有一个进程能够创建成功,认为其获取了锁
- 创建失败的节点可通过zookeeper的watcher机制来监听子节点的变化,监听到删除事件,触发所有进程去竞争锁
- 通过有序节点实现分布式锁,可避免大量进程同时去竞争锁,每个节点只需要监听比自己小的节点,避免了资源浪费
- curator基于zk提供了分布式锁的基本使用
Curator 实现分布式锁的基本原理
- 利用path创建临时有序节点,实现公平锁核心,1代表了能获得分布式锁的数量,即为互斥锁
- 由LockInternals对象执行分布式锁的申请和释放
- internaLock(-1, null):不限时等待 internaLock:限时等待
private boolean internalLock(long time, TimeUnit unit) throws Exception
{
Thread currentThread = Thread.currentThread();
// 这里给定的 LockData 实例只能由一个线程操作
LockData lockData = threadData.get(currentThread);
if ( lockData != null )
{
// 实现可重入,lockData映射表存放线程的锁信息
lockData.lockCount.incrementAndGet();
return true;
}
/// 映射表 中没有对应的锁信息, ,通过 attempLock获得锁
String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
if ( lockPath != null )
{
// 获取锁成功记录 锁信息到映射表 即 thread
// private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap();
LockData newLockData = new LockData(currentThread, lockPath);
threadData.put(currentThread, newLockData);
return true;
}
return false;
}
- lockCount:分布式锁重入次数
- attempLock 尝试获取锁,并返回锁对应的 Zookeeper 临时顺序节点的路径
String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception
{
final long startMillis = System.currentTimeMillis();
// 无限时等待 millisToWait 为null
final Long millisToWait = (unit != null) ? unit.toMillis(time) : null;
final byte[] localLockNodeBytes = (revocable.get() != null) ? new byte[0] : lockNodeBytes;
int retryCount = 0;
// 临时节点路径
String ourPath = null;
// 是否已经持有分布式锁
boolean hasTheLock = false;
// 是否已经完成获取锁操作
boolean isDone = false;
while ( !isDone )
{
isDone = true;
try
{
// 在 zk 中创建临时顺序节点
ourPath = driver.createsTheLock(client, path, localLockNodeBytes);
// 循环等待来激活分布式锁,实现锁的公平性
hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
}
catch ( KeeperException.NoNodeException e )
{
// gets thrown by StandardLockInternalsDriver when it can't find the lock node
// this can happen when the session expires, etc. So, if the retry allows, just try it all again
if ( client.getZookeeperClient().getRetryPolicy().allowRetry(retryCount++, System.currentTimeMillis() - startMillis, RetryLoop.getDefaultRetrySleeper()) )
{
isDone = false;
}
else
{
throw e;
}
}
}
if ( hasTheLock )
{
// 成功获得分布式锁,返回临时顺序节点的路径上层将其封装成锁信息,记录在映射表,方便锁重入
return ourPath;
}
return null;
}
- 创建临时顺序节点即锁节点,createsTheLock
public String createsTheLock(CuratorFramework client, String path, byte[] lockNodeBytes) throws Exception
{
String ourPath;
// lockNodeBytes为空的话,采用默认(IP地址)创建节点
if ( lockNodeBytes != null )
{
ourPath=client.create()
.creatingParentContainersIfNeeded()
.withProtection()
// 保证临时节点的有序性
.withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
.forPath(path, lockNodeBytes);
}
else
{
ourPath = client.create()
.creatingParentContainersIfNeeded()
.withProtection()
.withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
.forPath(path);
}
return ourPath;
}
- internalLockLoop 判断是否已经持有分布式锁:循环等待激活,实现锁的公平性
private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception
{
// 是否已经持有锁
boolean haveTheLock = false;
// 是否删除子节点
boolean doDelete = false;
try
{
if ( revocable.get() != null )
{
client.getData().usingWatcher(revocableWatcher).forPath(ourPath);
}
while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock )
{
// 获取排序后的子节点列表
List<String> children = getSortedChildren();
// +1 to include the slash 获取自己创建的临时子节点的名称
String sequenceNodeName = ourPath.substring(basePath.length() + 1);
PredicateResults predicateResults = driver
.getsTheLock(client, children, sequenceNodeName, maxLeases);
if ( predicateResults.getsTheLock() )
{
// 获得了锁,中断循环
haveTheLock = true;
}
else
{
// 没有获得锁,,监听上一临时顺序节点
String previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();
synchronized(this)
{
try
{
// use getData() instead of exists() to avoid leaving unneeded watchers which is a type of resource leak // 使用 getData() 避免资源泄露,exists会创建不必要的监听器
// 这里 如果监听到上一顺序节点被删除,,则会获得锁
client.getData().usingWatcher(watcher).forPath(previousSequencePath);
if ( millisToWait != null )
{
millisToWait -= (System.currentTimeMillis() - startMillis);
startMillis = System.currentTimeMillis();
if ( millisToWait <= 0 )
{
// 获取锁超时 删除属于当前进程的临时顺序节点
doDelete = true; // timed out - delete our node
break;
}
wait(millisToWait);
}
else
{
wait();
}
}
catch ( KeeperException.NoNodeException e )
{
// it has been deleted (i.e. lock released). Try to acquire again
}
}
}
}
}
catch ( Exception e )
{
ThreadUtils.checkInterrupted(e);
doDelete = true;
throw e;
}
finally
{
if ( doDelete )
{
deleteOurPath(ourPath);
}
}
return haveTheLock;
}
- getsTheLock获得锁的逻辑
- 获取临时有序节点在排序后的列表中的索引
- 校验创建的临时有序节点是否有效
- 锁公平性核心逻辑
- 已经了解到maxLeases为1,即当ourIndex为零的时候,线程才能获得锁
- zk临时有序节点特性,保证了跨多个JVM线程并发创建节点时的顺序性,越早创建成功越早激活锁
- 如果已经获取了锁,无需监听任何节点,否则需要监听上一顺序节点,,即 ourIndex-1
- 锁是公平的,因此无需监听除了 ourIndex-1 以外的任何节点
- 如果 ourIndex < 0,则表示会话过期或连接丢失等原因,该线程创建的临时有序节点被zk删除,抛出异常
- 如果在重试策略允许的范围内重新尝试获取锁,然后重新生成新的临时有序节点
- 释放锁逻辑
- newLockCount记录了锁重入次数,初始值为1,当 newLockCount=0,锁释放
- 然后从映射表中移除对应线程的锁信息
zookeeper的leader选举原理
-
在zk的分布式集群中,有着三种角色,leader、follower、observer
-
curator提供了两种选举 recipe -> Leader Latch 和 Leader Selector
-
Leader Latch:参与选举的所有节点都会创建一个顺序节点最小的节点会被设置为master节点
没有抢占到Leader节点的节点都会对前一个节点的删除事件进行监听,
当其删除或master节点手动调用close()或master节点挂掉,后续节点会抢占master
-
Leader Selector:和Leader Latch的差别在于leader节点在释放领导权之后会继续参与竞争
-
zookeeper数据同步
- zookeeper通过三种不同的集群角色(leader、follower、observer),组成整个高性能集群
- zk集群中,客户端会随机连接到zookeeper集群中的一个节点,如果是读请求,直接从当前节点读取数据
- 如果是写请求,请求将会被转发给leader提交事务
- 然后leader会广播事务,只要有超过半数节点写入成功,那么写请求就会被提交
流程详解
- 1.客户端发出写事务请求到zk集群中的follower节点
- 2.follower节点转发事务请求到leader节点进行处理
- 3.leader节点发起proposal事务广播
- 4.follower节点发回ack消息确认
- 5.commit提交事务
- 6.response返回客户端
zookeeper——ZAB协议
- ZAB(zookeeper Atomic Broadcast)协议是为分布式协调服务zookeeper专门设计的
- 支持 崩溃恢复 的原子广播协议
- 在zk中,主要依赖ZAB协议来实现分布式数据的一致性
- 基于该协议,zk实现了主备模式的系统架构中集群中各个副本之间的数据一致性
- zab协议包含两种基本模式:崩溃恢复和原子广播
崩溃恢复和原子广播
- 当zk集群启动后,leader节点出现网络中断、崩溃等情况时
- 基于zab协议zk会自动进入恢复模式并选举出新的leader
- 当leader选举出来之后,并且集群中有过半机器和leader节点的数据完成同步后
- zab协议就会退出恢复模式,然后整个集群进入消息广播模式
- 此时当一台同样遵守ZAB协议的服务器启动后加入到集群中时,如果集群中存在leader服务器进行消息广播
- 新加入的服务器会自动进入数据恢复模式,进行数据同步,然后一起参与到消息广播流程中去
- 注意点:如果一个事务proposal在一台机器上处理成功,那么该事务应该在所有机器上都被处理成功
- 即,已经被处理的消息不能丢弃。被丢弃的消息不能再次出现
- 判断依据是:消息是否被commit成功
消息广播实现原理
- 消息广播的过程实际上是一个简化版本的二阶段提交过程,类似于分布式事务的2pc协议
- 流程分析
- leader接收到事务请求后,会生成一个全局唯一的64位的自增id,即zxid通过其大小保证消息因果顺序
- leader为每一个follower节点准备了一个FIFO的队列(通过TCP协议实现,以实现全局有序的的特点)
- 将带有zxid的消息作为一个提案(Proposal)分发给所有的follower
- follower节点收到proposal后,会先把proposal写到磁盘,写入成功后返回给leader一个ack
- 当leader接收到合法数量即超过半数节点的ACK之后,leader就会向这些follower发送commit命令,同时在本地执行目标消息
- follower节点收到commit命令之后,会提交该消息(leader的投票过程不需要Observer的ack,仅做数据同步即可)
数据一致性保证
- leader选举算法能够保证重新选举出来的leader拥有集群中所有机器最高编号zxid——zxid最大的事务proposal
- 那么就可以保证重新选举的leader一定具有已经提交的proposal
- 这是因为所有提案被commit之前必须有超过半数的follower节点发出ack确认,即拥有消息提案
- 因此只要有合法数量的节点正常工作,就必然有一个节点保存了所有被commit消息的proposal状态
关于zxid
-
zxid构成以及作用
-
zxid是一个64位的数据,高32位时epoch编号,每经过一次leader选举产生一个新的leader,新的leader会将epoch号加1
-
低32位是消息计数器,每收到一条消息,该值+1,新的leader选举后,消息被重置为0
- 基于此设计,保证了挂掉的leader重新启动之后不会被选举为leader,它的zxid小于当前新的leader
- 当旧的leader作为follower接入之后,新的leader会让其清除所有违背commit的proposal的epoch号
-
zxid说明
-
为了保证事务的顺序一致性,zk采用了递增的事务id号(zxid)来标识事务
-
所有的提案proposal都会被加上zxid
zookeeper的一致性
- zk集群内部的数据副本同步是基于半提交策略的,意味着它是最终一致性,而不满足强一致性要求
- zookeeper基于zxid以及阻塞队列的方式实现请求的顺序一致性
- 如果一个client请求连接到一台follower节点,读取到最新数据,由于网络原因连接到其它节点
- 若是重新连接到的follower节点还没有完成数据同步,将会读到旧的数据
- 针对这种情况,client会记录自己已经读取到的最大的zxid,如果重新连接到server
- 发现client持有的zxid比server的zxid要大,则连接会失败
zookeeper——leader选举原理
- leader选举存在于两个阶段中,一个是服务器启动时的leader选举
- 其二是运行过程中leader节点宕机导致的leader选举
重要参数
- 服务器ID:myid:myid是服务器的编号,编号越大在选择算法中的权重越大
- zxid:事务id:值越大说明数据越新,在选举算法中权重越大
- 逻辑时钟也叫投票次数:epoch – logicalclock
- 在同一轮投票过程中epoch-logicalclock的值是相同的,每次投票结束,该数值会增加
- 然后接收其它服务器返回的投票信息中的数值进行比较,根据不同的值做出判断
- 选举状态
LOOKING | 竞选状态 |
---|---|
FOLLOWING | 随从状态,同步 leader 状态,参与投票 |
OBSERVING | 观察状态,同步 leader 状态,不参与投票 |
LEADING | 领导者状态 |
服务启动时的leader选举
- 每个节点启动时的状态都是LOOKING
- 在集群初始化阶段,当有一台服务器server1启动的时候,无法单独进行和完成leader选举
- 当有多台服务器启动的时候,服务器之间此时可以进行相互通信,开始进入leader选举
|-每个server发起一次投票,首次投票,会将自身作为Leader服务器来进行投票
|-票据信息中会包含服务器的myid、zxid和epoch,使用vote进行封装(myid,zxid,epoch)
|-然后各自的投票信息发送给其它服务器
|-收到来自其它服务器的投票信息后,会首先判断投票是否有效(epoch),是否来自looking状态的服务器
|-然后进行pk,
——比较epoch
——检查zxid,zxid较大的服务器优先作为Leader,zxid相同比较myid
——myid较大的服务器作为leader服务器
|-投票结束后会统计投票信息,当有过半服务器接受了相同的投票信息,即选出了leader节点
|-确定leader之后,每个服务器会更新自己的状态,follower->followering leader->leadering
运行过中的leader选举
- 当集群中的 leader 服务器出现宕机或者不可用的情况时,进入新一轮leader选举
- leader宕机之后,剩余follower服务器,将自己的状态改变为looking,然后开始进入leader选举过程