分布式锁

分布式锁 #

分布式锁是用来解决并发问题的。比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修改,改完了再存回去。如果这样的操 作同时进行了,就会出现并发问题,因为读取和保存状态这两个操作不是原子的。

分布式锁本质上要实现的目标就是在 Redis 里面占一个坑,当别的进程也要来占时,发现已经有人蹲在那里了,就只好放弃或者稍后再试

占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用完了,再调用 del 指令释放茅坑。

> setnx lock:codehole true
OK
... do something critical ...
> del lock:codehole
(integer) 1

但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放。

于是我们在拿到锁之后,再给锁加上一个过期时间,这样即使中间出现异常也可以保证锁会自动释放。

> set lock:codehole true ex 5 nx
OK
... do something critical ...
> del lock:codehole

超时问题 #

Redis 的分布式锁不能解决超时问题,例如:

  1. 加锁和释放锁之间的逻辑执行的太长,以至于超出了过期时间,导致锁过期了。
  2. 这时候第一个线程持有的锁过期了,但是临界区的逻辑还没有执行完。
  3. 这个时候第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格的串行执行。

解决方案 #

tag = random.nextint()  # 随机数
if redis.set(key, tag, nx=True, ex=5):
    do_something()
    redis.delifequals(key, tag)  # 假想的 delifequals 指令

或者:

SET key random_value NX PX 30000

set 指令的 value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key,这是为了确保当前线程占有的锁不会被 其它线程释放,除非这个锁是过期了被服务器自动释放的

设置一个随机字符串 tag 是很有必要的,它保证了一个客户端释放的锁必须是自己持有的那个锁。假如获取锁时 SET 的不是一个随机字 符串,而是一个固定值,那么可能会发生下面的执行序列:

  1. 客户端 1 获取锁成功。
  2. 客户端 1 在某个操作上阻塞了很长时间。
  3. 过期时间到了,锁自动释放了。
  4. 客户端 2 获取到了对应同一个资源的锁。
  5. 客户端 1 从阻塞中恢复过来,释放掉了客户端 2 持有的锁。

但是匹配 value 和删除 key 不是一个原子操作,Redis 也没有提供类似于 delifequals 这样的指令,这就需要使用 Lua 脚本来处理了,因 为 Lua 脚本可以保证连续多个指令的原子性执行。

# delifequals
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这段 Lua 脚本在执行的时候要把前面的 tag 作为 ARGV[1] 的值传进去,把 key 作为 KEYS[1] 的值传进去。

释放锁的操作必须使用 Lua 脚本来实现。释放锁其实包含三步操作:GET、判断和 DEL,用 Lua 脚本来实现能保证这三步的原子性。 否则,如果把这三步操作放到客户端逻辑中去执行的话,就有可能发生与前面第三个问题类似的执行序列:

  1. 客户端 1 获取锁成功。
  2. 客户端 1 访问共享资源。
  3. 客户端 1 为了释放锁,先执行 GET 操作获取随机字符串的值。
  4. 客户端 1 判断随机字符串的值,与预期的值相等。
  5. 客户端 1 由于某个原因阻塞住了很长时间。
  6. 过期时间到了,锁自动释放了。
  7. 客户端 2 获取到了对应同一个资源的锁。
  8. 客户端 1 从阻塞中恢复过来,执行 DEL 操纵,释放掉了客户端 2 持有的锁。

如果不是客户端阻塞住了,而是出现了大的网络延迟,也有可能导致类似的执行序列发生

这个方案,它只是相对安全一点,因为如果真的超时了,当前线程的逻辑没有执行完,其它线程也会乘虚而入。

Redlock 算法 #

上面的加锁方式,是有缺陷的。

antirez 还指出了一个问题,是由 failover 引起的,却是基于单 Redis 节点的分布式锁无法解决的。正是这个问题催生了 Redlock 的出现。

这个问题是这样的。假如 Redis 节点宕机了,那么所有客户端就都无法获得锁了,服务变得不可用。为了提高可用性,我们可以给这个 Redis 节点挂 一个 Slave,当 Master 节点不可用的时候,系统自动切到 Slave 上(failover)。但由于 Redis 的主从复制(replication)是异步的,这可能 导致在 failover 过程中丧失锁的安全性。考虑下面的执行序列:

  1. 客户端 1 从 Master 获取了锁。
  2. Master 宕机了,存储锁的 key 还没有来得及同步到 Slave 上。
  3. Slave 升级为 Master。
  4. 客户端 2 从新的 Master 获取到了对应同一个资源的锁。

客户端 1 和客户端 2 同时持有了同一个资源的锁。锁的安全性被打破。

锁的有效时间 #

锁的有效时间(lock validity time),设置成多少合适呢?如果设置太短的话,锁就有可能在客户端完成对于共享资源的访问之前过期,从而失去保 护;如果设置太长的话,一旦某个持有锁的客户端释放锁失败,那么就会导致所有其它客户端都无法获取锁,从而长时间内无法正常工作。

分布式锁 Redlock #

Redlock 分布式锁,它基于 N 个完全独立的 Redis 节点(通常情况下 N 可以设置成 5)。

运行 Redlock 算法的客户端依次执行下面各个步骤,来完成获取锁的操作:

  1. 获取当前时间(毫秒数)。
  2. 按顺序依次向 N 个 Redis 节点执行获取锁的操作。这个获取操作跟前面基于单 Redis 节点的获取锁的过程相同,包含随机字符串 tag,也包含 过期时间(比如 PX 30000,即锁的有效时间)。为了保证在某个 Redis 节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out), 它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个 Redis 节点获取锁失败以后,应该立即尝试下一个 Redis 节点。这里的失败,应该包含任 何类型的失败,比如该 Redis 节点不可用,或者该 Redis 节点上的锁已经被其它客户端持有(注:Redlock 原文中这里只提到了 Redis 节点不可用的 情况,但也应该包含其它的失败情况)。
  3. 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第 1 步记录的时间。如果客户端从大多数 Redis 节点(>= N/2+1)成功获 取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
  4. 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第 3 步计算出来的获取锁消耗的时间。
  5. 如果最终获取锁失败了(可能由于获取到锁的 Redis 节点个数少于 N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客 户端应该立即向所有 Redis 节点发起释放锁的操作(即前面介绍的 Redis Lua 脚本)。

释放锁的过程比较简单:客户端向所有 Redis 节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。