转载博客:深度剖析:Redis分布式锁到底安全吗?看完这篇文章彻底懂了!
文章目录
一.思考几个问题
- 基于 Redis 如何实现一个分布式锁?
- Redis 分布式锁真的安全吗?
- Redis 的 Redlock 有什么问题?一定安全吗?
- 业界争论 Redlock,到底在争论什么?哪种观点是对的?
- 分布式锁到底用 Redis 还是 Zookeeper?
- 实现一个有「容错性」的分布式锁,都需要考虑哪些问题?
二.为什么需要分布式锁?
与分布式锁相对应的是单机锁,我们在写多线程程序时,避免同时操作一个共享变量产生数据问题,通常会使用一把锁来互斥,以保证共享变量的正确性,其使用范围是在同一个进程中。如果换做是多个进程,需要同时操作一个共享资源,如何互斥呢?
三.分布式锁怎么实现?
1.想要实现分布式锁,必须要求 Redis 有互斥的能力,我们可以使用 SETNX 命令。我们可以同时开两个redis-cli客户端来测试,客户端 1 申请加锁,加锁成功,客户端 2 申请加锁,因为它后到达,加锁失败,此时,加锁成功的客户端,就可以去操作「共享资源」。
2.操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?也很简单,直接使用 DEL 命令删除这个 key 即可。
3.但是,存在一个很大的问题,当客户端 1 拿到锁后,如果发生下面的场景,就会造成「死锁」。这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」拿不到这把锁了。怎么解决这个问题呢?
- 程序处理业务逻辑异常,没及时释放锁
- 进程挂了,没机会释放锁
四.如何避免死锁?
1.很容易想到的方案是,在申请锁时,给这把锁设置一个「租期」。在 Redis 中实现时,就是给这个 key 设置一个「过期时间」。这里我们假设,操作共享资源的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过期即可。这样一来,无论客户端是否异常,这个锁都可以在 10s 后被「自动释放」,其它客户端依旧可以拿到锁。
127.0.0.1:6379> setnx mylock 1 //加锁
(integer) 1
127.0.0.1:6379> expire mylock 10 // 10s后自动过期
(integer) 1
2.其实这样还是有问题的!!现在的操作,加锁、设置过期是 2 条命令,有没有可能只执行了第一条,第二条却「来不及」执行的情况发生呢?总之,这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生「死锁」问题。
- SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败
- SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
- SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行
3.保证 SETNX 和 EXPIRE 原子性执行。在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:这样就解决了死锁问题,也比较简单。
127.0.0.1:6379> set mylock 1 ex 10 nx // 一条命令保证原子性执行
OK
4.仍旧存在问题,试想下面这种场景。存在两个严重的问题:(1)锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有。(2)释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁。
- 客户端 1 加锁成功,开始操作共享资源
- 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
- 客户端 2 加锁成功,开始操作共享资源
- 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)
5.先来看第二个问题。一个客户端释放了其它客户端持有的锁。重点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」!解决办法是:客户端在加锁时,设置一个只有自己知道的「唯一标识」进去,之后,在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:这里释放锁使用的是 GET + DEL 两条命令,这时,又会遇到我们前面讲的原子性问题了。怎样原子执行呢?Lua 脚本。我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。
// 锁是自己的,才释放
if redis.get("mylock") == $uuid:
redis.del("mylock")
6.再来看第一个问题。重点在于锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险。是否可以设计这样的方案:加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。
五.还有哪些问题场景,会危害 Redis 锁的安全性呢?
1.之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。那当「主从发生切换」时,这个分布锁会依旧安全吗?可见,当引入 Redis 副本后,分布锁还是可能会受到影响。
- 客户端 1 在主库上执行 SET 命令,加锁成功
- 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
- 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!
2.怎么解决这个问题?为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)。
六.Redlock 真的安全吗?
1.我们来看,Redis 作者提出的 Redlock 方案,是如何解决主从切换后,锁失效问题的。
2.Redlock 的方案基于 2 个前提:也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。
- 不再需要部署从库和哨兵实例,只部署主库。
- 但主库要部署多个,官方推荐至少 5 个实例。
3.整个流程是这样的,一共分为 5 步:
- 客户端先获取「当前时间戳T1」
- 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
- 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
- 加锁成功,去操作共享资源
- 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)
6.1 为什么要在多个实例上加锁?
本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。
6.2 为什么大多数加锁成功,才算成功?
多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。
在分布式系统中,总会出现「异常节点」,所以,在谈论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。
这是一个分布式系统「容错」问题,这个问题的结论是:如果存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。
6.3 为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?
因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且,因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。
所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。
6.4 为什么释放锁,要操作所有节点?
在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。
例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。
所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁。
七.Redlock 的争论谁对谁错?
1.分布式专家 Martin 对于 Relock 的质疑阐述了 几个论点:
2.Redis 作者 Antirez 的反驳
八.基于 Zookeeper 的锁安全吗?
1.如果你有了解过 Zookeeper,基于它实现的分布式锁是这样的:
- 客户端 1 和 2 都尝试创建「临时节点」,例如 /lock
- 假设客户端 1 先到达,则加锁成功,客户端 2 加锁失败
- 客户端 1 操作共享资源
- 客户端 1 删除 /lock 节点,释放锁
2.Zookeeper 不像 Redis 那样,需要考虑锁的过期时间问题,它是采用了「临时节点」,保证客户端 1 拿到锁后,只要连接不断,就可以一直持有锁。而且,如果客户端 1 异常崩溃了,那么这个临时节点会自动删除,保证了锁一定会被释放。
3.思考一下,客户端 1 创建临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁呢?原因就在于,客户端 1 此时会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端「定时心跳」来维持连接。
4.如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。
5.Zookeeper与redis对比