基于 Redis 实现的分布式锁
一个严谨的的流程如下:
加锁:SET lock_key $unique_id EX $expire_time NX
操作共享资源
释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁
加锁SETNX
锁已经存在 -> 加锁失败 ->没有获取到锁
锁不存在 -> 加锁成功 -> 操作共享资源 -> 释放锁
加锁成功后的特殊情况
死锁:
- 获取到锁的服务,在释放锁之前宕机,锁一直持有 -> 需要给锁(redis key)设置超时时间,保证获得锁的服务宕机后,锁可以自动释放
- SETNX成功,EXPIRE还未执行,服务宕机 -> redis2.6.12之后 SET..EX..NX操作,可能保证SETNX+EXPIRE是原子操作
锁被别人释放:
- 设置超时时间会导致另外一个问题
获取到锁的服务A在操作共享资源时超过超时时间,导致另一个服务B加锁成功,
但是服务A操作完成后,仍然会释放锁资源,但实际释放的是服务B的锁。
-> 加锁时,放入唯一标识
-> 释放锁时,先判断锁是否归属自己,再DEL(需要Lua脚本保证原子执行,特殊情况:判断锁归属自己,未执行DEL前,锁过期,其他服务加锁成功,该服务随后执行删除)
锁过期时间不好评估:
- 冗余过期时间
- 守护线程,自动续期(Redisson)
主从集群 + 哨兵的模式下分布式锁的问题
- 在主库1执行SET指令成功,加锁成功
- 此时主库1宕机,SET命令还未同步到从库
- 从库被哨兵选举为新主库,锁丢失。
解决方式:红锁Redlock
Redlock 整体的流程
- 客户端先获取「当前时间戳T1」
- 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
- 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
- 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
- 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)
主要4点
- 客户端在多个 Redis 实例上申请加锁(本质为了容错,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用)
- 必须保证大多数节点加锁成功(分布式系统容错问题:如果只存在故障节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的,拜占庭将军问题)
- 大多数节点加锁的总耗时,要小于锁设置的过期时间(多个实例加锁,总耗时若大于过期时间,某些实例锁可能已经过期)
- 释放锁,要向全部节点发起释放锁请求(客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。 所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁)
Redlock的问题
分布式系统会遇到的三座大山:NPC。
N:Network Delay,网络延迟
P:Process Pause,进程暂停(GC)
C:Clock Drift,时钟漂移
NC问题
若ABCDE五个节点
客户端1拿到了ABC节点的锁,DE网络原因没有获得,客户端1任务自己拿到了锁
此时C节点发生时钟漂移,向前跳跃导致锁过期
客户端2拿到了CDE节点的锁,AB由于网络原因没有获得
客户端1和2都得到了半数以上节点的锁,都认为自己获得了锁。
P问题
如何进程暂停,比如GC,发生在加锁过程中,第三步计算总耗时可以解决这个问题,
如果再加锁成功之后,操作共享资源过程中超时,属于超时时间没有准确预估的问题,
可以采用守护线程为锁自动续期的方式来实现
Zookeeper分布式锁时优劣:
Zookeeper 的优点:
1.不需要考虑锁的过期时间,断开连接,锁就释放了
2.watch 机制,加锁失败,可以 watch 等待锁释放,实现乐观锁
但它的劣势是:
1.性能不如 Redis
2.部署和运维成本高
3.客户端与 Zookeeper 的长时间失联,锁被释放问题