《我想进大厂》之分布式锁夺命连环 9 问 | 大理版人在囧途
开个头,这是篇技术文章,但是昨天一天太恶心了,忍不住还是简单说下昨天的事情。
昨天早上 11 点飞大理,结果 9 点钟要出门的时候发现密码锁坏了,不用密码都能打开,一边司机师傅在催着走,一边连忙打电话给房东和客服找人维修,这是第一。
然后飞机晚点,11 点 20 飞到 4 点钟才要落地,下降的过程那叫一个颠簸,我以为都要没了,这也是第一次晕飞机,简直快吐了,这是第二。
然后快 4 点了,飞机总算快要降落了,轮子都快着地了,结果愣是拔起来又起飞了,最后知道是大理 8 级大风,机长不敢落地。。。这是第三。
最后通知起飞不知道什么时候,要等大理那边通知,没有办法,我们只好下飞机转高铁,急急忙忙的一路转,总算赶上了最后 7 点前的高铁,否则就要等到 9 点以后了,最后一路周转,9 点多总算到了酒店,好在酒店还算行,没有让我太过于失望。
这一天搞下来,整个一人在囧途,太累了。好吧,废话就这么多,文章开始。
说说分布式锁吧?
对于一个单机的系统,我们可以通过 synchronized 或者 ReentrantLock 等这些常规的加锁方式来实现,然而对于一个分布式集群的系统而言,单纯的本地锁已经无法解决问题,所以就需要用到分布式锁了,通常我们都会引入三方组件或者服务来解决这个问题,比如数据库、Redis、Zookeeper 等。
通常来说,分布式锁要保证互斥性、不死锁、可重入等特点。
互斥性指的是对于同一个资源,任意时刻,都只有一个客户端能持有锁。
不死锁指的是必须要有锁超时这种机制,保证在出现问题的时候释放锁,不会出现死锁的问题。
可重入指的是对于同一个线程,可以多次重复加锁。
那你分别说说使用数据库、Redis 和 Zookeeper 的实现原理?
数据库的话可以使用乐观锁或者悲观锁的实现方式。
乐观锁通常就是数据库中我们会有一个版本号,更新数据的时候通过版本号来更新,这样的话效率会比较高,悲观锁则是通过for update
的方式,但是会带来很多问题,因为他是一个行级锁,高并发的情况下可能会导致死锁、客户端连接超时等问题,一般不推荐使用这种方式。
Redis 是通过set
命令来实现,在2.6.2
版本之前,实现方式可能是这样:
setNX
命令代表当key
不存在时返回成功,否则返回失败。
但是这种实现方式把加锁和设置过期时间的步骤分成两步,他们并不是原子操作,如果加锁成功之后程序崩溃、服务宕机等异常情况,导致没有设置过期时间,那么就会导致死锁的问题,其他线程永远都无法获取这个锁。
之后的版本中,Redis 提供了原生的set
命令,相当于两命令合二为一,不存在原子性的问题,当然也可以通过 lua 脚本来解决。
set
命令如下格式:
>key 为分布式锁的 key
>
>value 为分布式锁的值,一般为不同的客户端设置不同的值
>
>NX 代表如果要设置的 key 存在返回成功,否则返回失败
>
>EX 代表过期时间为秒,PX 则为毫秒,比如上面示例中为 10 秒过期
Zookeeper 是通过创建临时顺序节点的方式来实现。
当需要对资源进行加锁时,实际上就是在父节点之下创建一个临时顺序节点。
客户端 A 来对资源加锁,首先判断当前创建的节点是否为最小节点,如果是,那么加锁成功,后续加锁线程阻塞等待
此时,客户端 B 也来尝试加锁,由于客户端 A 已经加锁成功,所以客户端 B 发现自己的节点并不是最小节点,就会去取到上一个节点,并且对上一节点注册监听
当客户端 A 操作完成,释放锁的操作就是删除这个节点,这样就可以触发监听事件,客户端 B 就会得到通知,同样,客户端 B 判断自己是否为最小节点,如果是,那么则加锁成功
你说改为 set 命令之后就解决了问题?那么还会不会有其他的问题呢?
虽然set
解决了原子性的问题,但是还是会存在两个问题。
锁超时问题
比如客户端 A 加锁同时设置超时时间是 3 秒,结果 3s 之后程序逻辑还没有执行完成,锁已经释放。客户端 B 此时也来尝试加锁,那么客户端 B 也会加锁成功。
这样的话,就导致了并发的问题,如果代码幂等性没有处理好,就会导致问题产生。
锁误删除
还是类似的问题,客户端 A 加锁同时设置超时时间 3 秒,结果 3s 之后程序逻辑还没有执行完成,锁已经释放。客户端 B 此时也来尝试加锁,这时客户端 A 代码执行完成,执行释放锁,结果释放了客户端 B 的锁。
那上面两个问题你有什么好的解决方案吗?
锁超时
这个有两个解决方案。
针对锁超时的问题,我们可以根据平时业务执行时间做大致的评估,然后根据评估的时间设置一个较为合理的超时时间,这样能一大部分程度上避免问题。
自动续租,通过其他的线程为将要过期的锁延长持有时间
锁误删除
每个客户端的锁只能自己解锁,一般我们可以在使用set
命令的时候生成随机的 value,解锁使用 lua 脚本判断当前锁是否自己持有的,是自己的锁才能释放。
了解 RedLock 算法吗?
因为在 Redis 的主从架构下,主从同步是异步的,如果在 Master 节点加锁成功后,指令还没有同步到 Slave 节点,此时 Master 挂掉,Slave 被提升为 Master,新的 Master 上并没有锁的数据,其他的客户端仍然可以加锁成功。
对于这种问题,Redis 作者提出了 RedLock 红锁的概念。
RedLock 的理念下需要至少 2 个 Master 节点,多个 Master 节点之间完全互相独立,彼此之间不存在主从同步和数据复制。
主要步骤如下:
获取当前 Unix 时间
按照顺序依次尝试从多个节点锁,如果获取锁的时间小于超时时间,并且超过半数的节点获取成功,那么加锁成功。这样做的目的就是为了避免某些节点已经宕机的情况下,客户端还在一直等待响应结果。举个例子,假设现在有 5 个节点,过期时间=100ms,第一个节点获取锁花费 10ms,第二个节点花费 20ms,第三个节点花费 30ms,那么最后锁的过期时间就是 100-(10+20+30),这样就是加锁成功,反之如果最后时间<0,那么加锁失败
如果加锁失败,那么要释放所有节点上的锁
那么 RedLock 有什么问题吗?
其实 RedLock 存在不少问题,所以现在其实一般不推荐使用这种方式,而是推荐使用 Redission 的方案,他的问题主要如下几点。
性能、资源
因为需要对多个节点分别加锁和解锁,而一般分布式锁的应用场景都是在高并发的情况下,所以耗时较长,对性能有一定的影响。此外因为需要多个节点,使用的资源也比较多,简单来说就是费钱。
节点崩溃重启
比如有 1~5 号五个节点,并且没有开启持久化,客户端 A 在 1,2,3 号节点加锁成功,此时 3 号节点崩溃宕机后发生重启,就丢失了加锁信息,客户端 B 在 3,4,5 号节点加锁成功。
那么,两个客户端 A\B 同时获取到了同一个锁,问题产生了,怎么解决?
Redis 作者建议的方式就是延时重启,比如 3 号节点宕机之后不要立刻重启,而是等待一段时间后再重启,这个时间必须大于锁的有效时间,也就是锁失效后再重启,这种人为干预的措施真正实施起来就比较困难了
第二个方案那么就是开启持久化,但是这样对性能又造成了影响。比如如果开启 AOF 默认每秒一次刷盘,那么最多丢失一秒的数据,如果想完全不丢失的话就对性能造成较大的影响。
GC、网络延迟
对于 RedLock,Martin Kleppmann 提出了很多质疑,我就只举这样一个 GC 或者网络导致的例子。(这个问题比较多,我就不一一举例了,心里有一个概念就行了,文章地址:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
)
从图中我们可以看出,client1 线获取到锁,然后发生 GC 停顿,超过了锁的有效时间导致锁被释放,然后锁被 client2 拿到,然后两个客户端同时拿到锁在写数据,问题产生。
时钟跳跃
同样的例子,假设发生网络分区,4、5 号节点变为一个独立的子网,3 号节点发生始终跳跃(不管人为操作还是同步导致)导致锁过期,这时候另外的客户端就可以从 3、4、5 号节点加锁成功,问题又发生了。
那你说说有什么好的解决方案吗?
上面也提到了,其实比较好的方式是使用Redission
,它是一个开源的 Java 版本的 Redis 客户端,无论单机、哨兵、集群环境都能支持,另外还很好地解决了锁超时、公平非公平锁、可重入等问题,也实现了RedLock
,同时也是官方推荐的客户端版本。
那么 Redission 实现原理呢?
加锁、可重入
首先,加锁和解锁都是通过 lua 脚本去实现的,这样做的好处是为了兼容老版本的 redis 同时保证原子性。
KEYS[1]
为锁的 key,ARGV[2]
为锁的 value,格式为 uuid+线程 ID,ARGV[1]
为过期时间。
主要的加锁逻辑也比较容易看懂,如果key
不存在,通过 hash 的方式保存,同时设置过期时间,反之如果存在就是+1。
对应的就是hincrby', KEYS[1], ARGV[2], 1
这段命令,对 hash 结构的锁重入次数+1。
解锁
如果 key 都不存在了,那么就直接返回
如果 key、field 不匹配,那么说明不是自己的锁,不能释放,返回空
释放锁,重入次数-1,如果还大于 0 那么久刷新过期时间,反之那么久删除锁
watchdog
也叫做看门狗,也就是解决了锁超时导致的问题,实际上就是一个后台线程,默认每隔 10 秒自动延长锁的过期时间。
默认的时间就是internalLockLeaseTime / 3
,internalLockLeaseTime
默认为 30 秒。
最后,实际生产中对于不同的场景该如何选择?
首先,如果对于并发不高并且比较简单的场景,通过数据库乐观锁或者唯一主键的形式就能解决大部分的问题。
然后,对于 Redis 实现的分布式锁来说性能高,自己去实现的话比较麻烦,要解决锁续租、lua 脚本、可重入等一系列复杂的问题。
对于单机模式而言,存在单点问题。
对于主从架构或者哨兵模式,故障转移会发生锁丢失的问题,因此产生了红锁,但是红锁的问题也比较多,并不推荐使用,推荐的使用方式是用 Redission。
但是,不管选择哪种方式,本身对于 Redis 来说不是强一致性的,某些极端场景下还是可能会存在问题。
对于 Zookeeper 的实现方式而言,本身就是保证数据一致性的,可靠性更高,所以不存在 Redis 的各种故障转移带来的问题,自己实现也比较简单,但是性能相比 Redis 稍差。
不过,实际中我们当然是有啥用啥,老板说用什么就用什么,我才不管那么多。
版权声明: 本文为 InfoQ 作者【艾小仙】的原创文章。
原文链接:【http://xie.infoq.cn/article/01f9d5ed8abcc2b60f6e8f046】。文章转载请联系作者。
评论