Redis实现的分布式锁是完美的吗?

单实例

setnxRedis 官方提供的一个分布式原子性锁,它的实现利用了 Redis 在执行命令的时候是一个原子性操作,所以可以实现同一时间只有任务才能获取到锁。

当在同一个 redis 实例中进行加锁的操作的时候,如果加锁成功则会返回1,如果加锁失败的话,则是直接返回 0

1
2
127.0.0.1:6379> setnx 'redislock' 1
(integer) 1

如果此时另一个任务想进行加锁的话,则会返回0

1
2
127.0.0.1:6379> setnx 'redislock' 1
(integer) 0

虽然这样设置就可以实现一个分布式锁,但是如果一个客户端进行了加锁操作,后续自己的系统异常导致进程挂掉了,此时就会导致没有任务来进行解锁,从而导致任意一个客户端都无法再次加锁。

超时时间

redis官方提供了一个命令来支持这个加锁和设置超时时间的原子性命令:

SET key value [EX seconds] [PX milliseconds] [NX|XX]

其中 NXXX 的区别在于,NX 是只有当 key 不存在的时候才设置,而 XX 则是当 key 存在的时候才进行设置。

1
2
127.0.0.1:6379> set k1 v1 EX 10 NX
OK

这个命令表示当 k1 不存在的时候,设置该 key ,并且将其超时时间设置为 10s

这个时间是一般是由业务人员根据业务的特性来指定的,当加入了超时时间以后,如果该 key 在超时时间内没有被主动的调用 del 命令,等超时时间过了以后,该 key 会自动被删除。此时另外一个系统就可以对这个 key 加锁了。

这样设置以后虽然可以解决上面的锁无法释放的问题,但是却又有一个新的问题,就是锁被他人释放了,例如:

这种情况对于业务来说,是绝对不可以接受的,因为对于A任务来讲,它虽然释放了锁,但是它释放的其实是B的锁,此时如果有一个C任务再来加锁,就可以加锁成功了,于是分布式锁就变成了B、C可能在同一时间都在执行一个任务。

随机数

为了保证在只有自己加的锁才能被自己释放,此时每一个任务就需要自己的一个唯一标志,这个标志一定是要全局唯一的。当释放锁的时候,需要判断当前持有锁的ID是否是释放锁的任务ID,但是由于这是一个非原子性的操作,所以此时就需要通过 lua 脚本来执行。

加锁

在这里以 lua 脚本为例,lua脚本可以同时传入多个参数,在一个脚本里面执行,这样就可以判断加锁的value是不是当前传入的value。

1
2
127.0.0.1:6379> set k1 A EX 10 NX
OK

此时的操作是 Ak1 进行加锁,假设 A 是一个全局唯一的,此时 Ak1 进行了加锁,并且锁的超时时间是 10s

释放锁

由于在这里是以 value 来作为唯一标志的,当释放锁的时候需要把当前的 任务ID 作为 value 传入,然后在删除key的时候,通过以下 lua 脚本来释放锁。

lua脚本:

1
2
3
4
5
if (redis.call('exists',KEYS[1]) == 1) and (ARGV[1] == redis.call('get',KEYS[1])) then 
return redis.call('del',KEYS[1])
else
return 0
end;

首先加载 lua 脚本

1
2
127.0.0.1:6379> script load "if (redis.call('exists',KEYS[1]) == 1) and (ARGV[1] == redis.call('get',KEYS[1])) then return redis.call('del',KEYS[1]) else return 0 end;"
"51fd717f3d833a79f1a102483df7932d4b71cd69"

此时可以看到返回了一个hash值,这个值就是代表这个函数,当然也可以不用 hash 函数,每次用 eval 函数执行这个文本即可:

1
2
127.0.0.1:6379> eval "if (redis.call('exists',KEYS[1]) == 1) and (ARGV[1] == redis.call('get',KEYS[1])) then return redis.call('del',KEYS[1]) else return 0 end;" 1 k1 B
(integer) 0

此时可以看到以 B 尝试释放这个锁,但是返回的是0,并未释放成功,再次查看该锁:

1
2
127.0.0.1:6379> get k1
"A"

可以看到 k1 还是被 A 锁持有,尝试以 A 来释放锁:

1
2
127.0.0.1:6379> EVALSHA '51fd717f3d833a79f1a102483df7932d4b71cd69' 1 k1 A
(integer) 1

可以看到已经被释放了,所以这个可以解决锁被其它任务释放的问题,但是还是无法解决超时导致的锁释放的问题。

锁超时

要解决这个问题,需要对超时时间进行续约,即除非 A服务 自己挂掉了让锁自己超时释放掉,否则就必须让A自己释放掉它。

redission

Redission 是一款基于 Java 的 redis 操作 API 库,它采用的是一个 Watch dog 模式来解决这个问题的,具体做法是后台开启一个线程,这个线程每隔一定的时间去检查该锁还有多久超时,然后给这个锁进行续租。

集群

上述 Redis 的分布式锁在单实例的情况下是可以完美运行的,但是一旦涉及到 reids 集群,就会出现重复加锁的情况。假设在一个一主三从的redis架构中,

如果任务 A 对主节点进行加锁成功了,此时主节点突然挂掉了,但是在挂掉之前,其锁没有同步到从节点,此时从节点其中一个晋升为主节点,于是两个任务此时都加锁成功了。

可以看到此时Redis 出现了脑裂情况,在这个情况下,A 和 B 都加锁成功了,但是这也就违背了分布式锁最初的初衷了,于是 RedLock 就被人提出来了。

RedLock

该算法是用 Redis CRC16 算法,计算出所有的 master 节点,然后记一个初始化时间,随后对所有的 master 节点进行加锁,计算出加锁的耗时时间,如果加锁的耗时时间小于超时时间,则接下来就可以执行任务了,否则锁过期了,就必须再次尝试再次加锁。

  1. 记初始化时间
  2. 对所有的master加锁
  3. 计算加锁的耗时
  4. 判断是否小于过期时间
  5. 执行任务

缺陷

虽说 RedLock 可以解决某一个 Redis 节点挂掉,导致任务重复执行,但是还是无法避免如下问题:

  1. 锁超时

    例如如果在第4步的时候,应用出现了阻塞,就会导致锁其实过期了,但是任务 A 在锁过期以后还是在执行。

  2. 极度依赖服务器的时间

    如果对A、B、C三个服务器进行加锁,任务 A 已经对A、B、C加锁了,但是此时B、C的服务器时间有问题,导致锁被提前释放了,而此时任务B对B、C加锁了,由于一半以上的master的节点已经加锁成功了,所以此时 任务B 其实也加锁成功了。

总结

对于 Redis 的分布式锁,需要了解其可能出现的问题,然后再来做一些决策,任何一个技术都不是完美的,需要根据业务类型来选择最适合的。

参考资料

http://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

作者

Somersames

发布于

2020-08-03

更新于

2021-12-05

许可协议

评论