今天我来和你继续聊一聊分布式锁的使用场景和常见实现。在上一次我们讲到了常见的三种分布式锁方案,分别是基于数据库、ZooKeeper 和 Redis 的实现方案。然后我们着重讲了三种基于数据库的分布式锁实现,也提到了基于数据库的分布式锁实现的一些问题和适用场景。那么,我们接下来详细讲一下基于 ZooKeeper 和 Redis 的分布式锁实现,看看这些方案能否解决上述这些问题。
基于 ZooKeeper 的分布式锁实现
Apache ZooKeeper 是开源的分布式协调服务,设计目标是通过简单易用的接口封装复杂且易出错的分布式一致性服务,形成高效可靠的原语集。ZooKeeper 的典型应用就是提供分布式锁服务。ZooKeeper 有很多种特性可以用来实现分布式锁,这里介绍一种基于临时有序 ZNode 的分布式锁实现方案。
首先来看一下 ZooKeeper 的存储模型。ZooKeeper 使用层级目录的结构来组织存储节点,每个节点称为 ZNode。默认情况下,每个 ZNode 可以存储最多 1MB 的数据。同时,每个 ZNode 下可以包含多个子 ZNode。我们可以看一下 ZNode 存储模型的代码示例:
bash
/
|- [/lock]
| |- [/lock/lock_001]
| └- [/lock/lock_002]
└- [/node1]
...
在 ZooKeeper 中,使用不同的创建参数可以创建不同类型的 ZNode 节点:
-
持久节点
-
CreateMode 为 PERSISTENT 时,创建普通持久节点,存储在该节点上的数据会永久存储在 ZooKeeper 上。
-
CreateMode 为 PERSISTENT_SEQUENTIAL 时,创建有序持久节点,存储在该节点上的数据同样是持久化的,和普通持久节点相比,有序节点的节点名称会自动加上一个全局单调递增的序号。
-
临时节点
-
CreateMode 为 EPHEMERAL 时,创建出来的节点为普通临时节点。临时节点在一个链接 Session 有效期内是活跃的,当连接的 session 过期后,这个 Session 创建的临时节点就会被删除。
-
CreateMode 为 EPHEMERAL_SEQUENTIAL 时,创建出来的节点为有序临时节点,和普通临时节点一样,节点及其存储的数据不是持久的,同时,每创建一个新的有序节点,该节点的名称会自动加上一个全局单调递增的序号。
利用临时有序节点这种全局单调递增,过期自动删除的特性,我们可以构建一个可靠的分布式锁,基本原理如下所述:
-
创建一个持久化节点(以下称父节点),代表一把分布式锁实例。
-
一个线程要想持有这把锁时,在该节点下面创建一个临时有序节点(tryLock)。
-
检查新建的临时节点,如果该节点为父节点下所有子节点中序号最小的节点时,表示加锁成功。
-
如果当前节点不是最小节点,则需要持续检查节点是否为最小,直到获得锁或者超时。可以通过 ZooKeeper 的 Watch 机制,为当前节点的上一个序位的节点设置一个监听,一直阻塞直到收到上一个节点的删除事件,再重新比较节点序号,看是否可以获得锁。
-
在完成需同步协调的业务逻辑后,可以通过手动删除临时节点的方式释放这把锁。
-
如果获得锁的进程因为某些原因挂掉了,这个临时节点也会在 session 超时后自动删除,这把锁也就会自动被释放掉。
以上就是最简单的基于 ZooKeeper 的分布式阻塞公平锁的实现原理,对于实际应用来说,这个实现还是不够的,比如,这个方案不支持可重入。那么如何实现可重入呢?最简单的方式是在获取锁的线程中维护一个锁标记和计数器,每次加锁的时候判断当前线程是否已经获取了这把锁,如果获取了就只将计数器加一。释放锁的时候将计数器减一,如果计数器归零,就执行实际的锁释放逻辑,调用 ZooKeeper 的客户端删除对应的临时节点。
上述锁的实现过程有些冗杂,还需要考虑很多边界条件,这是一个非常细致的工作。所幸的是,这些都不需要我们自己来处理,Apache Curator 项目已经为我们提供了开箱即用、工作可靠、特性丰富的基于 ZooKeeper 的分布式锁实现。一般项目中,直接使用 Apache Curator 提供的分布式锁就足够了。
基于 Redis 的分布式锁实现
Redis 想必你非常熟悉,在业务系统中通常作为高性能内存缓存使用。Redis 提供了丰富的数据结构和多种原子操作接口,同时支持原子化执行 Lua 脚本,基于这种些特性可以快速地实现分布式锁。下面介绍一种 Redis 下简单可靠的分布式锁实现。这个方案基本思路如下:
- 基于
SET key value [expiration EX seconds | PX milliseconds] [NX|XX]
复合命令实现加锁操作,下面是具体的实现代码:
arduino
public static boolean tryLock(String lockKey, String uid, int expireTimeSec) {
String result = jedis.set(lockKey, uid, "NX", "EX", expireTimeSec);
return "OK".equals(result);
}
- 基于 Lua 脚本完成原子化的删除操作实现可靠的解锁操作:
typescript
public static boolean unlock(String lockKey, String uid) {
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] " +
"then " +
"return redis.call('del', KEYS[1]) " +
"else " +
"return 0 " +
"end";
return jedis.eval(script, Lists.newArrayList(lockKey), Lists.newArrayList(uid)).equals(1L);
}
之所以使用 Lua 脚本来完成删除操作,是因为 Redis 没有提供原子性的 del 操作,使用 del 实现释放锁的逻辑时,需要先判断是否持有锁,再进行删除,这个过程不是原子的,存在误删的风险。而 Redis 将 Lua 脚本作为整体来执行,执行期间不会插入其他命令,可以实现原子性的删除操作。
以上就是一个经典的 Redis 分布式锁实现方案,这个方案存在几个明显的缺陷,第一个是单点问题,第二个是需要预先估计超时时间,锁到期后无法续租,如果被锁保护的业务逻辑执行时间超过设置的超时时间,或者出现比如网络阻塞、STW GC 等异常情况,使业务逻辑长时间阻塞,都会导致锁机制失效。从这点看,基于 ZooKeeper 的分布式锁要省心很多。
为了解决单点问题,Redis 的作者提出一种解决方案:Redlock (红锁)。Redlock 依赖多个 Master 节点 (官方推荐大于 5 个),Master 之间彼此独立。Redlock 基本实现流程如下:
-
加锁过程:
-
获取节点当前时间。
-
依次获取所有节点的锁,每个节点加锁的超时时间都需要依次减去前面节点加锁所耗的时间总和。
-
如果在超时时间内没有完成所有节点的加锁操作,就认为加锁失败。增加这个超时时间的约束主要是为了保证获取的锁始终是有效的。
-
判断是否加锁成功,如果成功获取了超过半数的节点的锁,则认为加锁成功,否则加锁失败,需要释放锁。这一点和 ZooKeeper 非常类似,分布式系统中,异常是常态,大多数节点正常就能保证整个系统的正确性,因此,只要获取到大多数节点的锁,就能保证锁的正常工作。
-
释放锁:需要释放所有节点上的锁,因为加锁过程中虽然可能只"成功"地获取了大多数节点的锁,并不代表失败节点没有实际加锁了。可以看出,Redlock 整个算法十分依赖节点的系统时间,如果节点时间不同步,将无法保证锁正常工作。另外,加锁和释放锁的逻辑也体现了分布式系统的复杂性。
Redlock 是非常完备的解决方案,但天底下没有完美的分布式锁,围绕 Redlock 的设计,《Designing Data-Intensive Applications》的作者 Martin 和 Redis 作者 antirez 曾经有一场精彩的论战,有兴趣的可以深入了解下。
基于 Redis 的分布式锁也有完备的开源实现:Redisson,支持多种类型的锁和同步量,简单易用。在实际项目中,直接使用该开源实现即可。
总结
我们主要讲了一下基于 ZooKeeper 和 Redis 的分布式锁实现。
Apache Curator 项目为我们提供了开箱即用、工作可靠、特性丰富的基于 ZooKeeper 的分布式锁实现,对于一般项目而言,使用 Apache Curator 提供的分布式锁就足够了。而 Redis 提供了丰富的数据结构和多种原子操作接口,同时支持原子化执行 Lua 脚本,基于这种些特性可以快速地实现分布式锁。
实际上,工程实践中分布式锁的实现方式远不止本节提到的这些,比如基于 Google Chubby 的分布式锁、基于 Etcd 的分布式锁等。但是不管是哪种锁的实现,都依赖底层存储系统提供的原子特性,并且都是基于全局一致性的约束而实现的。还是那句话,天下没有完美的分布式锁,每个分布式锁的实现都无法避开分布式系统与生俱来的复杂性。