Redis实现的分布式锁是完美的吗?
单实例
setnx
是 Redis
官方提供的一个分布式原子性锁,它的实现利用了 Redis
在执行命令的时候是一个原子性操作,所以可以实现同一时间只有任务才能获取到锁。
当在同一个 redis
实例中进行加锁的操作的时候,如果加锁成功则会返回1
,如果加锁失败的话,则是直接返回 0
1 | 127.0.0.1:6379> setnx 'redislock' 1 |
如果此时另一个任务想进行加锁的话,则会返回0
:
1 | 127.0.0.1:6379> setnx 'redislock' 1 |
虽然这样设置就可以实现一个分布式锁,但是如果一个客户端进行了加锁操作,后续自己的系统异常导致进程挂掉了,此时就会导致没有任务来进行解锁,从而导致任意一个客户端都无法再次加锁。
超时时间
redis官方提供了一个命令来支持这个加锁和设置超时时间的原子性命令:
SET key value [EX seconds] [PX milliseconds] [NX|XX]
其中 NX
和 XX
的区别在于,NX
是只有当 key
不存在的时候才设置,而 XX
则是当 key
存在的时候才进行设置。
1 | 127.0.0.1:6379> set k1 v1 EX 10 NX |
这个命令表示当 k1
不存在的时候,设置该 key
,并且将其超时时间设置为 10s
。
这个时间是一般是由业务人员根据业务的特性来指定的,当加入了超时时间以后,如果该 key
在超时时间内没有被主动的调用 del
命令,等超时时间过了以后,该 key
会自动被删除。此时另外一个系统就可以对这个 key
加锁了。
这样设置以后虽然可以解决上面的锁无法释放的问题,但是却又有一个新的问题,就是锁被他人释放了,例如:
这种情况对于业务来说,是绝对不可以接受的,因为对于A任务来讲,它虽然释放了锁,但是它释放的其实是B的锁,此时如果有一个C任务再来加锁,就可以加锁成功了,于是分布式锁就变成了B、C可能在同一时间都在执行一个任务。
随机数
为了保证在只有自己加的锁才能被自己释放,此时每一个任务就需要自己的一个唯一标志,这个标志一定是要全局唯一的。当释放锁的时候,需要判断当前持有锁的ID是否是释放锁的任务ID,但是由于这是一个非原子性的操作,所以此时就需要通过 lua 脚本来执行。
加锁
在这里以 lua 脚本为例,lua脚本可以同时传入多个参数,在一个脚本里面执行,这样就可以判断加锁的value是不是当前传入的value。
1 | 127.0.0.1:6379> set k1 A EX 10 NX |
此时的操作是 A
对 k1
进行加锁,假设 A
是一个全局唯一的,此时 A
对 k1
进行了加锁,并且锁的超时时间是 10s
。
释放锁
由于在这里是以 value 来作为唯一标志的,当释放锁的时候需要把当前的 任务ID
作为 value
传入,然后在删除key的时候,通过以下 lua 脚本来释放锁。
lua脚本:
1 | if (redis.call('exists',KEYS[1]) == 1) and (ARGV[1] == redis.call('get',KEYS[1])) then |
首先加载 lua 脚本
1 | 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;" |
此时可以看到返回了一个hash值,这个值就是代表这个函数,当然也可以不用 hash 函数,每次用 eval 函数执行这个文本即可:
1 | 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 |
此时可以看到以 B 尝试释放这个锁,但是返回的是0,并未释放成功,再次查看该锁:
1 | 127.0.0.1:6379> get k1 |
可以看到 k1 还是被 A 锁持有,尝试以 A 来释放锁:
1 | 127.0.0.1:6379> EVALSHA '51fd717f3d833a79f1a102483df7932d4b71cd69' 1 k1 A |
可以看到已经被释放了,所以这个可以解决锁被其它任务释放的问题,但是还是无法解决超时导致的锁释放的问题。
锁超时
要解决这个问题,需要对超时时间进行续约,即除非 A服务
自己挂掉了让锁自己超时释放掉,否则就必须让A自己释放掉它。
redission
Redission
是一款基于 Java 的 redis 操作 API 库,它采用的是一个 Watch dog
模式来解决这个问题的,具体做法是后台开启一个线程,这个线程每隔一定的时间去检查该锁还有多久超时,然后给这个锁进行续租。
集群
上述 Redis 的分布式锁在单实例的情况下是可以完美运行的,但是一旦涉及到 reids
集群,就会出现重复加锁的情况。假设在一个一主三从的redis架构中,
如果任务 A 对主节点进行加锁成功了,此时主节点突然挂掉了,但是在挂掉之前,其锁没有同步到从节点,此时从节点其中一个晋升为主节点,于是两个任务此时都加锁成功了。
可以看到此时Redis 出现了脑裂情况,在这个情况下,A 和 B 都加锁成功了,但是这也就违背了分布式锁最初的初衷了,于是 RedLock 就被人提出来了。
RedLock
该算法是用 Redis CRC16
算法,计算出所有的 master
节点,然后记一个初始化时间,随后对所有的 master
节点进行加锁,计算出加锁的耗时时间,如果加锁的耗时时间小于超时时间,则接下来就可以执行任务了,否则锁过期了,就必须再次尝试再次加锁。
- 记初始化时间
- 对所有的master加锁
- 计算加锁的耗时
- 判断是否小于过期时间
- 执行任务
缺陷
虽说 RedLock
可以解决某一个 Redis
节点挂掉,导致任务重复执行,但是还是无法避免如下问题:
锁超时
例如如果在第4步的时候,应用出现了阻塞,就会导致锁其实过期了,但是任务 A 在锁过期以后还是在执行。
极度依赖服务器的时间
如果对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
Redis实现的分布式锁是完美的吗?