Zookeeper入门——利用Java客户端API实现分布式锁

在简单学习了客户端API之后,基本可以尝试利用这些API去开发一个分布式锁

初版实现

利用的两个Zookeeper特性:

(1)临时有序节点:保证了即使客户端发生异常没有删除节点,该节点也会自动被删除。而且有序节点可以将所有操作变成串行操作。

(2)事件监听与回调机制:Zookeeper客户端与服务端实现了事件监听与回调,该机制非常重要,他可以保证服务端的节点发生改变时,客户端可以及时的感知并作出相应的动作。

代码实现如下

public class SecondKill {
    private int number = 10000;

    public void decrease() {
        if (number>0) {
            Thread.yield();
            number--;
            System.out.println(number);
        }
    }
}



package com.example.zookeeper_lock.lock;

import org.apache.zookeeper.*;
import org.apache.zookeeper.data.ACL;
import org.apache.zookeeper.data.Stat;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;

/**
 * @ClassName ReentrantLockZk
 * @Deacription zookeeper实现分布式可重入锁
 * @Author
 * @Date 2020/3/12
 * @Version 1.0
 * @Modefied what?
 **/
public class ReentrantLockZk {

    private static String zkNodes = "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183";
    private ZooKeeper zooKeeper = null;
    private String lockPath = null;
    private String parentPath = "/lock";
    private int version;
//初始化,创建客户端连接对象,并且初始化创建父节点
    public ReentrantLockZk() throws IOException, InterruptedException, KeeperException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        zooKeeper = new ZooKeeper(zkNodes , 50000, new Watcher() {
            public void process(WatchedEvent event) {
                if (event.getState().equals(Event.KeeperState.SyncConnected)) {
                    countDownLatch.countDown();
                }
            }
        });
        countDownLatch.await();
        ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
        List<ACL> acls = new ArrayList<ACL>();
        acls.add(acl);
        Stat stat = zooKeeper.exists(parentPath, true);
        if (stat == null) {
            parentPath = zooKeeper.create(parentPath, "lock".getBytes(), acls, CreateMode.PERSISTENT);
        }

    }

//创建节点,也就是获取锁
    public void createNode() throws IOException, KeeperException, InterruptedException {
        final CountDownLatch countDownLatch=new CountDownLatch(1);
        ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
        List<ACL> acls = new ArrayList<ACL>();
        acls.add(acl);
        //创建节点
        lockPath = zooKeeper.create(parentPath + "/demo", "helloworld".getBytes(), acls, CreateMode.EPHEMERAL_SEQUENTIAL);
        Stat stat = new Stat();
        zooKeeper.getData(lockPath,true, stat);
        version = stat.getVersion();
        //获取子节点列表,并设置回调方法实现所获取的判断逻辑
        zooKeeper.getChildren(parentPath, false, new LockCallBack(), countDownLatch);
        //主线程阻塞
        countDownLatch.await();
    }

    private class LockCallBack implements AsyncCallback.Children2Callback {
        @Override
        public void processResult(int i, String s, Object o, List<String> list, Stat stat) {
            CountDownLatch countDownLatch = (CountDownLatch)o;
            //遍历查询出的节点集合,这个经过实际测试发现并不是有序的,并不是按照序号大小有序返回的
            for (int j=0 ; j < list.size(); j++) {
                //遍历到当前节点
                if (lockPath.equals(parentPath+"/"+list.get(j))) {
                    try {
                        //如果当前节点是第一个,那就直接获取锁,解除主线程阻塞
                        if (j == 0) {
                            countDownLatch.countDown();
                            return;
                        } else {
                            //否则监控前一个节点的事件状态,这里使用同步方法进行获取,实际上会有一些问题,应该采用异步方法
                            stat = zooKeeper.exists(parentPath+"/"+list.get(j-1), new Watcher() {
                                @Override
                                public void process(WatchedEvent watchedEvent) {
                                    //如果事件类型为节点删除,那么就解除主线程阻塞,获取锁
                                    if (watchedEvent.getType().equals(Event.EventType.NodeDeleted)) {
                                        countDownLatch.countDown();
                                    }
                                }
                            });
                            //该情况表示前一个节点已经被删除了,直接解除主线程阻塞,表示获取到锁
                            if (stat == null) {
                                countDownLatch.countDown();
                            }

                        }

                    } catch (KeeperException e) {
                        e.printStackTrace();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                }
            }
        }
    }

//删除节点。也就是释放锁
    public void deleteNode() throws IOException, KeeperException, InterruptedException {
        zooKeeper.delete(lockPath, version);
    }

    public static void main(String[] args) throws InterruptedException, IOException, KeeperException {
        //测试加锁和释放锁
//        ReentrantLockZk lockZk = new ReentrantLockZk();
//        lockZk.createNode();
//        lockZk.deleteNode();

        //简单模拟秒杀场景
        SecondKill secondKill = new SecondKill();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                ReentrantLockZk lockZk = null;
                try {
                    //创建锁对象
                    lockZk = new ReentrantLockZk();
                    //阻塞直到获取锁,
                    lockZk.createNode();
                    //数量减一
                    secondKill.decrease();
                    //这行代码主要是查看加锁效果,是否会造成其他线程的阻塞等待,一定要注意不要设置时间太长,最好注释这行代码
                    //否则一旦超出创建客户端连接对象设置的50秒的过期时间,就会报错异常。
                    Thread.sleep(1000);
                    //释放锁
                    lockZk.deleteNode();
                } catch (IOException e) {
                    e.printStackTrace();
                } catch (KeeperException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        };
        //启动101个线程进行测试,最后的输出结果应该为9899
        for (int i = 0; i <= 100; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
    }

}

代码缺陷分析

初版代码缺陷很多,只能用来进行测试,做一个分布式锁的实现思路分析与验证,初步验证运行结果是没问题的,但是问题还是存在很多。

(1)异常处理,代码中没有做正确的异常处理,几乎所有的异常都是无脑向上抛出或者未做任何处理,一旦发生异常就会导致代码运行异常。这里的异常情况说实话,确实比较多,具体应该怎么处理就必须要详细思考,每一个异常分支都要进行相应的处理,如果获取锁方法报了异常,是否要进行重试,重试几次,如果确实无法获取锁的话主线程的业务代码是否继续执行,都是需要考虑的。

(2)LockCallBack的processResult方法中,通过exists方法监控前一个节点的删除事件时,如果该节点已经被删除了,那么方法就会抛出异常,进而无释放主线程的锁,导致死锁问题,造成内存溢出或者逃逸一系列问题,该问题只能通过锁超时参数解决。

(3)一旦请求压力过大,瞬间数万请求(也就是数万个子节点)甚至数十万打入Zookeeper下:首先要面临的问题就是节点队列过长的问题,如果一个节点的从创建到删除操作需要1秒的时间,那么数万个节点可以想象,对于处于后面的节点等待时间是非常恐怖的,根本等待不到获取锁。另一个问题就是,数万个节点的节点名数据都是要缓存一份到本地的,可能会导致出现内存溢出。这个可以通过一些限流手段解决,首先可以想到加一个消息中间件比如RabbitMQ或者Kafka去做一个削峰限流。

或者,利用Zookeeper有序节点的特性,当前节点的序号-1就是上一个节点的序号,所以只需要监控上一个节点,就可以无需获取所有的子节点序列集合。

(4)没有实现可重入锁,这个比较简单,模仿一下JDK中ReentrantLock锁的实现即可,非常简单。

(5)可以提供几个有参构造方法,可以手动指定父节点、子节点的路径名字,数据内容可选。目前父节点和子节点的路径名称都是默认的,这样会造成的问题比较大(不同业务代码,却被同一把锁加锁)。

(6)获取锁超时问题,没有进行获取锁超时情况下的处理。加锁的方法最好设置一个超时参数。这个实现比较简单,可以直接通过countDownLatch.await()方法来实现,加超时参数即可,该方法有一个重载版本,专门用于进行超时处理

public boolean await(long timeout, TimeUnit unit)

 

改进第二版

在上一版的基础上,稍微做了一点修改,主要修改在对上一版的缺陷分析中的第3点和第5点进行改造,节点监控的代码性能提高,提供有参构造方法尽量贴合业务代码。源码如下

package com.example.zookeeper_lock.lock;

import org.apache.zookeeper.*;
import org.apache.zookeeper.data.ACL;
import org.apache.zookeeper.data.Stat;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;

/**
 * @ClassName ReentrantLockZk2
 * @Deacription Zookeeper分布式锁
 * @Author
 * @Date 2020/3/17
 * @Version 2.0
 * @Modefied what?
 **/
public class ReentrantLockZk2 {
    private String zkNodes = "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183";
    private ZooKeeper zooKeeper = null;
    private String lockPath = null;
    private String parentLockPath = null;
    private int version;

    /**
     * @Author dinggang
     * @Description //初始化锁
     * @Date 2020/3/17
     * @Param [parentLockPath, zkNodes]
     * parentLockPath参数表示指定的锁的父节点的路径名称,不带 /
     * zkNodes表示Zookeeper集群地址
     * @return
     * @throws
     **/
    public ReentrantLockZk2(String parentLockPath, String zkNodes) throws IOException, InterruptedException, KeeperException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        this.parentLockPath = "/" + parentLockPath;
        this.zkNodes = zkNodes;
        //初始化Zookeeper对象
        zooKeeper = new ZooKeeper(zkNodes , 50000, new Watcher() {
            public void process(WatchedEvent event) {
                if (event.getState().equals(Event.KeeperState.SyncConnected)) {
                    countDownLatch.countDown();
                }
            }
        });
        countDownLatch.await();

        //创建父节点
        Stat stat = zooKeeper.exists(this.parentLockPath, false);
        if (stat == null) {
            ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
            List<ACL> acls = new ArrayList<ACL>();
            acls.add(acl);
            //临时节点下无法创建子节点,所以只能采用永久节点
            this.parentLockPath = zooKeeper.create(this.parentLockPath, "parentLockPath".getBytes(), acls, CreateMode.PERSISTENT);
        }
    }

    /**
     * @Author dinggang
     * @Description //获取加锁
     * @Date 2020/3/17
     * @Param [lockPath, data]
     * lockPath子节点路径名称,不带/
     * data表示子节点上存储的数据
     * @return void
     * @throws
     **/
    public void lock(String lockPath, byte[] data) throws Exception {

        final CountDownLatch countDownLatch=new CountDownLatch(1);
        ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
        List<ACL> acls = new ArrayList<ACL>();
        acls.add(acl);
        //创建临时有序节点
        String path = parentLockPath + "/" + lockPath;
        this.lockPath = zooKeeper.create(path, data, acls, CreateMode.EPHEMERAL_SEQUENTIAL);
        Stat stat = new Stat();
        //获取版本号,用于删除节点,释放锁实际上不删也可以,直接调用zooKeeper的close方法关闭客户端连接,效果相同,但是会慢一点
        zooKeeper.getData(this.lockPath,true, stat);
        version = stat.getVersion();
        //获取上一个节点的路径名,demo0000000809
        String str = this.lockPath.substring(path.length());
        Long number = Long.valueOf(str);
        number = number - 1;
        int count = String.valueOf(number).length();
        StringBuilder builder = new StringBuilder(path);
        for (int i = 1; i <= 10-count; i++) {
            builder.append(0);
        }
        builder.append(number);
        //异步执行,判断是否存在,并添加回调方法逻辑
        zooKeeper.exists(builder.toString(), true, new lockCallBack(), countDownLatch);
        //主线程阻塞,直到异步方法中判断前一个节点已经不存在
        countDownLatch.await();
    }

    private class lockCallBack implements AsyncCallback.StatCallback {

        @Override
        public void processResult(int i, String s, Object o, Stat stat) {
            CountDownLatch countDownLatch = (CountDownLatch) o;
            if (stat == null) {
                countDownLatch.countDown();
                return;
            }
            try {
                zooKeeper.exists(s, new Watcher() {
                    @Override
                    public void process(WatchedEvent watchedEvent) {
                        //节点被删除
                        if (watchedEvent.getType().equals(Event.EventType.NodeDeleted)) {
                            countDownLatch.countDown();
                        }
                        //节点不存在
                        if (watchedEvent.getType().equals(Event.EventType.None)) {
                            countDownLatch.countDown();
                        }
                    }
                });
            } catch (KeeperException e) {
                e.printStackTrace();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }

    /**
     * @Author dinggang
     * @Description //释放锁
     * @Date 2020/3/17
     * @Param []
     * @return void
     * @throws
     **/
    public void unlock() throws IOException, KeeperException, InterruptedException {
        zooKeeper.delete(lockPath, version);
        zooKeeper.close();
    }

    public static void main(String[] args) {
        //简单模拟秒杀场景
        SecondKill secondKill = new SecondKill();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                ReentrantLockZk2 lockZk = null;
                try {
                    //创建锁对象
                    lockZk = new ReentrantLockZk2("parent", "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183");
                    //阻塞直到获取锁,
                    lockZk.lock("child", "data".getBytes());
                    //数量减一
                    secondKill.decrease();
                    //释放锁
                    lockZk.unlock();
                } catch (IOException e) {
                    e.printStackTrace();
                } catch (KeeperException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (Exception e) {
                    e.printStackTrace();
                }

            }
        };
        //启动101个线程进行测试,最后的输出结果应该为9899
        for (int i = 0; i <= 100; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
    }

}

经过测试,发现代码结果正确。但是感觉,Zookeeper确实不是很适合用来作为分布式锁的实现,首先问题是性能低,性能真滴低,100个并发线程,业务代码仅仅是很简单的数据-1,100个线程执行完毕耗时差不多有3-5秒左右,其中的创建和删除节点的操作和与Zookeeper服务端通信来监听某个节点的数据变化,这三个操作耗费的时间远大于真正的业务代码时间。

而且还发现另一个问题,当我把并发线程数调整至400时,就开始报错了,java.io.IOException:Connection reset by peer。这个错误表示客户端连接数超过Zookeeper上限,与Zookeeper之间创建的长连接connection在被不断的关闭重置,原因在于Zookeeper客户端连接数是有限制的,可以在启动Zookeeper的配置文件中进行配置。

同时调整代码把Zookeeper设置为static变量,也就是类变量(考虑初始化的问题,采用单例模式,要保证多线程下只初始化一次),这样可以使得一个虚拟机中对应一个Zookeeper对象,尽量保证连接复用。没有必要每一个线程都创建一个新的客户端连接对象。

maxClientCnxns

这个配置参数将限制连接到ZooKeeper的客户端的数量,限制并发连接的数量,它通过IP来区分不同的客户端。此配置选项可以用来阻止某些类别的Dos攻击。将它设置为0将会取消对并发连接的限制。

例如,将maxClientCnxns的值设置为1

启动ZooKeeper之后,首先用一个客户端连接到ZooKeeper服务器之上。然后,当第二个客户端尝试对ZooKeeper进行连接,或者某些隐式的对客户端的连接操作,将会触发ZooKeeper的上述配置。

ZooKeeper关于maxClientCnxns参数的官方解释:

单个客户端与单台服务器之间的连接数的限制,是ip级别的,默认是60,如果设置为0,那么表明不作任何限制。请注意这个限制的使用范围,仅仅是单台客户端机器与单台ZK服务器之间的连接数限制,不是针对指定客户端IP,也不是ZK集群的连接数限制,也不是单台ZK对所有客户端的连接数限制。

 

Zookeeper分布式锁与Redis分布式锁的比较(个人的思考比较,可能不太对)

Zookeeper实现分布式锁差不多就是上面那样,自己的一点小思路,当然并没有做完全的实现,可重入锁、锁获取超时处理、读写锁这些其实实现也并不难,思路比较重要,实现也比较简单,难点在于对于可能发生的各种异常情况的处理,异常情况太多,很难思考的非常全面,而且难以对每一种异常情况都做出合理的处理。看网上说很少用Zookeeper实现分布式锁,搜索来搜索去也就是数据库、Redis、Zookeeper三种实现方式,找不到其他实现方式了。

Zookeeper实现分布式锁的话性能其实不是很好,甚至可以说一般,因为Zookeeper分布式锁的加锁与释放锁会频繁的在Zookeeper集群中进行增删节点操作,大量的写操作会导致Zookeeper性能很差,因为Zookeeper需要对集群中所有节点进行数据同步,这个是比较耗时的操作,尤其是当Zookeeper集群的Zookeeper节点很多的时候,性能消耗很大,对于业务代码来说,锁的获取和释放就比较慢。(上面的Zookeeper分布式锁测试中,101个线程进行最简单的数字减1操作,全部完成花费了3-4秒左右,系统吞吐量恐怕有些太低了,几乎全部都是耗时在节点创建和删除上,无意义的耗时太多了)

Redis分布式锁的性能消耗倒是很小,但是对于业务代码所在的机器性能消耗比较高,因为Redis没有监控机制,导致Redis实现的分布式锁在等待获取锁时必须通过while循环来实现线程阻塞,这就会导致业务代码所在机器的CPU消耗很高,但是锁的获取和释放性能很高(耗时短)。(n个线程while循环访问某一个Redis节点可能确实会对Redis造成压力,但是好在Redis本身的性能很强,单机理论10万的qps还是差不多够用的,当然,你要说一秒上千万的请求打到Redis上的话,当我没说,不谈限流处理,纯粹耍流氓)

综合来看,个人更偏向于使用Redis,因为如果是业务代码所在的机器性能压力大,我们可以通过增加机器来分担,但是Zookeeper实现分布式锁是本身的压力很大,所以就导致锁的获取和释放很慢。实际开发中,我们通常都会选择用空间来换取时间,耗时一定要缩短,可以多部署几台机器来减轻业务代码对CPU造成的压力,但绝对不能让代码运行耗时延迟。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值