zk相关文章已经有了三篇《zookeeper-paxos》、《zookeeper知识结构》、《zookeeper知识结构2-zab协议》
但都没有到具体到应用,此篇弥补一下
talk is cheap,show me the code
如何使用zk
除了zk提供原生客户端,还有能过编程方式
zkcli原生操作指令比较简单
zkcli 连接默认zookeeper服务器
zkcli -server ip:port 连接指定的zookeeper服务器
create -s -e path data [acl] 创建节点,-s表示顺序,-e表示临时,默认是持久节点,acl缺省表示不做任何权限限制
ls path [watch] 显示path下的节点,不递归显示,watch注册监听,命令行可忽视
ls2 path 显示当前节点下的节点和当前节点的属性信息
get path [watch] 获取path的属性信息和数据内容
set path data [version] 更新path的数据内容,version是做类似cas的功能的对应dataversion,命令行可忽略
delete path [version] 删除节点,不能递归删除,只能删除叶子节点
setacl path acl 设置节点acl,例子(scheme:id:password=:perm)-(digest:example:sha-1(base64(pwd))=:cdrwa) create delete read write admin
getacl path 获取path节点的acl
stat path 查看path的属性信息
quit 退出zkcli
这两个常用的开源组件
相对zkclient,Curator已经成为Apache的顶级项目,不仅解决了非常底层的细节开发工作,包括连接重连、反复注册Watcher和NodeExistsException异常等,还提供了Zookeeper各种应用场景(Recipe,如共享锁服务、Master选举机制和分布式计算器等)的抽象封装
所以推荐使用curator
主要介绍两种常见情景,一是分布式锁,二是master选举
为什么zk能实现分布式锁?
像redis原理是通过全局key是否存,而zk则是通过其特定的数据结构来实现:利用节点名称的唯一性
ZooKeeper抽象出来的节点结构是一个和unix文件系统类似的小型的树状的目录结构。ZooKeeper机制规定:同一个目录下只能有一个唯一的文件名。例如:我们在Zookeeper目录/jjk目录下创建,两个客户端创建一个名为Lock节点,只有一个能够成功
利用名称唯一性,加锁操作时,只需要所有客户端一起创建/lock节点,只有一个创建成功,成功者获得锁。解锁时,只需删除/lock节点,其余客户端再次进入竞争创建节点,直到所有客户端都获得锁
尝试加锁
/**
* 尝试加锁,直接创建节点,如果节点创建失败,说明加锁失败
* @param lockName
* @return
*/
public boolean tryLock(String lockName) {
try {
//创建节点
String path = cf.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT).forPath(getLockPath(lockName), lockName.getBytes());
logger.info(Thread.currentThread().getName()+ "try lock success ,path:"+path+" tid:"+Thread.currentThread().getName());
return true;
}catch (KeeperException.NodeExistsException e) {
logger.info("try lock fail,"+" tid:"+Thread.currentThread().getName());
}catch (Exception ex) {
ex.printStackTrace();
}
return false;
}
尝试加锁失败后,阻塞等待
/**
* 尝试加锁失败后,阻塞等待
* @param lockName
* @throws Exception
*/
public void waitForLock(String lockName) throws Exception {
//监听子节点
PathChildrenCache pathChildrenCache = new PathChildrenCache(cf, LOCK_PATH, false);
pathChildrenCache.start(PathChildrenCache.StartMode.BUILD_INITIAL_CACHE);
pathChildrenCache.getListenable().addListener(new PathChildrenCacheListener() {
@Override
public void childEvent(CuratorFramework client, PathChildrenCacheEvent event) throws Exception {
switch (event.getType()) {
case CHILD_REMOVED:
logger.info("path:" + event.getData().getPath() + " has removed,start to lock:{}",Thread.currentThread().getName());
countDownLatch.countDown();
break;
default:
//logger.info(" has changeed,{},start to lock:{}",event.getType(),Thread.currentThread().getName());
break;
}
}
});
boolean hasLock = false;
while(!hasLock) {
Stat stat = cf.checkExists().forPath(getLockPath(lockName));
//节点存在,此处与unlock非原子操作,如果在checkExists返回true时刻,成功unlock,那此端无限等待
if (stat == null) {
logger.info("waitForLock not exists:{}", Thread.currentThread().getName());
hasLock = tryLock(lockName);
if(hasLock) {
logger.info("waitForLock get lock :{}", Thread.currentThread().getName());
break;
}
} else {
logger.info("waitForLock exists:{}", Thread.currentThread().getName());
countDownLatch.await();
}
}
}
解锁
public boolean unlock(String lockName) throws Exception {
logger.info("start unlock:" + lockName + " tid:" + Thread.currentThread().getName());
cf.delete().forPath(getLockPath(lockName));
logger.info("end unlock:" + lockName + " tid:" + Thread.currentThread().getName());
return true;
}
这个方案比较简单,但会出现两个问题:
为了应对上面的问题,可以使用临时有序节点:EPHEMERAL_SEQUENTIAL,之前的篇章中说明了临时节点特点,在client与zk断开连接时,临时节点会自动删除
加锁算法:
解锁算法:
尝试加锁失败后,阻塞等待
public void waitForLock(String lockName) throws Exception {
logger.info("waitForLock {}:{}",beforeNode, Thread.currentThread().getName());
boolean hasLock = false;
while(!hasLock) {
//是不是最小节点
boolean isMin = isMinNode();
if (isMin) {//是 则成功获取锁
logger.info("waitForLock getLock:{}", Thread.currentThread().getName());
break;
} else {
try {
cf.getData().usingWatcher(new Watcher() {
@Override
public void process(WatchedEvent event) {
switch (event.getType()) {
case NodeDeleted:
logger.info("path:" + event.getPath() + " has removed,before:{},start to lock:{}", beforeNode, Thread.currentThread().getName());
countDownLatch.countDown();
break;
}
}
}).forPath(beforeNode);
logger.info("waitForLock waiting:{}", Thread.currentThread().getName());
countDownLatch.await();
logger.info("开始抢占:{},{}", Thread.currentThread().getName(), currentNode);
}catch (KeeperException.NoNodeException e){
logger.info("waitForLock has delete:{},{}",beforeNode, Thread.currentThread().getName());
}
}
}
}
这个方案解决了思路一中的问题
再回看《剖析分布式锁》。zk实现方式完美了吗?
显然示例中没有达到好锁的标准,更完善的实现可以看看curator中的InterProcessLock
此锁高可用了吗?对比一下Redis,哪种方案更完美?
客户端1发生GC停顿的时候,zookeeper检测不到心跳,也是有可能出现多个客户端同时操作共享资源的情形
redis的最新set指令,zk的临时节点两个性质都是一样的,解决了因过期时间问题引起的死锁
有了“续命丸”方案,在单机情况下,redis更完美些,至少不会出现zk临时节点因session超时提前删除问题
在集群下呢?线上环境,为了高可用不大会使用单点
如redis的cluster,哨兵模式;但由于Redis的主从复制(replication)是异步的,这可能会出现在数据同步过程中,master宕机,slave来不及同步数据就被选为master,从而数据丢失
为了应对这个情形, redis的作者antirez提出了RedLock算法,步骤如下(该流程出自官方文档),假设我们有N个master节点(官方文档里将N设置成5,其实大等于3就行)
缺陷
比如一下场景,两个客户端client 1和client 2,5个redis节点nodes (A, B, C, D and E)。
对于步骤2,还有一种情况,比如节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了;节点C重启后,client2从C、D、E成功获取锁
对于这两种情况,redis作者antirez给出了两种人为补偿措施
一时钟问题,不允许人员修改时间;
二节点重启,提出延迟重启的概念,即一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,等待的时间大于锁的有效时间。采用这种方式,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响
Redlock的问题,最关键的一点在于Redlock需要客户端去保证写入的一致性,后端5个节点完全独立,所有的客户端都得操作这5个节点。如果5个节点有一个leader,客户端只要从leader获取锁,其他节点能同步leader的数据,这样,分区、超时、冲突等问题都不会存在。所以为了保证分布式锁的正确性,我觉得使用强一致性的分布式协调服务能更好的解决问题
而强一致问题,zk可以完成,zk是个CP系统,zk内部机制就保证了各数据的一致性
到此,对分布式锁的实现可以总结一下
zookeeper可靠性比redis强太多,只是效率低了点,如果并发量不是特别大,追求可靠性,首选zookeeper
为了效率,则首选redis实现
除了分布式锁,还有一个常用场景:master选举。在curator中也有相应封装:LeaderSelector;具体实现可以自行阅读源码
基于zookeeper的分布式锁
《Is Redlock safe?》
《How to do distributed locking》
curator使用说明
分布式锁实现抉择
聊一聊分布式锁的设计