Redis - 分布式锁的使用详解(加锁、锁过期时间、释放锁、Redlock、性能优化、去分布式锁)
本文介绍如何使用 Redis 来实现一个分布式锁,当然并不意味着分布式锁只能使用 Redis 来实现。简单来说,支持排他性操作的中间件都可以作为实现分布式锁的中间件,例如 ZooKeeper、Nacos 等,甚至关系型数据库也可以,比如说利用 MySQL 的 SELECT FOR UPDATE 语法是可以实现分布式锁的。


(3)为了防止出现总有业务不能在锁过期时间内结束的问题,可以考虑引入续约机制。也就是在分布式锁快要过期的时候就重置一下过期时间。





1,加锁
(1)利用 Redis 来实现分布式锁的时候,所谓的锁就是一个普通的键值对。而加锁就是使用 SETNX 命令,排他地设置一个键值对。
- 如果 SETNX 命令设置键值对成功了,那么说明加锁成功。
- 如果没有设置成功,说明这个时候有人持有了锁,需要等待别人释放锁。
(2)而相应地,释放锁就是删除这个键值对。

(3)当加锁失败的时候,这个等待时间是要根据锁的持有时间来设置的。比如说如果预计 99% 的锁持续时间是一秒钟,那么我们就可以把这个等待时间设置成一秒钟。等待也有两种实现方式:
- 第一种方式就是轮询,比如说在加锁失败之后,每睡眠 100 毫秒就尝试加锁一次,直到成功或者整个等待时间超过一秒钟。
- 第二种方式是监听删除事件,也就是在加锁失败之后立刻订阅这个键值对。当键值对被删除的时候就说明锁被释放了,这个时候再次尝试加锁。监听删除事件总的来说,实时性比较好,但是实现起来比较麻烦。
注意:下图只是为了方便你理解,而在实际的实现中,发现锁被人设置了和订阅是要做成一个整体的,不然就会有并发问题。

(4)分布式锁也可以考虑提供重试功能。比如说加锁的时候收到了超时响应,就可以发起重试。假如说我要给 key1 加分布式锁,随机生成了一个 UUID value1 作为值,那么重试的基本逻辑是这样的:
- ① 检查一下 Redis 里是否存在 key1。如果 key1 不存在,那么说明上一次调用没有加锁成功。
- ② 如果 key1 存在,检查值是不是 value1。如果是 value1,那么说明我上一次加锁成功了。考虑到距离重试的时候已经过去了一段时间,所以需要重置一下过期时间。
- ③ 值并不是 value1,这个时候说明已经有别人拿着锁了,也就说明加锁失败了。
提示:如果重试一直都超时,这个时候也不需要额外处理。因为如果之前加锁已经成功了,那么无非就是过期时间到了,锁自然失效。如果之前没有加锁成功,就更没事了,别的线程需要的时候就可以拿到锁。
2,锁过期时间
(1)在使用分布式锁的时候,分布式锁的持有者有可能宕机,这会导致整个锁既没有人能够获得,也没有人能够释放。在这种情况下,就可以考虑给分布式锁加一个过期时间。

(2)这个过期时间应该根据业务来设置。比如说,如果在拿到锁之后,99% 的业务都可以在 1 秒内完成,那么就可以把过期时间设置得比 1 秒长一些,比如说设置成 2 秒。保险起见,设置成 10 秒甚至一分钟也没多大关系。
提示:过期时间主要是为了防止系统宕机而引入的,而大部分情况下,锁都能被正常释放掉,所以把过期时间设置得长一些也没什么问题。
(3)为了防止出现总有业务不能在锁过期时间内结束的问题,可以考虑引入续约机制。也就是在分布式锁快要过期的时候就重置一下过期时间。
- 比如说一开始过期时间设置的是 1 分钟,那么可以在 50 秒之后再次把过期时间重置为 1 分钟。理论上来说,只需要确保在剩余过期时间内能够续约成功,就可以了。比如说这里预留了 10 秒,那么就算第一次续约失败,也有足够的时间进行重试。
(4)如果不断重试之后,续约都失败了,那么这个时候就要根据业务来决定采取保守策略还是激进策略了
- 如果你对排他性要求得非常严格,那么这个时候你只能考虑中断业务。因为你可能续约失败了,那么接下来就会有人拿到分布式锁。所以你的业务不能继续执行,这也就是保守策略。
- 如果你觉得这种非常偶然的续约失败是可以接受的,那么你还是可以继续执行业务,当然这可能引起数据不一致的问题,这也就是激进方案。
(5)在分布式锁出了问题的时候,中断业务也是一个很困难的事情。分布式锁并不能直接帮你中断业务,它只能给你发一个信号,告诉你发生了什么糟糕的事情。比如说分布式锁在续约失败的时候,给你发了一个信号。这个时候是否中断业务完全是看你的业务代码是如何实现的。
- 举个例子来说,如果你的业务是一个大循环,那么你可以在每个循环开始的时候,检测一下有没有收到什么信号。如果收到了需要中断的信号,那么就退出循环。
for condition { // 中断业务执行 if interrupted { break; } // 你的业务逻辑 DoSomething() }
- 如果你的业务没有循环,那么你可以在每一个关键步骤之后都检测一下有没有收到信号,然后考虑要不要中断业务。
step1() if interrupted { return } step2() if interrupted { return }
3,释放锁
(1)正常来说,释放锁都不会有什么问题。但是在一些特殊场景下,释放锁也可能会有问题。比如说线程 1 加了锁,结果 Redis 崩溃了又恢复过来,这时候线程 2 也加了同一把锁。当线程 1 执行完毕之后,去释放锁就会把线程 2 的锁也释放掉。

(2)在释放锁的时候,要先确认锁是不是自己加的,防止因为系统故障或者有人手动操作了 Redis 导致锁被别人持有了。确认锁的方法也很简单,就是比较一下键值对里的值是不是自己设置的。这也要求在加锁设置键值对的时候使用唯一的值,比如说用 UUID。

4,Redlock
(1)在实现分布式锁的时候,有一个问题必须要考虑:如果 Redis 崩溃了怎么办?在释放锁那里,我们已经看到,如果 Redis 崩溃了再恢复过来的时候,锁就有可能被别人拿走。怎么解决这种问题呢?
- 首先,Redis 的主从切换机制是解决不了这个问题的,因为 Redis 的主从同步是异步的。也就是说当你拿到一个分布式锁的时候,这个锁还没有同步到从节点,主节点就可能崩溃了。这个时候从节点被提升成主节点,里面并没有你的分布式锁,所以别人就可以拿到分布式锁。

(2)为了解决这个问题,就有了 Redlock 算法。Redlock 的思想说起来也很简单,用一句话概括就是多数原则。也就是说,你加锁的时候要在多个独立的 Redis 节点上同时加锁。当大多数节点都告诉你加锁成功的时候,就说明你加锁成功了。
- 举例来说,如果你同时在 5 个节点上加锁,那么大多数就意味着至少 3 个节点成功才算加锁成功。
- 在这个过程中,假设说你的锁过期时间是 10 秒,加锁花了 1 秒钟,那么你就只剩下 9 秒钟。如果加锁失败,还要在所有的节点上释放锁。比如说 4 个节点告诉你成功了,1 个节点超时了,这种时候你需要在 5 个节点上一起释放锁。
注意:就是目前绝大部分公司使用的分布式锁,都没有按照 Redlock 算法来实现,因为 Redlock 成本高,性能也比较差。

5,性能优化
(1)要想优化分布式锁的性能,一方面是考虑优化 Redis 本身的性能,比如说启用单独的 Redis 集群,这可以有效防止别的业务操作 Redis,影响加锁和释放锁的性能。
(2)另外一方面则是可以考虑减少分布式锁的竞争,比如说使用 Singleflight 模式。也就是针对同一把锁,每个实例内部先选出一个线程去获得锁。
- 假设有 2 个实例,每个实例上各有 10 个线程要去获得 key1 上的分布式锁。在不使用 Singleflight 模式的情况下,总共有 20 个线程会去竞争分布式锁。但是在使用 Singleflight 模式之后,最终只有 2 个线程去竞争分布式锁。竞争越激烈,这种方案的效果越好。如果没什么并发的话,那么就基本没什么效果。

- 这里还有一种更加激进的优化方案。在实例拿到了分布式锁之后,释放锁之前先看看本地有没有别的线程也需要同一把分布式锁。如果有,就直接转交给本地的线程,进一步减少加锁和释放锁的开销。这种优化手段同样是在竞争越激烈的场景,效果越好。

附:去分布式锁
分布式锁不管怎么优化,都有性能损耗。在一些场景下是可以考虑去掉分布式锁的。严格来说,应该是原本这些场景就不该用分布式锁,现在是回归本源了。这里提供两种去分布式锁思路。
1,用数据库乐观锁来取代分布式锁
(1)比如说一些场景是加了分布式锁之后执行一些计算,最后更新数据库。在这种场景下,完全可以抛弃分布式锁,直接计算,最后计算完成之后,利用乐观锁来更新数据库。
(2)这种方式缺点就是没有分布式锁的话,可能会有多个线程在计算。但是问题不大,因为只要最终更新数据库控制住了并发,就没关系。
2,利用一致性哈希负载均衡算法
在使用这种算法的时候,同一个业务的请求肯定发到同一个节点上。这时候就没必要使用分布式锁了,本地直接加锁,或者用 Singleflight 模式就可以。
