redis分布式锁的各种实现方案以及问题点分析
redis的分布式锁暂时发现如下几种实现:
- 通过 SETNX, GET, GETSET实现。
- 通过 SET key value NX EX milliseconds 实现。
- Redisson
- Redlock
CAP定理
一致性(Consistency),可用性(Availability), 分区容错性(Partition Tolerance)。
任何一个分布式系统最多同时满足以上两点,所以通常需要牺牲其中一点来最大化满足另两点。
上面几种实现方式各有问题,适合业务的就是最好的。
通过 SETNX, GET, GETSET实现:
因为通过第二种方式可以实现SETNX, GETSET等操作,所以redis正考虑在未来版本弃用SETNX, SETEX, GETSET。 原文(Note: Since the SET command options can replace SETNX, SETEX, PSETEX, GETSET, it is possible that in future versions of Redis these commands will be deprecated and finally removed.)
- SETNX key value。
将key的值设置为value,当且仅当key不存在。 设置成功返回1, 设置失败返回0。 - GETSET key value。
将key的值设置为value,并且将旧的值返回,如果key不存在,返回nil。 - GET key。
获取key的值,如果key不存在,那么返回nil。
锁实现:
# 线程1获取锁
127.0.0.1:6379> setnx lock:a 1623837618
# 获取成功
(integer) 1
# 线程2获取锁
127.0.0.1:6379> setnx lock:a 1623837618
# 获取失败
(integer) 0
# 线程1释放锁
127.0.0.1:6379> del lock:a
(integer) 1
这种情况下实现了基本的锁互斥,但是存在一个问题:
- 出现死锁:如果线程1 出现问题,导致锁没有释放。
为了解决这个问题,有两个解决方案,一个是为锁添加过期时间,一个是将锁的值设置时间戳,抢占的时候判断是否过期。
为锁添加过期时间:
# 线程1获取锁
127.0.0.1:6379> setnx lock:a 1623837618
(integer) 1
# 设置锁最多10秒过期
127.0.0.1:6379> expire lock:a 10
(integer) 1
# 线程2获取锁
127.0.0.1:6379> setnx lock:a 1623837619
# 获取失败
(integer) 0
# 线程1释放锁
127.0.0.1:6379> del lock:a
(integer) 1
将锁的值设置为时间戳,抢占时判断是否过期:
# 线程1先检查锁是否存在
127.0.0.1:6379> get lock:a
(nil)
# 线程2先检查锁是否存在
127.0.0.1:6379> get lock:a
(nil)
# 线程1通过getset获取锁, 因为旧值为空,所以获取到锁
127.0.0.1:6379> getset lock:a 1623837618
(nil)
# 线程2通过getset获取锁, 因为返回的时间戳并未过期,所以没有获取到锁
127.0.0.1:6379> getset lock:a 1623837619
"1623837618"
上面两种方式通常情况下都不会导致超时死锁,但是依然存在两个问题。
- 锁被抢占:超时时间定多少?有可能线程1任务处理时间过长,导致线程2提前抢占锁。
- 锁被误删: 线程1超时处理完任务后,释放了锁,但是锁已被线程2提前抢占到,那么对于线程2,锁被误删了。
- 仍然出现死锁: 假设redis主服务器挂掉, 从库转为主库,但是主从只同步了setnx,没有同步expire,那么此时的锁没有设置过期时间,仍然出现死锁。
通过 SET key value NX EX milliseconds 实现:
为了解决问题3 主服务器挂掉,锁不会超时的问题,可以使用SET key value NX EX milliseconds实现,因为这条命令实现了SETNX, EXPIRE的效果,那么在主从同步命令的时候,一定也是作为一条命令同步过去的,所以即使在主服务器执行完这条命令后挂掉,从服务器也不会出现死锁。因为假设这条命令主从同步了,那么从服务器就设置了锁也设置了过期时间,假设这条命令没同步,那么从服务器也就没有锁。
使用SET来设置锁也是redis建议的方式。
# 线程1获取到锁
127.0.0.1:6379> set look:a 1623837618 NX EX 10
OK
# 线程2获取锁失败
127.0.0.1:6379> set look:a 1623837618 NX EX 10
(nil)
# 线程1删除锁
127.0.0.1:6379> del lock:a
(integer) 1
为了解决问题2 锁被误删,可以将值设置为每个线程唯一的随机数或线程id,在释放的时候进行值判断,保证只释放自己线程持有的锁。
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
即使存在问题1,那么也能满足很多业务了,同时只要解决了问题1,问题2也就不存在了。
但是如果想解决问题1,只能从设计上考虑,比如线程1获取锁后,启动一个辅助线程,定期的更新锁过期时间,通常(超时时间/3)更新一次,来防止锁被提前释放。
Redisson
Redisson是一个较为广泛应用的开源项目,https://github.com/redisson/redisson
它就是通过不断地更新锁过期时间来续命来解决锁超时问题。
Redssion使用见https://blog.51cto.com/xvjunjie/2428610
不论哪一种实现,都存在一个问题
- 线程1从主服务器获取锁后,主挂掉了,锁还没来得及同步到从,从切换为主后,这个时候线程2仍能获取倒锁。
Redlock算法
Redlock主要思想是从多个redis主节点获取锁,当获取成功的锁个数大于节点数一半的时候,才算获取成功。
具体Redlock需要执行以下几个步骤:
- 获取当前Unix时间,以毫秒为单位
- 依次尝试从5个实例,使用相同的key和具有唯一性的value获取锁当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间,这样可以防止在锁获取失败客户端仍然死等。
- 客户端使用当前时间减去开始获取锁时间就得到获取锁使用的时间。当且仅当从半数以上的Redis节点取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间,这个很重要
- 如果因为某些原因,获取锁失败(没有在半数以上实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁,无论Redis实例是否加锁成功,因为可能服务端响应消息丢失了但是实际成功了(客户端请求的响应成功了,但是因为网络原因,服务端返回的响应丢失了),毕竟多释放一次也不会有问题
参考
https://redis.io/commands/set
https://www.infoq.cn/article/dvaaj71f4fbqsxmgvdce
https://www.bookstack.cn/read/redis-tutorial/18.md
https://blog.51cto.com/xvjunjie/2428610
https://juejin.cn/post/6844904039218429960