Zookeeper实现分布式锁

本文为个人学习笔记整理,仅供交流参考,非专业教学资料,内容请自行甄别。

文章目录


概述

实现分布式锁的思路有很多,关键点在于利用一个第三方组件,对多服务实例进行管理。常见的实现分布式锁的思路:

  • 使用数据库实现,利用表的唯一索引的约束,在数据库中建一张表,给某个字段加上唯一索引,在执行业务代码之前,首先往该表中插入一条记录,分为以下两种情况。
    • 如果插入成功,则继续执行业务,业务执行完成后,删除该条记录。
    • 如果插入失败,则会抛出数据库唯一索引的异常,则捕获异常,进行等待重试。
  • 使用Redis的set nx ex实现,如果自己去实现,会有非常多的问题。
  • 使用redisson或zookeeper等成熟的解决方案实现,无论是使用数据库还是redis,自己实现的分布式锁都是有非常多问题的,例如死锁,可重入性,续约,解锁判断等。Redisson实现的分布式锁,适用于对于并发要求较高,性能要求较高的场景,但是一致性不能很好的得到保证,例如在主从复制的过程中出现了问题,从节点没有同步到主节点的锁。基于Zookeeper实现的分布式锁,则是更加偏向于对于可靠性和一致性要求较高的场景,但是性能不如Redisson。

使用Zookeeper实现分布式锁,可以利用临时节点,或临时有序节点,以及使用Curator框架。既然有成熟的解决方案,前两者自己手动实现肯定是不推荐的,但是在这里做一个简单实现,主要是学习思路。

一、基于Zookeeper临时节点的分布式锁

首先,Zookeeper实现锁,是要用临时节点的,利用临时节点服务器重启,客户端与 Zookeeper 的会话失效则删除的特性,防止死锁的问题。

临时节点实现分布式锁的思路,某个线程尝试获取锁,创建一个临时节点:

  • 该节点不存在,则创建节点,加锁成功,执行业务代码,业务执行完成后删除节点。

  • 该节点存在,则加锁失败,进入等待方法,监听节点(回调方法中,监听到节点被删除,解除阻塞),然后阻塞自身。

首先需要定义一个锁规范的顶级接口。定义了加锁和解锁两个方法。

java 复制代码
public interface Lock {

    /**
     * 加锁
     * @return
     */
    void lock() throws InterruptedException, KeeperException;

    /**
     * 解锁
     */
    void unlock() throws InterruptedException, KeeperException;
}

然后定义一个中间抽象层,实现lock方法:

  • 首先尝试获取锁,获取到锁则执行业务代码
  • 否则等待加锁,并且再次重试。
java 复制代码
public abstract class AbstractLock implements Lock {


    @Override
    public void lock() throws InterruptedException, KeeperException {
        if (tryLock()) {
            System.out.println("获取锁");
        } else {
            //等待加锁(判断该路径对应的节点在zk中是否存在,并且设置监听器,监听节点删除事件,配合并发工具配合阻塞和解除阻塞)
            waitForLock();
            //再次尝试加锁
            lock();
        }

    }

    /**
     * 尝试加锁
     *
     * @return
     */
    public abstract boolean tryLock();

    /**
     * 等待锁
     */
    public abstract void waitForLock() throws InterruptedException, KeeperException;
}

具体的实现类:

java 复制代码
public class TemporaryNodeLock extends AbstractLock {
    
    /**
     * 得到zk连接
     */
    private ZooKeeper zooKeeper = null;

    private final static String CONNECT_STR = "192.168.198.128:2181";

    private final String LOCK_PATH = "/lock1";

    private final String LOCK_DATA = "lock1";

    public TemporaryNodeLock() {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        try {
            zooKeeper = new ZooKeeper(CONNECT_STR, 30000, new Watcher() {
                @Override
                public void process(WatchedEvent event) {
                    if (event.getType() == Event.EventType.None && event.getState() == Event.KeeperState.SyncConnected) {
                        countDownLatch.countDown();
                        System.out.println("连接建立成功");
                    }
                }
            });
            countDownLatch.await();
        } catch (IOException e) {
            throw new RuntimeException("zk连接初始化错误");
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 尝试加锁
     *
     * @return
     */
    @Override
    public boolean tryLock() {
        try {
            zooKeeper.create(LOCK_PATH, LOCK_DATA.getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
        } catch (Exception e) {
            return false;
        }
        return true;
    }

    /**
     * 等待锁
     */
    @Override
    public void waitForLock() throws InterruptedException, KeeperException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        //判断节点是否存在,并且绑定监听事件
        Stat stat = zooKeeper.exists(LOCK_PATH, event -> {
            // 发生了LOCK_PATH的删除事件
            if (event.getType() == Watcher.Event.EventType.NodeDeleted && event.getPath().equals(LOCK_PATH)) {
                countDownLatch.countDown();
            }
        });
        if (stat != null) {
            countDownLatch.await();
        }
    }

    /**
     * 解锁
     */
    @Override
    public void unlock() throws InterruptedException, KeeperException {
        zooKeeper.delete(LOCK_PATH, -1);
    }

}

二、基于Zookeeper临时有序节点的分布式锁

按照上述的思路,在不考虑重入,续约等情况下 ,即可实现简单的分布式锁,但是有优化的空间。假设有三十个线程,A线程通过tryLock方法成功执行,获取到了锁,其他二十九个线程,都没有获取到,在waitForLock方法中等待。当A线程执行完业务代码,执行unlock解锁操作时,其他二十九个线程,同时监听到了解锁的事件,都被唤醒,重新执行tryLock方法尝试加锁,但是最后同样只有一个线程能加锁成功,其余的线程依旧要继续等待,唤醒了不必要的节点,这就称之为惊群效应

应该是需要避免的,否则可能造成高并发场景下服务器瞬时压力过大。那可以换一种思路,使用临时有序节点的特性,使用临时有序节点,在根节点下创建出的节点,是类似于这样的:

/lock/sub0000000001

/lock/sub0000000002

...

/lock/sub0000000030

既然同一时间,只有一个线程能获取到锁,大多数的线程都需要阻塞,那么就可以列表中的后一个节点,监听前一个节点。,当前一个节点执行完成业务代码删除节点解锁,后一个节点监听到事件,被唤醒,继续执行。

实现思路,同样需要定义一个锁规范的顶级接口。定义了加锁和解锁两个方法,但是具体的实现类,可以不需要抽象层,而是直接重写加锁和解锁两个方法即可。

java 复制代码
@Slf4j
public class TemporarySequenceNodeLock implements Lock {

    private final String CONNECT_STR = "192.168.198.128:2181";

    private String ROOT_LOCK_PATH = "/lock";

    private String ROOT_LOCK_NAME = "lock";

    private String CHILDREN_LOCK_PATH = "/sub";

    private CountDownLatch connectCountDownLatch = new CountDownLatch(1);

    private CountDownLatch lockCountDownLatch = new CountDownLatch(1);

    private String waitPath = null;

    public ZooKeeper zooKeeper = null;

    public TemporarySequenceNodeLock() {

        try {
            zooKeeper = new ZooKeeper(CONNECT_STR, 30000, new Watcher() {
                @Override
                public void process(WatchedEvent event) {
                    //监听连接事件
                    if (event.getType() == Event.EventType.None && event.getState() == Event.KeeperState.SyncConnected) {
                        connectCountDownLatch.countDown();
                        System.out.println("连接建立成功");
                    }
                    //监听解锁事件
                    if (event.getType() == Event.EventType.NodeDeleted && event.getPath().equals(waitPath)) {
                        lockCountDownLatch.countDown();
                    }

                }
            });
            connectCountDownLatch.await();
        } catch (IOException e) {
            throw new RuntimeException("zk连接初始化错误");
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }


    /**
     * 加锁
     *
     * @return
     */
    @Override
    public void lock() throws InterruptedException, KeeperException {
        //创建根节点(永久节点)
        try {
            zooKeeper.create(ROOT_LOCK_PATH, ROOT_LOCK_NAME.getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
        } catch (KeeperException e) {
            if (e.code() == KeeperException.Code.NODEEXISTS) {
                log.info("节点已存在,创建失败");
            }
        }

        //在根节点下创建临时有序节点
        final String path = ROOT_LOCK_PATH + CHILDREN_LOCK_PATH;
        // /lock/sub0000000005
        String currentNode = zooKeeper.create(path, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);

        ZkNodeThreadLocal.set(currentNode);

        //获取该根节点下的所有子节点
        List<String> subs = zooKeeper.getChildren(ROOT_LOCK_PATH, false);

        //如果只有一个节点,直接获取到锁
        if (subs.size() == 1) {
            log.info("当前集合只有一个节点,获取锁成功:{}", Thread.currentThread().getName());
            return;
        }
        //对节点进行排序
        //sub0000000000
        //sub0000000001
        Collections.sort(subs);

        //获取当前节点在集合中的位置
        int lastIndexOf = currentNode.lastIndexOf("/");
        String subNode = currentNode.substring(lastIndexOf + 1);

        int index = subs.indexOf(subNode);

        if (index == -1) {
            throw new RuntimeException("index error!");
        }
        //如果当前元素的索引是集合中的第一个元素,则直接加锁成功
        if (index == 0) {
            log.info("当前节点是集合中的头部元素,获取锁成功:{}", Thread.currentThread().getName());
            return;
        }
        //否则等待
        waitPath = ROOT_LOCK_PATH + "/" + subs.get(index - 1);
        zooKeeper.getData(waitPath, true, new Stat());
        lockCountDownLatch.await();

    }


    /**
     * 解锁
     */
    @Override
    public void unlock() throws InterruptedException, KeeperException {

        String currentNode = ZkNodeThreadLocal.get();

        zooKeeper.delete(currentNode, -1);
        log.info("{}删除节点成功", Thread.currentThread().getName());
    }
}

三、Curator 分布式锁

上面两种方案,仅仅是提供了一种实现分布式锁的思路,如果要深究是存在很多问题的,也不能直接在生产环境使用:

  • 每个Lock中,都持有一个自己的Zookeeper连接,而连接是有上限的。
  • 不支持锁重入
  • 无法处理续约
  • 未处理锁误删

Curator是一个成熟的解决方案,底层依旧使用的是有序临时节点的思想,但是支持锁重入,以及读写锁等模式的实现。

java 复制代码
public class Test implements Runnable {

    private final static String CONNECT_STR = "192.168.198.128:2181";


    private static final CuratorFramework CLIENT = CuratorFrameworkFactory.builder().connectString(CONNECT_STR)
            .retryPolicy(new ExponentialBackoffRetry(100, 1)).build();

    private OrderCodeGenerator orderCodeGenerator = new OrderCodeGenerator();

    //可重入互斥锁
    final InterProcessMutex lock = new InterProcessMutex(CLIENT, "/curator_lock");

    public static void main(String[] args) {
        CLIENT.start();
        for (int i = 0; i < 30; i++) {
            new Thread(new Test()).start();
        }
    }

    /**
     * When an object implementing interface {@code Runnable} is used
     * to create a thread, starting the thread causes the object's
     * {@code run} method to be called in that separately executing
     * thread.
     * <p>
     * The general contract of the method {@code run} is that it may
     * take any action whatsoever.
     *
     * @see Thread#run()
     */
    @Override
    public void run() {
        try {
            // 加锁
            lock.acquire();

            String orderCode = orderCodeGenerator.getOrderCode();
            System.out.println("生成订单号 " + orderCode);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                // 释放锁
                lock.release();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    static class OrderCodeGenerator {

        private static int count = 0;

        /**
         * 生成订单号
         */
        public String getOrderCode(){
            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMddhhmmss");
            return simpleDateFormat.format(new Date()) + "-" + ++count;
        }


    }
}
相关推荐
MarcoPage3 小时前
Python 字典推导式入门:一行构建键值对映射
java·linux·python
脸大是真的好~3 小时前
黑马JAVAWeb-11 请求参数为数组-XML自动封装-XML手动封装-增删改查-全局异常处理-单独异常分别处理
java
Hello.Reader5 小时前
Data Sink定义、参数与可落地示例
java·前端·网络
毕设源码-钟学长5 小时前
【开题答辩全过程】以 分布式菌菇销售系统为例,包含答辩的问题和答案
分布式
2401_837088506 小时前
stringRedisTemplate.opsForHash().entries
java·redis
lkbhua莱克瓦248 小时前
Java基础——集合进阶3
java·开发语言·笔记
码事漫谈8 小时前
智能体颠覆教育行业调研报告:英语、编程、语文、数学学科应用分析
后端
蓝-萧8 小时前
使用Docker构建Node.js应用的详细指南
java·后端
多喝开水少熬夜8 小时前
Trie树相关算法题java实现
java·开发语言·算法