分布式锁
1. 基于数据库实现分布式锁
要实现分布式锁,最简单的方式就是创建一张锁表,然后通过操作该表中的数据来实现。
当我们要锁住某个资源时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。数据库对共享资源做了唯一性约束,如果有多个请求被同时提交到数据库的话,数据库会保证只有一个操作可以成功,操作成功的那个线程就获得了访问共享资源的锁,可以进行操作。
基于数据库实现的分布式锁,是最容易理解的。但是,因为数据库需要落到硬盘上,频繁读取数据库会导致 IO 开销大,因此这种分布式锁适用于并发量低,对性能要求低的场景。对于双 11、双 12 等需求量激增的场景,数据库锁是无法满足其性能要求的。而在平日的购物中,我们可以在局部场景中使用数据库锁实现对资源的互斥访问。
优缺点
可以看出,基于数据库实现分布式锁比较简单,绝招在于创建一张锁表,为申请者在锁表里建立一条记录,记录建立成功则获得锁,消除记录则释放锁。该方法依赖于数据库,主要有两个缺点:
- 单点故障问题。一旦数据库不可用,会导致整个系统崩溃。
- 死锁问题。数据库锁没有失效时间,未获得锁的进程只能一直等待已获得锁的进程主动释放锁。一旦已获得锁的进程挂掉或者解锁操作失败,会导致锁记录一直存在数据库中,其他进程无法获得锁。
2. 基于缓存实现分布式锁
数据库的性能限制了业务的并发量,那么对于双 11、双 12 等需求量激增的场景是否有解决方法呢?
基于缓存实现分布式锁的方式,非常适合解决这种场景下的问题。所谓基于缓存,也就是说把数据存放在计算机内存中,不需要写入磁盘,减少了 IO 读写。接下来,我以 Redis 为例与你展开这部分内容。
Redis 通常可以使用 setnx(key, value) 函数来实现分布式锁。key 和 value 就是基于缓存的分布式锁的两个属性,其中 key 表示锁 id,value = currentTime + timeOut,表示当前时间 + 超时时间。也就是说,某个进程获得 key 这把锁后,如果在 value 的时间内未释放锁,系统就会主动释放锁。
setnx 函数的返回值有 0 和 1:
- 返回 1,说明该服务器获得锁,setnx 将 key 对应的 value 设置为当前时间 + 锁的有效时间。
- 返回 0,说明其他服务器已经获得了锁,进程不能进入临界区。该服务器可以不断尝试 setnx 操作,以获得锁。
总结来说,Redis 通过队列来维持进程访问共享资源的先后顺序。Redis 锁主要基于 setnx 函数实现分布式锁,当进程通过 setnx<key,value> 函数返回 1 时,表示已经获得锁。排在后面的进程只能等待前面的进程主动释放锁,或者等到时间超时才能获得锁。
优缺点
相对于基于数据库实现分布式锁的方案来说,基于缓存实现的分布式锁的优势表现在以下几个方面:
- 性能更好。数据被存放在内存,而不是磁盘,避免了频繁的 IO 操作。
- 很多缓存可以跨集群部署,避免了单点故障问题。
- 很多缓存服务都提供了可以用来实现分布式锁的方法,比如 Redis 的 setnx 方法等。
- 可以直接设置超时时间来控制锁的释放,因为这些缓存服务器一般支持自动删除过期数据。
这个方案的不足是,通过超时时间来控制锁的失效时间,并不是十分靠谱,因为一个进程执行时间可能比较长,或受系统进程做内存回收等影响,导致时间超时,从而不正确地释放了锁。
3. 基于 ZooKeeper 实现分布式锁
定义锁
ZooKeeper 通过一个数据节点来表示一个锁,类似于“/shared_lock/[Hostname]-请求类型-序号” 的临时顺序节点,例如 /shared_lock/192.168.0.1-R-0000000001,这个节点就代表了一个锁,如图所示。
获取锁
在需要获取锁时,所有客户端都会到/shared_lock这个节点下面创建一个临时顺序节点:
-
如果当前是读请求,那么就创建如 /shared_lock/192.168.0.1-R-0000000001 的节点;
-
如果当前是写请求,那么就创建如 /shared_lock/192.168.0.1-W-0000000001 的节点。
判断读写顺序
由于不同的事务都可以同时对同一个数据对象进行读取操作,而更新操作必须在当前没有任何事务进行读写操作的情况下进行。基于这个原则,大致可以分为如下4个步骤:
-
创建完节点后,获取/shared_lock节点下的所有子节点,并对该节点注册所有子节点变更的 Watcher 监听。
-
确定自己的节点序号在所有子节点中的顺序。
-
对于读请求:
-
如果没有比自己序号小的子节点,或是所有比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了读锁,同时开始执行读取逻辑。
-
如果比自己序号小的子节点中有写请求,那么就需要进入等待。
对于写请求:
-
如果自己不是序号最小的子节点,那么就需要进入等待。
-
-
接收到 Watcher 通知后,重复步骤1。
释放锁
/exclusive_lock/lock 是一个临时节点,在以下两种情况下可能释放锁:
- 当前获取锁的客户端机器发生宕机,那么ZooKeeper 上的这个临时节点就会被移除。
- 正常执行完业务逻辑后,客户端就会主动将自己创建的临时节点删除。
无论在什么情况下移除了lock节点,ZooKeeper 都会通知所有在 /exclusive_lock 节点上注册了子节点变更Watcher 监听的客户端。这些客户端在接收到通知后,再次重新发起分布式锁获取,即重复“获取锁”过程。
羊群效应
上面讲解的这个锁实现,大体上能够满足一般的分布式集群竞争锁的需求,并且性能都还可以——这里说的一般场景是指集群规模不是特别大,一般是在10台机器以内。但是如果机器规模扩大之后,会有什么问题呢?我们着重来看上面“判断读写顺序”过程的步骤3,结合下图给出的实例,看看实际运行中的情况。
针对图中的实际情况,我们看看会发生什么事情。
- 192.168.0.1 这台机器首先进行读操作,完成读操作后将节点/192.168.0.1- R-0000000001删除。
- 余下的4台机器均收到了这个节点被移除的通知,然后重新从/shared_lock节点上获取一份新的子节点列表。
- 每个机器判断自己的读写顺序。其中192.168.0.2这台机器检测到自己已经是序号最小的机器了,于是开始进行写操作,而余下的其他机器发现没有轮到自己进行读取或更新操作,于是继续等待。
- 继续……
很明显,我们看到,192.168.0.1这个客户端在移除自己的共享锁后,ZooKeeper 发送了子节点变更Watcher通知给所有机器,然而这个通知除了给192.168.0.2这台机器产生实际影响外,对于余下的其他所有机器都没有任何作用。
在这整个分布式锁的竞争过程中,大量的“Watcher通知”和“子节点列表获取”两个操作重复运行,并且绝大多数的运行结果都是判断出自己并非是序号最小的节点,从而继续等待下一次通知——这个看起来显然不怎么科学。
客户端无端地接收到过多和自己并不相关的事件通知,如果在集群规模比较大的情况下,不仅会对ZooKeeper服务器造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个节点对应的客户端完成事务或是事务中断引起节点消失,ZooKeeper服务器就会在短时间内向其余客户端发送大量的事件通知——这就是所谓的羊群效应。
上面这个ZooKeeper分布式共享锁实现中出现羊群效应的根源在于,没有找准客户端真正的关注点。我们再来回顾一下上面的分布式锁竞争过程,它的核心逻辑在于:判断自己是否是所有子节点中序号最小的。于是,很容易可以联想到,每个节点对应的客户端只需要关注比自己序号小的那个相关节点的变更情况就可以了——而不需要关注全局的子列表变更情况。
改进后的分布式锁实现
现在我们来看看如何改进上面的分布式锁实现。首先,我们需要肯定的一点是,上面提到的锁实现,从整体思路上来说完全正确。这里主要的改动在于:每个锁竞争者,只需要关注 /shared_lock 节点下序号比自己小的那个节点是否存在即可,具体实现如下。
-
客户端调用 create() 方法创建一个类似于“/shared_lock/[Hostname]-请求类型-序号”的临时顺序节点。
-
客户端调用 getChildren() 接口来获取所有已经创建的子节点列表,注意,这里不注册任何Watcher。
-
如果无法获取共享锁,那么就调用exist()来对比自己小的那个节点注册Watcher。注意,这里“比自己小的节点”只是一个笼统的说法,具体对于读请求和写请求不一样。
-
读请求:向比自己序号小的最后一个写请求节点注册 Watcher 监听。
-
写请求:向比自己序号小的最后一个节点注册 Watcher 监听。
-
-
等待Watcher通知,继续进入步骤2。
注意
看到这里,相信很多读者都会觉得改进后的分布式锁实现相对来说比较麻烦。确实如此,如同在多线程并发编程实践中,我们会去尽量缩小锁的范围——对于分布式锁实现的改进其实也是同样的思路。那么对于开发人员来说,是否必须按照改进后的思路来设计实现自己的分布式锁呢?答案是否定的。在具体的实际开发过程中,我们提倡根据具体的业务场景和集群规模来选择适合自己的分布式锁实现:在集群规模不大、网络资源丰富的情况下,第一种分布式锁实现方式是简单实用的选择;而如果集群规模达到一定程度,并且希望能够精细化地控制分布式锁机制,那么不妨试试改进版的分布式锁实现。
优缺点
可以看到,使用 ZooKeeper 可以完美解决设计分布式锁时遇到的各种问题,比如单点故障、不可重入、死锁等问题。虽然 ZooKeeper 实现的分布式锁,几乎能涵盖所有分布式锁的特性,且易于实现,但需要频繁地添加和删除节点,所以性能不如基于缓存实现的分布式锁。 Zookeeper实现的分布式锁在中小型公司的普及率不高,尤其是非 Java 技术栈的公司使用的较少,如果只是为了实现分布式锁而重新搭建一套 ZooKeeper 集群,显然实现成本和维护成本太高。