基于Zookeeper的分布式锁原理及实现(上篇)

907 阅读6分钟
原文链接: blog.didiyun.com

1 背景

在单进程应用内,我们经常使用锁来保障多个线程并发访问同一资源的互斥性。在多进程、分布式场景下,如果多个系统或者单个系统的多个节点并发访问同一资源,为了保障对资源读写的互斥性,就需要用到分布式锁。而由于分布式场景比单机场景要复杂很多,分布式锁的实现方式也有很多种。我们这里主要介绍使用Zookeeper 实现分布式锁的原理及实现方式。

2 为什么用Zookeeper来实现分布式锁?

Zookeeper 能够保障分布式场景下数据的一致性、有序性、原子性及可靠性,它的所有写入动作会在 Leader 节点持久化,并在集群过半数节点写入成功才会返回;它也能够支持节点的崩溃恢复以及客户端的最终一致性视图。对于分布式锁场景来说,数据一致性的保障、以及锁服务的容灾保障至关重要。

另外,Zookeeper 还提供了三种在分布式锁场景下非常有用的特性(以下的节点指的是Zookeeper内部存储的 znode 节点):

  1. 临时节点
    客户端可以指定 zk 创建一个临时节点,此节点将在这个客户端与服务端建立的 session 到期时自动删除,这个特性可以保障客户端创建的分布式锁节点在客户端宕机或者网络通讯中断一段时间后自动释放该临时节点,从而避免分布式锁由于客户端或网络原因导致的死锁问题。
  2. 有序节点
    客户端可以指定 zk 创建一个有序节点,此节点将自动在客户端指定的节点名后面添加一个单调递增序号来确保多个客户端同时创建相同的节点名时能够创建成功,并且保障越早创建的节点的序号越小。利用该特性可以实现锁的互斥性和公平性,即同一时刻只有一个客户端能够成功获取到锁(序号最小的一个获取到锁),获取锁失败的节点可以按照创建顺序进行锁等待。
  3. watcher 机制
    可以对一个节点的读操作注册一个 watcher 监听器,当节点有变化时(例如节点被删除或更新)zk 服务端将主动通知注册了监听的客户端。这样对于正在等待锁的客户端可以及时得知锁被释放的事件从而重新进行抢锁动作。

以上三种特性可以结合使用,比如创建一个临时 + 有序节点,再注册一个其它序号节点的watcher监听来感知其他节点的变化。我们可以利用 Zookeeper 原生提供的这些特性实现各种可靠、安全的分布式锁。

3 常用的分布式锁类型

  • 排它锁(MutexLock)
    任意时刻只有一个线程能够获取到锁,其他线程等待持有这把锁的线程释放锁后才能尝试获取锁。
  • 信号量(Semaphore)
    允许多个线程持有一定数量的租约(Lease)。在当前租约数量小于最大租约数时,允许新的请求获取到租约,一旦当前租约数等于最大租约数,则新的请求将等待已获取到租约的线程释放租约后才能尝试获取。一般用来控制访问一个资源池的最大并发度。
  • 读写锁(ReadWriteLock)
    写锁作为排它锁,任意时刻只有一个线程能获取到写锁。读锁作为共享锁,当没有写锁被持有的前提下,允许有多个线程同时获取到读锁。
  • 联锁(MultiLock)
    保障多个不同资源的锁获取或释放的原子性的一种组合锁。多个锁资源被封装成一个联锁后,要么全部获取成功,要么全部获取失败,联锁保障不会出现部分获取成功的情况。

注意:以上提到的”线程”可能属于同一进程内,也可能属于不同进程。Zookeeper 能够保障在跨进程场景下数据的一致性。

4 分布式锁的通用特性

除了 Zookeeper 提供的一致性保障之外,分布式锁一般还需要提供如下的通用特性:

  • 公平性
    在多个客户端抢锁的过程中,需要保障获取锁的公平性,先到达 Zookeeper 抢锁的请求能够先获取到锁(可以基于 Zookeeper 的有序节点特性来实现)。
  • 等待超时
    为了避免死锁,一般在获取锁时都需要传递一个超时时间,超时请求则获取锁失败。
  • 可重入性
    在一个线程内,如果已经持有一把锁,则在这把锁被释放前可以多次重复获取锁,其获取次数和释放次数需要保障一致。

5 分布式锁的实现库

推荐使用 Apache Curator 库来实现分布式锁,它不仅封装了分布式锁的所有实现细节,还提供友好易用的 API。以下是 Curator 已经实现的分布式锁相关功能:

  • 可重入锁:InterProcessMutex 实现了可重入的排它锁,支持锁等待超时、保证获取锁的公平性。
  • 不可重入锁:InterProcessSemaphoreMutex 实现了不可重入的排它锁,支持锁等待超时、保证获取锁的公平性。可以在多个线程间传递和释放锁,从而满足异步调用场景下的锁需求。
  • 信号量:InterProcessSemaphoreV2 实现了信号量,支持信号量等待超时、保证获取信号量的公平性。客户端每次获取信号量成功都会返回一个租约(Lease)对象,建议客户端在 finally 代码块 close 这个租约对象以释放租约。注意在线程重入时,每次获取信号量成功也会占用一个租约。另外,在多进程场景下,可以通过 SharedCountReader 来保障最大租约数的一致性,避免不同的进程设置不同的最大租约数。
  • 读写锁:InterProcessReadWriteLock 实现了可重入读写锁,支持锁等待超时、保证获取锁的公平性。并且支持写锁降级(持有写锁的线程可以同时获取到读锁),不支持读锁升级(持有读锁的线程不能同时获取到写锁)。
  • 联锁:InterProcessMultiLock 实现了联锁,它使用装饰器模式实现多把锁的组合,与可重入锁、不可重入锁实现相同的接口,使得可以像使用单锁一样使用联锁。联锁获取成功代表它拥有的所有内部锁都获取成功,联锁获取失败则会自动释放所有内部已经获取成功的部分锁,从而保证联锁的原子性语义。

总结

以上,我们简要介绍了基于 Zookeeper 实现的分布式锁的基本特性,以及 Curator 这个封装良好的分布式锁的库。下一篇我们将详细介绍每一种锁的实现原理,并从 Curator 源码的角度来详细讲解其实现方式。

本文作者:高嵩