0. 背景介绍
还是接着上周的话题,这个周接着Java八股文的第二弹,今天给大家分享的是我面试的时候高频问的一个方向的问题,关于Redis实现分布式锁的连环问题。
其实关于Redis,面试的时候其实是可以问到好多方面的。就像前段时间我写的两篇文章:
《缓存更新的Design Pattern -- 缓存专题(2)》
主要是关于Redis作为缓存时候如何保证数据库与缓存一致性的问题。Redis实现分布式锁的方方面面就是面试的时候高频问到的另一类问题,也是Redis在项目中另一个高频使用点。
关于Redis实现分布式锁的连环问题,我总结起来主要是以下这几个问题,就像是做数学题的时候一问接着一问,而且前面一问常常是下一问的引子:
- 如何使用Redis 做分布式锁,有哪些需要注意的问题?
- 上面的实现方式,如果是 Redis 是单点部署的,会带来什么问题?
- 那你准备怎么解决单点问题呢?
- 集群模式下,比如主从模式,有没有什么问题呢?
- 怎么解决Redis 集群模式下分布式锁不靠谱的问题的吗?
- 那你简单的介绍一下 Redlock 吧?
- 你觉得 Redlock 有什么问题呢?
当然中间还可以插入很多其他的 Redis 的考察点,比如Redis集群有哪些等等。大家可以先自己脑海里回答一下上面的这几个问题,看看自己是否可以cover住,然后再看看我给出的答案是否达到各位老板的要求。
其实后面6个问题都是在第一个问题的基础上提出来的,需要你掌握了如何在单机版redis的情况下实现分布式锁,然后去考虑redis的高可用问题,保证redis在集群模式下继续保证分布式锁的准确可用。我估计这一篇文章只能先把单机版的Redis实现的分布式锁讲明白,关于Redis集群实现分布式锁的方案,打算下一篇再介绍下吧。
1. 为什么需要分布式锁?
首先我们需要考虑的是,为啥需要分布式锁呢?我们Java中高频面试用到的ReentrantLock、synchronized这些关键字咋不适用了呢?
说到底,这些实现方案都是单个jvm中的锁(什么,你们要让我在写一篇关于这些锁是怎么实现的文章,这玩意也是一个高频面试点,有空再说吧,老板们),现在越来越多的系统采用了微服务架构,其实根本上来说就是系统部署在了多个jvm上,那么单单一个jvm中的锁怎么会锁的住其他jvm上的资源呢?这时候就需要一个三方的“公信部门”来提供这种锁服务,这个“公信部门”能被整个系统所有的jvm都能访问到,查看到统一的状态,才能实现整个系统架构上的锁服务。
所以,从上面的描述来说,我们需要借助系统中的都可以访问到的这种“三方机构”才能完成分布式锁的功能。所以对系统比较熟悉的同事就能觉察到具体哪些中间件能完成上述:
常见的就是这三种:
1. 通过系统持久化的基石,“系统硬盘” -- 数据库
2. 通过系统的公共缓存,“系统内存” -- redis
3. 通过系统的高可用组件 -- zookeeper(可能有的系统不涉及它)
网上也可以搜到具体这三种组件都是怎么实现分布式锁的,今天我们主要介绍的是通过redis实现分布式锁,这也是业界使用广泛的实现方案,当然其他两种方式也是有案例在使用的,比如xxl-job就是使用数据库做分布式锁等等。
2. Redis实现分布式锁的基础?
那么如果我们想要通过redis实现分布式锁,必须就要要求 Redis 有「互斥」的能力,在Redis中是存在对应的命令的,也就是 SETNX 命令,这个命令表示SET if Not eXists,即如果 key 不存在,才会设置它的值,否则什么也不做。
这个命令的含义:两个客户端同时申请redis执行这条指令,只有一个客户端可以成功,这样就可以达到互斥的效果,通过Redis实现分布式锁的基础就在这里。
客户端 1 申请加锁,加锁成功:
127.0.0.1:6379> SETNX lock 1
(integer) 1 // 客户端1,加锁成功
客户端 2 申请加锁,因为它后到达,加锁失败:
127.0.0.1:6379> SETNX lock 1
(integer) 0 // 客户端2,加锁失败
此时,加锁成功的客户端,就可以去操作「共享资源」。
加锁完成之后,就是解锁操作,那么如何释放锁呢?
也很简单,直接使用 DEL 命令删除这个 key 即可:
127.0.0.1:6379> DEL lock // 释放锁
(integer) 1
整个逻辑非常简单,如下所示:
那么就仅仅这两个指令就完成了分布式锁的加锁和解锁操作了,这个问题就这么接单么?NO,NO,NO,如果你只是回答到这一步,那真是图样图森破了。如果你只是给出这简单漏洞百出的方案,面试官已经准备好下文了,“我这已经没有其他问题,回去等通知吧”。
3. 死锁问题?
上面的实现方案存在一个很大的问题,当客户端拿到锁后,如果发生下面的场景,就会造成「死锁」:
1. 业务逻辑异常,没有及时释放redis锁2. 进程直接挂了,没有机会释放redis锁
这两种情况下,这个客户端就会一直占用这个锁,而其它客户端就拿不到这把锁了。
基于我们的开发经验,以及对JUC代码中Lock锁的使用体验,我们可以添加一个有效时间嘛。Lock锁与synchronized相比,其中一个优势就是可以设置超时时间,这不又穿插了一个小面试题。
想法挺好的,但是redis支持这种设置么?
当然,在 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
通过这个设置,就可以在出现上面那两种情况下,及时客户端不能主动释放redis锁,超过我们设置的超时时间,这个锁也会被「自动释放」,其它客户端依旧可以拿到锁。
4. 原子性问题?
通过上面这种两步配置,先setnx,然后设置超时时间,有没有其他问题呢?相信老板们已经看出来了,这是两步操作,不是原子性操作,仍然会存在没有设置超时时间的情况。
通过上面的图示,在redis通过客户端向服务端发起指令所涉及的流程中包含三个实体:redis客户端,访问链路,redis服务端也就是说,在上面添加lock之后在设置超时时间的过程中,这三个实体可能不知道哪一个就会闹出幺蛾子,导致超时时间设置失败。
1. SETNX 执行成功,执行 EXPIRE 时由于链路问题,最终没有执行 EXPIRE
2. SETNX 执行成功,Redis服务崩溃,最终没有执行EXPIRE
3. SETNX 执行成功,客户端异常崩溃,最终没有执行EXPIRE
总之,不能保证这两条指令执行的原子性,如果没有执行EXPIRE,这依旧有肯能发生「死锁」问题。
redis版本升级之后,这个问题就不用我们自己解决了,在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:
// 一条命令保证原子性执行
127.0.0.1:6379> SET lock 1 EX 10 NX //设置超时时间为10s
OK
这样一条指令就能保证原子性操作了,这样感觉我们已经解决了死锁问题,也比较简单。
5. 其他问题?
通过上面的设置,真的可以高枕无忧了么?
我们分析一下,这里设置的时间长度10s,这是我们设置的经验值。在面试的时候,我经常会问面试者这个问题,如果你的业务逻辑处理时间超过这个时间,会有什么问题呢?
1. 客户端 1 加锁成功,开始操作共享资源
2. 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
3. 客户端 2 申请锁,并加锁成功,同样开始操作共享资源
4. 客户端 1 在操作完成共享资源之后,去释放锁(但释放的是客户端 2 的锁)
在以上这种场景下,会出现两个问题:
1. 根本问题是我们设置的超时时间只是一个经验值,实际使用过程中比如网络请求超时,程序内部异常各种各样的情况都会发生,谁也不能保证给出的超时时间就一定能保证满足所有情况。
2.在第4步中,客户端1释放了属于客户端2的锁。
关于第一个问题,我会在后面详细来讲对应的解决方案。
我们继续来看第二个问题。
第二个问题在于,一个客户端释放了其它客户端持有的锁。
想一下,导致这个问题的关键点在哪?
重点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」!
6. 锁被错误释放怎么办?
我们可以想到的方法就是,对锁的value值进行标记,打上一个属于自己的标签,当释放锁的时候去查看一下锁的value值是不是已经变化了,如果没有变化则代表自己仍然持有锁,否则就是锁已经不属于自己了。
例如,可以是自己的线程 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 两条命令,这时,又会遇到我们前面讲的原子性问题了。
- 客户端 1 执行 GET,判断锁是自己的
- 客户端 2 执行了 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性模型)
- 客户端 1 执行 DEL,却释放了客户端 2 的锁
由此可见,这两个命令还是必须要原子执行才行。
怎样原子执行呢?Lua 脚本。
我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。
因为 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 实现的分布式锁,一个严谨的的流程如下:
- 加锁:SET lock_key $unique_id EX $expire_time NX
- 操作共享资源
- 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁
7. 锁过期时间如何设置?
前面我们提到,锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险。
是否可以设计这样的方案:加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。
这确实一种比较好的方案。
如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson。
Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。
到这里我们再小结一下,基于 Redis 的实现分布式锁,前面遇到的问题,以及对应的解决方案:
- 死锁:设置过期时间
- 过期时间评估不好,锁提前过期:守护线程,自动续期
- 锁被别人释放:锁写入唯一标识,释放锁先检查标识,再释放
还有哪些问题场景,会危害 Redis 锁的安全性呢?
之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。
而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。
那当「主从发生切换」时,这个分布锁会依旧安全吗?
试想这样的场景:
- 客户端 1 在主库上执行 SET 命令,加锁成功
- 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
- 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!
可见,当引入 Redis 副本后,分布锁还是可能会受到影响。
怎么解决这个问题?
为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)。
关于Redlock锁的一些问题,我们下一篇文章再分享吧。
欢迎关注我的公众号:风云编程录
欢迎想深入学习编程知识,探究底层原理或者了解开发工程师工作趣事的同学加入我的知识星球,刚刚开通,正在梳理内容,后续保证干货很多,欢迎关注!