Redis 实现分布式锁

805 阅读9分钟

原文

分布式锁的解决方案非常多,常用的如 ZooKeeper ,今天讲的是如何通过 Redis 去实现分布式锁。

我们从最简单的开始,然后一步一步去完善这个分布式锁。

通过 SETNX 命令实现 Redis 分布式锁

SETNX 意思是 SET if Not eXists,即 key 不存在时才会设置它的值,否则什么也不做。

通过 SETNX 这个命令我们就可以实现一个简陋的分布式锁,具体操作如下

  1. 客户端 1 申请加锁(其实就是添加一条 String 类型的数据),并且加锁成功:
127.0.0.1:6379> SETNX lock 1
(integer) 1     // 客户端1,加锁成功
  1. 客户端 2 申请加锁,因为它后到达,加锁失败:
127.0.0.1:6379> SETNX lock 1
(integer) 0     // 客户端2,加锁失败
  1. “加锁成功”的客户端,就可以去操作「共享资源」(例如,修改 MySQL 的某一行数据,或者调用一个 API 请求)

  2. 「共享资源」操作完成后,立即释放锁(其实就是删除刚刚添加的那条数据),让出给其他客户端使用

127.0.0.1:6379> DEL lock // 释放锁
(integer) 1

整个逻辑就是,让所有想操作「共享资源」的客户端都去 RedisSETNX 同一条数据,谁先添加成功谁就算拥有分布式锁,然后就可以操作「共享资源」,操作完后在删掉该数据,释放锁

image.png

死锁及处理

上述方式虽然能够实现分布式锁,但是存在一个很大的问题,当客户端 1 拿到锁后,如果发生下面的场景,就会造成「死锁」:

  • 程序业务处理出现异常,没及时释放锁
  • 进程挂了,没机会释放锁
  • ...

如何避免死锁?很容易想到在申请锁时,给这把锁设置一个「租期」。

Redis 中实现这个功能时,就是给这个 key 设置一个「过期时间」。

设置多长好呢?肯定不能过短,否则「共享资源」还没操作完,就给我释放了,这个时间主要根据你的业务耗时来定!

假设操作「共享资源」的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过期即可:

127.0.0.1:6379> SETNX lock 1    // 加锁
(integer) 1
127.0.0.1:6379> EXPIRE lock 10  // 10s后自动过期
(integer) 1

这样就可以保证,持有锁的客户端即使发生异常,没有手动释放锁,这个锁也可以在 10s 后被「自动释放」

这样就万无一失了吗?并不一定,注意上面是两条 redis 命令,也就是说可能出现执行完第一条命令后,redis 突然挂了导致第二条命令没有执行成功的情况,这种情况下还是会发生死锁。

总之,只要这两条命令不能保证是原子操作(一起成功),就存在发生「死锁」的风险

怎么办?

Redis 2.6.12 版本之前,我们需要想尽办法,保证 SETNXEXPIRE 原子性执行,还要考虑各种异常情况如何处理。

但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,原生支持在添加数据的同时指定过期时间 :

// 添加数据的同时指定过期时间
127.0.0.1:6379> SET lock 1 EX 10 NX
OK

锁被提前释放

上一节 死锁及处理 中说道,要根据业务合理评估操作「共享资源」的时间,防止操作没有完成锁被提前释放。

但是即使我们做出了合理评估,有些情况下操作「共享资源」的时间可能还是超过了锁的租期,比如

  • 操作「共享资源」的时候卡住了
  • 网络请求超时
  • ...

当锁被提前释放(DEL lock)后,其他客户端就能获取到锁(SET lock),然后开始操作「共享资源」,这时候,之前的客户端操作完成了,然后释放了锁(DEL lock),此时,它释放的其实是其他客户端的锁。

这里面有两个问题

  1. 如何防止释放掉其他客户端的锁
  2. 如何避免锁被提前释放

防止释放掉其他客户端的锁

解决这个问题的重点在于如何判断锁是不是自己的?

我们可以这样做,客户端在加锁时,设置一个只有自己知道的「唯一标识」进去,可以是自己的线程 ID,也可以是一个 UUID,这里我们以 UUID 举例:

// 锁的VALUE设置为UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK

这里假设 20s 操作共享资源的时间完全足够,先不考虑锁被提前释放的问题。

之后,在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:

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

这里释放锁使用的是 GET + DEL 两条命令,这时,又会遇到我们前面讲的原子性问题了。怎么办,这次 Redis 可没提供二合一的命令。

虽然 redis 自身没有提供,但我们可以将两个命令写在 Lua 脚本里,Redis 执行 Lua 脚本的过程是原子性的

安全释放锁的 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 $unique_id EX $expire_time NX
  2. 操作共享资源
  3. 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁

image.png

现在我们还剩一个问题没解决

避免锁被提前释放

上面提到即使合理评估租期,也有概率会出现锁被提前释放的情况,怎么解决呢?

延长租期吗?延长租期也只能减小事件发生的概率,不能彻底杜绝

我们可以设计这样一个方案:加锁时,先设置一个过期时间,然后开启一个「守护线程」,定时去检测这个锁的过期时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。

如果你是 Java 技术栈,幸运的是,已经有一个 Redisson 库把这些工作都封装好了

Redisson

Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,而这个守护线程在 Redisson 中被称作 「看门狗」线程。

image.png

除此之外,这个 SDK 还封装了很多易用的功能:

  • 可重入锁
  • 乐观锁
  • 公平锁
  • 读写锁
  • Redlock(红锁,下面会详细讲) 这个 SDK 提供的 API 非常友好,它可以像操作本地锁的方式,操作分布式锁。

小结

基于 Redis 实现的分布式锁,可能遇到的问题,以及对应的解决方案:

  • 死锁:设置过期时间
  • 锁被别人释放:锁写入唯一标识,释放锁先检查标识,再释放(Lua 脚本)
  • 锁被提前释放:使用守护线程,自动续期

主从集群 + 哨兵的模式下的分布式锁

上面分析的场景都是锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。

那在 主从集群 + 哨兵的模式下,上面的 Redis 分布式锁会不会有问题呢?

来看下面这个场景

  1. 客户端 1 在主库上执行 SET 命令,加锁成功
  2. 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
  3. 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!

image.png

为了解决主从切换时,锁丢失的问题,Redis 的作者提出一种叫 Redlock(红锁) 的解决方案

Redlock(红锁)

Redis 作者提出的 Redlock(红锁) 的解决方案基于 2 个前提:

  1. 只用的到主库,用不上从库和哨兵实例
  2. 主库要部署多个,官方推荐至少 5 个实例

也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个互不相关的实例。

注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。

在看具体如何实现 Redlock, 整个流程如下

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

整个逻辑说白了就是大家都去抢注锁,谁抢注的多谁就拥有锁

这里面有几个重点

  1. 客户端必须在多个 Redis 实例上申请加锁
  2. 必须保证大多数节点加锁成功
  3. 大多数节点加锁的总耗时,要小于锁设置的过期时间(租期不要设置的过短,防止锁还没抢到手,就失效了)
  4. 释放锁,要向全部节点发起释放锁请求,防止锁残留(锁残留指定是,实际加锁成功但返回结果时由于网络等原因客户端以为加锁失败。)

Redlock(红锁) 通过向当前存活的多个主库抢注锁,实现了即使部分节点不可用,也不会影响到分布式锁系统,解决主从切换时,锁丢失的问题

Redlock(红锁) 和 主从集群 + 哨兵的模式 并不冲突,Redlock(红锁) 只要求有5个主库,至于这5个主库背后有没有从库和哨兵,它并不关心

注意 Redlock(红锁) 依然需要搭配 Redisson 来解决锁提前过期的问题

那么 Redlock(红锁) 真的安全吗?有人并不这么认为

Redlock 的争论

从这里开始属于扩展内容,有兴趣的可以继续了解 Redis Redlock 的争论