分布式锁 #
分布式锁是用来解决并发问题的。比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修改,改完了再存回去。如果这样的操 作同时进行了,就会出现并发问题,因为读取和保存状态这两个操作不是原子的。
分布式锁本质上要实现的目标就是在 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 的分布式锁不能解决超时问题,例如:
- 加锁和释放锁之间的逻辑执行的太长,以至于超出了过期时间,导致锁过期了。
- 这时候第一个线程持有的锁过期了,但是临界区的逻辑还没有执行完。
- 这个时候第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格的串行执行。
解决方案 #
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 从阻塞中恢复过来,释放掉了客户端 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 访问共享资源。
- 客户端 1 为了释放锁,先执行
GET
操作获取随机字符串的值。 - 客户端 1 判断随机字符串的值,与预期的值相等。
- 客户端 1 由于某个原因阻塞住了很长时间。
- 过期时间到了,锁自动释放了。
- 客户端 2 获取到了对应同一个资源的锁。
- 客户端 1 从阻塞中恢复过来,执行
DEL
操纵,释放掉了客户端 2 持有的锁。
如果不是客户端阻塞住了,而是出现了大的网络延迟,也有可能导致类似的执行序列发生。
这个方案,它只是相对安全一点,因为如果真的超时了,当前线程的逻辑没有执行完,其它线程也会乘虚而入。
Redlock 算法 #
上面的加锁方式,是有缺陷的。
antirez 还指出了一个问题,是由 failover 引起的,却是基于单 Redis 节点的分布式锁无法解决的。正是这个问题催生了 Redlock 的出现。
这个问题是这样的。假如 Redis 节点宕机了,那么所有客户端就都无法获得锁了,服务变得不可用。为了提高可用性,我们可以给这个 Redis 节点挂 一个 Slave,当 Master 节点不可用的时候,系统自动切到 Slave 上(failover)。但由于 Redis 的主从复制(replication)是异步的,这可能 导致在 failover 过程中丧失锁的安全性。考虑下面的执行序列:
- 客户端 1 从 Master 获取了锁。
- Master 宕机了,存储锁的 key 还没有来得及同步到 Slave 上。
- Slave 升级为 Master。
- 客户端 2 从新的 Master 获取到了对应同一个资源的锁。
客户端 1 和客户端 2 同时持有了同一个资源的锁。锁的安全性被打破。
锁的有效时间 #
锁的有效时间(lock validity time),设置成多少合适呢?如果设置太短的话,锁就有可能在客户端完成对于共享资源的访问之前过期,从而失去保 护;如果设置太长的话,一旦某个持有锁的客户端释放锁失败,那么就会导致所有其它客户端都无法获取锁,从而长时间内无法正常工作。
分布式锁 Redlock #
Redlock 分布式锁,它基于 N 个完全独立的 Redis 节点(通常情况下 N 可以设置成 5)。
运行 Redlock 算法的客户端依次执行下面各个步骤,来完成获取锁的操作:
- 获取当前时间(毫秒数)。
- 按顺序依次向 N 个 Redis 节点执行获取锁的操作。这个获取操作跟前面基于单 Redis 节点的获取锁的过程相同,包含随机字符串
tag
,也包含 过期时间(比如 PX 30000,即锁的有效时间)。为了保证在某个 Redis 节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out), 它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个 Redis 节点获取锁失败以后,应该立即尝试下一个 Redis 节点。这里的失败,应该包含任 何类型的失败,比如该 Redis 节点不可用,或者该 Redis 节点上的锁已经被其它客户端持有(注:Redlock 原文中这里只提到了 Redis 节点不可用的 情况,但也应该包含其它的失败情况)。 - 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第 1 步记录的时间。如果客户端从大多数 Redis 节点(
>= N/2+1
)成功获 取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。 - 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第 3 步计算出来的获取锁消耗的时间。
- 如果最终获取锁失败了(可能由于获取到锁的 Redis 节点个数少于
N/2+1
,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客 户端应该立即向所有 Redis 节点发起释放锁的操作(即前面介绍的 Redis Lua 脚本)。
释放锁的过程比较简单:客户端向所有 Redis 节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。