Java 基于 ZooKeeper 实现分布式锁需要注意什么?

在前一篇有关 Redis 分布式锁的文章中,我们讨论了几点有关分布式锁的要求:

  1. 操作原子性
  2. 可重入性
  3. 效率

为了满足上述条件,采用 本地锁 + Redis 锁 的方式解决了问题。不过在文章末尾提到,Redis
不保证强一致性,因此对一致性要求很高的场景会存在安全隐患。

本文将讨论使用满足 CP 要求的 ZooKeeper 来实现强一致性的分布式锁。

Zookeeper 分布式锁原理

结合 Redis 的分布式锁实现,我们能够想到最直接的 zk lock 实现方式,可能会是以 ZNode 来类比 redis 的 kv pair:创建一个 ZNode,通过判断其是否存在、以及其值是否与当前 client id 一致来尝试获取一个锁。

然而,结合 zk 的诸多优秀特性,实际上我们能更优雅的实现这一过程:

  1. 创建一个路径为 locknode/{guid}-lock- 的 znode,同时将之设置为 EPHEMERAL_SEQUENTIAL, 其中的 guid 是为了解决一种边缘 case*。因此,我们会创建形如 locknode/{guid}-lock-0000000012 的一个节点。
  2. 尝试获取 locknode 下的所有节点,对其进行排序,若刚刚创建的节点处在第一位,则获取锁成功,退出当前流程。
  3. 若不为第一位,则对整个序列中排在自己持有的路径前一位的路径添加一个 watcher,并检查该前一位节点是否存在
  4. 若前一位节点不存在,跳转至第二步,否则休眠等待。当被 watch 的路径发生变化时(通常是被删除),等待被唤醒并跳转至第二步。

可以看到,上述实现分布式锁的流程,用到了 zk 的两个特性:

  1. sequence node
    • 通过 zk 内部保证的序列来确保获取锁公平(回顾 Redis 的方案,每隔 100ms 重试,是一种抢占式的非公平策略)
    • 每一次获取锁的尝试都会被如实的记录下来,易于观察整个获取锁的过程,也易于 debug
  2. watcher
    • watcher 避免了轮询,每个等待中的路径都只观察其前一位路径,确保锁释放时只会有一个等待者(而不是所有)被唤醒,避免了羊群效应 (herd effect)。

注* guid 的特殊 case:对于EPHEMERAL_SEQUENTIAL节点的创建,假设节点创建成功,但 zk server 在返回创建结果之前 crash,那么在 client 重新连接至 zk 后,其 session 仍然有效,因此节点亦存在。

这时将出现诡异的一幕:某种情况下,该 client 以为自己没有获取到锁(实际上已经拿到了),这时他会再次创建一个 path,并休眠,而另一个 client 一直在等待第一位 path 被释放,但却永远也等不到(本来持有锁的 client 却休眠了)。

通过给 path 增加 guid 前缀的办法,当 client 检测到 create 非正常返回时,会启动 retry 流程:获取所有 children,若其中包含有 guid 的节点,则认为节点已经创建成功。

代码实现

  1. lock()
@Override
public void lock() {
    boolean acquired = false;
    localLock.lock();
    try {
        // reentrant
        acquired = localLock.getHoldCount() > 1;
        if (acquired) {
            return;
        }

        acquire();
        acquired = true;
    } finally {
        if (!acquired) {
            localLock.unlock();
        }
    }
}

Redis 分布式锁中实现类似,zk 分布式锁的 lock() 部分也采用了本地锁+分布式锁结合的方式:首先获取本地锁,之后尝试获取 zk 锁(即acquire())。

这里对于可重入的处理比 Redis 的方案简单一些:
在 Redis 锁中,需要在 Redis 判断当前 client Id 是否与锁中保存的一致。而这里的方案,直接判断本地锁是否重入,若是则直接返回。

之所以能够简化,其原因是 ZooKeeper 锁并没有像 Redis 锁一样给锁加上了超时时间,再结合 ZooKeeper 强一致的特点,因此不会出现本地锁获取到而分布式锁被自动释放的情况。

接下来看看真正获取分布式锁的逻辑:

void acquire() {
    String lockPath = createLockPath();
    if (lockPath.equals(getCurrentFirstPath())) {
        return;
    }

    boolean needDelete = true;
    watcherLock.lock();
    try {
        do {
            Condition condition = watcherLock.newCondition();
            addWatcher(getPreviousPath(lockPath), new LockWatcher(condition));
            condition.await();
        } while ((!lockPath.equals(getCurrentFirstPath())));
        needDelete = false;
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } catch (Exception e) {
        throw new ZkLockException(e);
    } finally {
        watcherLock.unlock();
        if (needDelete) {
            deletePath(lockPath);
        }
    }
}

如上所见,lock() 方法实现了前文中描述的加锁过程:先创建锁路径,然后获取目前排序第一位的锁路径,若与创建的路径相同则直接获取锁,否则获取到前一个路径,对其添加 watcher,并进入休眠,直到被唤醒后获得锁。这里采用了一个 watcherLock 来控制休眠与唤醒。唤醒机制写在 LockWatcher 中:

private class LockWatcher implements Watcher {
    private final Condition currentCondition;

    private LockWatcher(Condition currentCondition) {
        this.currentCondition = currentCondition;
    }

    @Override
    public void process(WatchedEvent event) {
        localLock.lock();
        try {
            currentCondition.signalAll();
        } finally {
            localLock.unlock();
        }
    }
}

通过实现 Watcher 来实现当被监听 path 有变动时释放 Condition 的等待状态

其他逻辑中包含的底层实现如下:

String createLockPath() {
    try {
        return client.create().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(LOCK_PATH);
    } catch (Exception e) {
        throw new ZkLockException(e);
    }
}

private void deletePath(String lockPath) {
    try {
        client.delete().guaranteed().forPath(lockPath);
    } catch (Exception e) {
        // do nothing
    }
}

可以看到,底层实现中主要使用 Curator 来与 zk 进行交互,其中的 clientWatcherRemoveCuratorFramework

实际上 Curator 本身提供了完整的 zk lock 实现,Spring Integration ZooKeeper 中的 LockRegistry 也直接包装了 Curator 的方案,本文以讨论原理为目的,实际使用中还是采用 Curator 更好。

String getCurrentFirstPath() {
    List<String> allSortedPaths = getAllSortedPaths();
    if (allSortedPaths.isEmpty()) {
        throw new ZkLockException();
    }

    return allSortedPaths.get(0);
}

String getPreviousPath(String lockPath) {
    List<String> allSortedPaths = getAllSortedPaths();
    int previousIndex = allSortedPaths.indexOf(lockPath) - 1;
    if (previousIndex < 0) {
        throw new ZkLockException();
    }

    return allSortedPaths.get(previousIndex);
}

private List<String> getAllSortedPaths() {
    try {
        return client.getChildren()
                .forPath(BASE_LOCK_PATH)
                .stream()
                .sorted(Comparator.comparing(path -> path.split("-")[2]))
                .collect(Collectors.toList());
    } catch (Exception e) {
        throw new ZkLockException(e);
    }
}

以上为各种对所有锁路径的排序等操作。

  1. unlock()
@Override
public void unlock() {
    if (!localLock.isHeldByCurrentThread()) {
        throw new IllegalStateException("You do not own the lock");
    }

    if (localLock.getHoldCount() > 1) {
        localLock.unlock();
        return;
    }

    try {
        client.delete().guaranteed().forPath(getCurrentFirstPath());
    } catch (Exception e) {
        throw new ZkLockException(e);
    } finally {
        localLock.unlock();
    }
}

unlock 的过程简单了很多,首先判断线程是否合法,之后判断是否是重入状态,最后直接删除相关节点即可。

在 Curator 的 Lock 实现中(commit f0a09db4423f06455ed93c20778c65aaf7e8b06e 之后的版本),release 锁之前,调用了client.removeWatchers();,经过代码分析,实际上对于 foreground 运行的 ZooKeeper 才删除 watcher,background 运行的不会删除。

总结

采用 ZooKeeper 实现的分布式锁,在实现原理上与 Redis 有一定的区别,它采用临时序列节点的方式实现公平的分布式锁,并通过 Watcher 机制,避免了释放锁时可能产生的羊群效应。

ZooKeeper 以其强一致性的特点,使得采用它实现的分布式锁安全可靠,不过性能相比 Redis 差一些。

实际使用中可以直接采用 Curator 提供的分布式锁方案,Curator Recipes 库包括了可重入、共享锁、信号量、栅栏等多种实现,方便可靠。

原创文章,作者 LENSHOOD, 首发自:https://lenshood.github.io/2020/03/25/zk-distributed-lock/

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,135评论 6 514
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,317评论 3 397
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 167,596评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,481评论 1 296
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,492评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,153评论 1 309
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,737评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,657评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,193评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,276评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,420评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,093评论 5 349
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,783评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,262评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,390评论 1 271
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,787评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,427评论 2 359

推荐阅读更多精彩内容