Redis 分布式锁

141 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第28天,点击查看活动详情

为什么需要分布式锁

单机场景下

在单机场景下,写多线程程序的时候,为了避免同时操作一个共享变量产生数据的一致性问题,通常使用一把锁来实现互斥,保证共享变量的正确性,使用范围在同一进程中

微服务场景下

在微服务架构下, 一个业务会部署多个进程,多个进程如果要修改 MySQL 中的同一行记录,同样为了保证数据的一致性,就需要分布式锁来实现

原理

想实现分布式锁,必须借助一个外部系统,所有进程都去这个系统申请加锁。

而这个外部系统,必须实现互斥的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。

而 MySQL、 Redis 或Zookeeper 都可以实现这个功能

  1. 基于 MySQL 中的锁:MySQL 本身有自带的悲观锁 for update 关键字,也可以自己实现悲观/乐观锁来达到目的;
  2. 基于 Zookeeper 有序节点:Zookeeper 允许临时创建有序的子节点,这样客户端获取节点列表时,就能够当前子节点列表中的序号判断是否能够获得锁;
  3. 基于 Redis 的单线程:由于 Redis 是单线程,所以命令会以串行的方式执行,并且本身提供了像 SETNX(set if not exists) 这样的指令,本身具有互斥性;

MySQL 实现起来需要额外考虑锁超时、加事务,并且性能取决于数据库,要提高效率就需要提高 MySQL 的配置,所以一般不用

Redis

使用

setnx key 1;
 DEL lock // 释放锁

问题

问题一

如果程序挂了,锁就永远存在了,占有锁的客户端会一直占有锁,其他客户端就永远无法拿到这个锁了

这时候应该设置过期时间

setnx key value;
ex 10;
有可能出现:
SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败
SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行
所以用原子操作,不要用两行操作
setnx key value ex 10;

问题二

评估操作共享资源的时间不准确,本来共享资源时间需要10S,我们不知道,只设置了5S

过期时间能避免客户端宕机导致锁无法释放的问题,如果超过了过期时间,程序仍然在运行怎么办?因为此时锁释放了,万一此时其他程序也获得锁了,这样两个程序获得锁

并且即使我们能够推算出过期时间,但这个也是不准确的,因为实际场景中会有很多突发情况,例如程序内部出现异常,网络请求超时等等

加锁的时候,先设置一个过期时间,然后开启一个守护线程,定时去检测这个锁的失效时间,如果锁快过期了,操作共享资源还未结束,那么就自动对锁进行续期,重新设置过期时间(看门狗)

问题三

锁被别的客户端释放了怎么办?

针对这个问题,可以设置一个唯一标识进去,可以是自己的线程ID,也可以是UUID

// 锁的VALUE设置为UUID
 SET lock $uuid EX 20 NX

如果要释放锁,要先判断lock是否是自己的,伪代码如下

// 锁是自己的,才释放
if redis.get("lock") == $uuid:
    redis.del("lock")

但是这里面又出现了上面提到的原子性问题,这个时候需要使用LUA脚本

因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。

释放锁的LUA脚本

// 判断锁是自己的,才释放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
    return redis.call("DEL",KEYS[1])
else
    return 0
end

基于 Redis 实现的分布式锁,一个严谨的的流程如下:

  1. 加锁:SET lock_key uniqueidEXunique_id EX expire_time NX
  2. 操作共享资源
  3. 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁

主从集群+哨兵

单机故障时,整个业务会停滞,所以一般会采用主从集群+哨兵模式部署,这样子主库宕机的时候,哨兵可以实现故障自动切换,把从库升为主库,继续服务,保证系统的可用性。但是主从切换的时候,分布式锁就不安全了

1、客户端在主库上执行Setnx命令,加锁成功

2、主库宕机,Setnx命令没有同步到从库中,(因为主从复制是异步的)

3、从库升级为新的主库,这个锁在新的主库中丢失了

红锁

准备

1、不再需要部署从库和哨兵,只需要部署主库

2、至少5个主库孤立的实例

流程

  1. 客户端先获取「当前时间戳T1」
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  3. 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
  4. 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  5. 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

这主要有四个重点

1、客户端在多个Redis上依次申请加锁

2、必须保证一半以上加锁成功

3、枷锁成功总耗时小于锁设置的过期时间

4、释放锁,要想全部节点发起释放请求

理解

1、为什么要设置5个实例,然后依次加锁?

为了保证容错,就算部分宕机,只要没有出现一半以上宕机,就能保证锁的正常使用

2、为什么加锁成功后还要算加锁的累计耗时然后和锁的超时时间进行比较?

因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且,因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。

所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。

3、为什么释放锁,要操作所有节点?

因为服务端在给一个Redis加锁的时候,可能因为网络问题导致加锁失败

也就是枷锁成功了,但是读取响应结果的时候,网络问题导致读取失败,那这把锁其实已经在Redis上加锁成功了。

所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁。