分布式锁的实现

在单机多线程环境中,JDK 提供了很多线程同步的工具,包括 synchronized 和 Lock 来帮助我们解决线程间代码同步的问题。但是现在的系统多是分布式的,这意味着我们需要一种能够处理进程级别的代码同步的工具,即常说的分布式锁。

分布式锁应该满足的几个特征:

  • 互斥性
    在分布式环境下同一时间只有一个客户端能够获取到锁,也就是保证不同节点的不同线程的互斥。

  • 高性能、高可用
    必须能够高性能、高可用地获取和释放锁。

  • 可重入
    必须是可重入锁,即同一个节点同一个线程可以多次获取锁。

  • 超时
    应该具备锁失效的机制,防止死锁的发生。

  • 阻塞或非阻塞
    应该提供类似 ReentrantLock 的阻塞获取锁的 lock 方法和非阻塞获取锁的 tryLock 方法。

目前常见的实现分布式锁的方式有:基于数据库、基于缓存(Redis、Memcached)和基于 ZooKeeper。

基于数据库

基于数据库的实现方式有很多种,其中比较常见的就是利用数据库的唯一约束,成功插入记录就表示获取到了锁,插入失败则表示没有获取到锁。

1
2
3
4
5
6
7
8
9
10
DROP TABLE IF EXISTS `resource_lock`;
CREATE TABLE `resource_lock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`resource_name` varchar(64) NOT NULL COMMENT '锁定的资源',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '默认自动生成的时间戳',
`count` int(11) NOT NULL DEFAULT '0' COMMENT '统计重入的次数',
`node_info` varchar(128) DEFAULT NULL COMMENT '机器和线程信息',
PRIMARY KEY (`id`),
UNIQUE KEY `unique_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的资源';

创建类似的一张表,如果在使用某个资源时想获取锁,则向数据库中插入一条记录。因为资源名做了唯一性校验,如果有多个请求同时提交到数据库,则只会有一个成功,那么操作成功的我们就认为获取到了锁。在资源使用完后,通过删除该条记录来释放锁。同时为了实现可重入的特性,在表中增加一列用于记录获得锁的主机和线程信息,在下次获取锁时检查当前机器的主机和线程信息与表中数据是否一致,如果一致则可以直接分配锁。

这种方式的优点是实现简单,当然相对的,缺点也很多。因为该方式重度依赖数据库,所以数据库的可用性和性能将直接影响分布式锁,这时可以通过数据库双机部署、主从同步来避免单点问题。为了支持超时释放锁,可以使用定时任务,定时清理超时的锁数据。当然这些优化只是一种补救措施,实际使用中并不推荐通过数据库实现分布式锁。

基于缓存

常用的缓存中间件有 Redis、Memcached 等,其中 Redis 使用较为广泛。Redis 的操作大部分都是基于内存的,所以性能很高,同时所有的单个操作都是原子性的,也支持主从同步,具备高可用性。

Redis 提供了一个命令可以很好地实现锁的功能,这个命令就是 SETNX(SET if Not eXists),调用该命令需要提供一个 key/value 对,只有在 key 不存在时才会设置 value,设置成功后返回 1,失败则返回 0。

1
2
3
4
redis> SETNX lan "Java"
(integer) 1
redis> SETNX lan "Ruby"
(integer) 0

只有 SETNX 可不行,我们还需要实现超时锁失效的机制,因此就需要用到另一个命令 EXPIRE,用法为 EXPIRE key timeout。当超时时间到时,Redis 会自动删除 key。

有了这些命令,我们的思路就是在获取锁时使用 SETNX 命令,设置的 value 值为一个全局唯一的值(如 UUID),这么做是为了确保释放的是自己的锁。同时还要使用 EXPIRE 命令为锁添加一个超时时间,超时则会自动释放锁。在释放锁时使用 DELETE 命令,并判断 value 值是否一致,一致则进行删除。因此我们的实现可能类似这样(使用 Jedis):

1
2
3
4
5
Long result = jedis.setnx(lockKey, uuid);
if (result == 1) {
// 若在这里程序突然崩溃,则无法设置过期时间,将会发生死锁
jedis.expire(lockKey, expireTime);
}

但是很遗憾,这种实现在程序意外崩溃时会导致锁无法释放,引发死锁。在 Redis 2.6.12 版本之前,我们可以利用 Redis 的事务特性来处理。

1
2
3
4
5
6
// 开始事务
Transaction transaction = jedis.multi();
transaction.setnx(lockKey, uuid);
transaction.expire(lockKey, expireTime);
// 提交事务
transaction.exec();

但是这里有一个问题就是,如果有多个客户端的请求同时到达,虽然 SETNX 命令只会有一个客户端执行成功,但是 EXPIRE 命令所有的客户端都可以执行成功,这会导致锁的超时时间一直延长。在 Redis 2.6.12 版本及以后,SET 命令可以通过设置参数实现上面所有的效果并避免超时时间延长的问题。

参数 使用 含义
EX seconds SET key value EX seconds 设置键的过期时间,单位秒,效果等同于 SETEX key seconds value
PX milliseconds SET key value PX milliseconds 设置键的过期时间,单位毫秒,效果等同于 PSETEX key milliseconds value
NX SET key value NX 只有在键不存在时才会设置 value 值,效果等同于 SETNX key value
XX SET key value XX 只有在键存在时才会设置 value 值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 获取锁
*/
String result = jedis.set(lockKey, uuid, "NX", "PX", expireTime);
if ("OK".equals(result)) {
// 设置成功
return true;
}
/**
* 释放锁
*/
String LuaScript = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) else return 0 end";
// 第一个参数是脚本文本,第二个参数是一个 key 的集合,第三个参数是参数集合
Object result = jedis.eval(LuaScript, Collections.singletonList(lockKey),
Collections.singletonList(uuid));
if (result.equals(1L)) {
// 删除成功
return true;
}

这里需要解释的就是释放锁,luaScript 是一段 Lua 脚本,它首先获取 key 对应的 value 值并与传入的 uuid 比较,如果相同则执行删除 key,否则返回 0。之所以使用 Lua 脚本是因为 eval 命令在执行 Lua 脚本时会把它当成一个命令去执行,并且直到 eval 执行完成后 Redis 才能执行其他命令,这就确保了整个过程的原子性。

Redlock

为了保证 Redis 服务的高可用,一般会使用主从 + 哨兵的架构,当主节点宕机能够自动进行故障切换,这个时候如果使用上面提到的分布式锁就可能存在一个问题。

举个例子,假如客户端 A 在主节点拿到了锁,然后主节点在把客户端 A 创建的 key 写入到从节点之前就宕机了。此时由于故障切换,从节点变成了主节点,但是又有一个客户端 B 来申请锁,由于原来的从节点里没有客户端 A 持有的锁信息,所以客户端 B 也获取到了锁。这就违背了分布式锁在同一时间只能有一个客户端获取到锁的原则。

为了解决这个问题,Redis 提出了 Redlock 算法。该算法假设我们有 N 个主节点,这些节点都是完全独立的,不使用任何复制或者其他分布式协调算法。然后继续使用上面提到的获取锁的方式分别在每个节点上获取锁和释放锁。我们假设 N 为 5,也就是说我们有五个独立的主节点,一个客户端具体需要做出以下操作来获取锁:

  1. 获取当前时间(单位是毫秒)。

  2. 使用 SET 设置相同的过期键和唯一值的方式在 5 个节点上请求锁,超时时间的设置需要比总的锁释放时间小的多,比如如果总的锁释放时间是 10 秒钟,那么每个节点锁请求的超时时间可能是 0 到 50 毫秒的范围,这样可以防止一个客户端在某个宕掉的主节点上阻塞过长的时间,如果一个主节点不可用了,我们应该尽快尝试下一个主节点。

  3. 只有当客户端在超过半数的主节点上成功获取到了锁(此处为 3 个或 3 个以上),并且总共消耗的时间不超过总的锁释放时间,我们才认为该客户端真正获取到了锁。

  4. 从成功获取锁开始,锁自动释放的时间就是设定的总的锁释放时间减去之前获取锁消耗的时间。

  5. 如果锁获取失败了,不管是因为获取成功的锁没有达到半数以上,还是因为总消耗时间超过了总的锁释放时间,客户端都会到每个节点上释放锁,即便是那些它认为没有获取成功的锁。

从 Redlock 算法的描述中,我们可以明白为什么该算法需要 N 个独立的节点。假如采用主从架构,客户端在请求到主节点的锁后,在请求从节点的锁时,很有可能该键已经同步到从节点,因此会获取锁失败。而假如是集群架构,由于 Redis 的集群通过划分槽的方式进行数据库分片,客户端使用相同的键请求集群来获取锁时会始终被分配到某一个主节点。

redisson 中提供了 Redlock 的 Java 实现。

基于 ZooKeeper

ZooKeeper 是一个 CP 系统,因此天生就能够用来解决分布式一致性问题。基于 ZooKeeper 实现分布式锁的思路是:将 ZooKeeper 上的一个 ZNode 看作一把锁,通过创建临时节点来实现获取锁的操作,而没有获取到锁的客户端需要在该临时节点上通过 exists 注册 Watcher,当获取锁的客户端因为宕机或者主动删除临时节点释放锁时,其他客户端就可以收到通知,从而重新发送创建临时节点的请求来继续竞争锁。

这种实现思路有一个问题就是在释放锁后会发生惊群效应,所有客户端都会去竞争锁,这里我们使用一种更好的方式。首先需要创建一个父节点,这个节点是 PERSISTENT 类型的。然后在获取锁时在父节点下创建 EPHEMERAL_SEQUENTIAL 类型(临时的、有序的)的节点,如果有多个客户端同时请求获取锁,那么它们都能够成功创建节点。接着客户端在父节点上调用 getChildren(),但是此时不要设置监听,避免惊群效应。将获取到的所有子节点按照节点顺序排序,序号最小的节点就表示获取到了锁,客户端需要判断该节点是否是自己创建的,如果是则该客户端获取到了锁,如果不是此时客户端需要找到序号比自己小的那个相邻节点,然后对其调用 exists 同时设置监听,之后如果被监听的节点被删除,则客户端会收到通知,此时再调用 getChildren() 并排序比较序号,如果自己创建的节点是序号最小的,那么就获取到了锁,否则重复之前的步骤。

操作 ZooKeeper 最常用的第三方实现就是 Apache Curator,它与 ZooKeeper 的关系就像 Google Guava 与 Java。Curator 为我们提供了 curator-recipes 包,里面有很多常用的功能,包括分布式锁、分布式栅栏(Barrier)、分布式队列等。

Curator 实现的分布式锁包括:

  • InterProcessMutex
    分布式可重入锁共享锁,在任意时刻只能有一个客户端获取到锁,可重入。

  • InterProcessSemaphoreMutex
    分布式共享锁,与 InterProcessMutex 区别就是不可重入。

  • InterProcessReadWriteLock
    分布式可重入读写锁,读写锁保持一对相关的锁,一个用于只读操作,一个用于写入操作。可以有多个客户端获取到读锁,而写锁是独占的。

  • InterProcessMultiLock
    多共享锁,将多个锁作为单个实体进行管理的容器,acquire() 方法会获取所有的锁,release() 方法会释放所有的锁。

参考

分布式锁简单入门以及三种实现方式介绍

分布式锁的几种实现方式