本文主要是介绍信不信,90%程序猿都不会正确使用分布式锁,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
大家好,我是 沐子。
分布式锁的话题,很多文章已经写烂了,我为什么还要写这篇文章呢?
因为我发现网上 90% 的文章,并没有把这个问题真正讲清楚。导致很多读者看了很多文章,依旧云里雾里。例如下面这些问题,你能清晰地回答上来吗?
数据库通过乐观锁怎么实现分布式锁?
基于 Redis 如何实现一个分布式锁?
Redis 如何避免死锁?
Redis 如何合理的设置超时时间?
Zookeeper如何规避羊群效应?
三种分布式锁的优缺点分别是什么?
这篇文章,我就来把这些问题彻底讲清楚。
读完这篇文章,你不仅可以彻底了解分布式锁,还会对「分布式系统」有更加深刻的理解。
一、 为什么需要分布式锁
为了保证一个方法在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLcok或synchronized)进行互斥控制。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题。
二、 常见的分布式锁方案
我们在使用分布式锁的时候,大部分同学可能都忽略了一点,那就是分布式锁经常出现哪些问题,以及如何解决。
可用问题:无论何时都要保证锁服务的可用性(这是系统正常执行锁操作的基础)。
死锁问题:客户端一定可以获得锁,即使锁住某个资源的客户端在释放锁之前崩溃或者网络不可达(这是避免死锁的设计原则)。
脑裂问题:集群同步时产生的数据不一致,导致新的进程有可能拿到锁,但之前的进程以为自己还有锁,那么就出现两个进程拿到了同一个锁的问题。
总的来说,设计分布式锁服务,至少要解决上面最核心的几个问题,才能评估锁的优劣,一般分布式锁有三种常见的实现方式:
1. 数据库乐观锁;
2. 基于分布式缓存Redis的分布式锁;
3. 基于ZooKeeper的分布式锁
1. 基于关系型数据库实现分布式锁
1)基于悲观锁的方式实现分布式锁
基于关系型数据库(如 MySQL) 来实现分布式锁是任何阶段的研发同学都需要掌握的,做法如下:先查询数据库是否存在记录,为了防止幻读取(幻读取:事务 A 按照一定条件进行数据读取,这期间事务 B 插入了相同搜索条件的新数据,事务 A 再次按照原先条件进行读取时,发现了事务 B 新插入的数据 )通过数据库行锁 select for update 锁住这行数据,然后将查询和插入的 SQL 在同一个事务中提交。以订单表为例:
select id from order where order_id = xxx for update
基于关系型数据库实现分布式锁比较简单,不过你要注意,基于 MySQL 行锁的方式会出现交叉死锁,比如事务 1 和事务 2 分别取得了记录 1 和记录 2 的排它锁,然后事务 1 又要取得记录 2 的排它锁,事务 2 也要获取记录 1 的排它锁,那这两个事务就会因为相互锁等待,产生死锁。
当然,你可以通过“超时控制”解决交叉死锁的问题,但在高并发情况下,出现的大部分请求都会排队等待,所以“基于关系型数据库实现分布式锁”的方式在性能上存在缺陷。
2)基于乐观锁的方式实现分布式锁
在数据库层面,select for update 是悲观锁,会一直阻塞直到事务提交,所以为了不产生锁等待而消耗资源,你可以基于乐观锁的方式来实现分布式锁,比如基于版本号的方式,首先在数据库增加一个 int 型字段 ver,然后在 SELECT 同时获取 ver 值,最后在 UPDATE 的时候检查 ver 值是否为与第 2 步或得到的版本值相同。
//SELECT 同时获取 ver 值
select amount, old_ver from order where order_id = xxx
// UPDATE 的时候检查 ver 值是否与第 2 步获取到的值相同
update order set ver = old_ver + 1, amount = yyy where order_id = xxx and ver = old_ver
此时,如果更新结果的记录数为1,就表示成功,如果更新结果的记录数为 0,就表示已经被其他应用更新过了,需要做异常处理。
这个策略源于 mysql 的 mvcc 机制,使用这个策略其实本身没有什么问题,主要的问题就是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断 sql 每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。
2. 基于分布式缓存Redis实现分布式锁
因为数据库的性能限制了业务的并发量,所以针对“ 618 和双 11 大促”等请求量剧增的场景,需要引入基于缓存的分布式锁,这个方案可以避免大量请求直接访问数据库,提高系统的响应能力。基于缓存实现的分布式锁,就是将数据仅存放在系统的内存中,不写入磁盘,从而减少 I/O 读写。接下来,我以 Redis 为例讲解如何实现分布式锁。
我们从最简单的开始讲起。
想要实现分布式锁,必须要求 Redis 有「互斥」的能力,我们可以使用 SETNX 命令,这个命令表示SET if Not eXists,即如果 key 不存在,才会设置它的值,否则什么也不做。
两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。
客户端 1 申请加锁,加锁成功:
127.0.0.1:6379> SETNX lock 1
(integer) 1 // 客户端1,加锁成功
客户端 2 申请加锁,因为它后到达,加锁失败:
127.0.0.1:6379> SETNX lock 1
(integer) 0 // 客户端2,加锁失败
此时,加锁成功的客户端,就可以去操作「共享资源」,例如,修改 MySQL 的某一行数据,或者调用一个 API 请求。
操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?
也很简单,直接使用 DEL 命令删除这个 key 即可:
127.0.0.1:6379> DEL lock // 释放锁
(integer) 1
这个逻辑非常简单,整体的路程就是这样:
但是,它存在一个很大的问题,当客户端 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 后被「自动释放」,其它客户端依旧可以拿到锁。
但这样真的没问题吗?
还是有问题。
现在的操作,加锁、设置过期是 2 条命令,有没有可能只执行了第一条,第二条却「来不及」执行的情况发生呢?例如:
SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败
SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行
总之,这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生「死锁」问题。
怎么办?
在 Redis 2.6.12 版本之前,我们需要想尽办法,保证 SETNX 和 EXPIRE 原子性执行,还要考虑各种异常情况如何处理。
但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:
// 一条命令保证原子性执行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
这样就解决了死锁问题,也比较简单。
我们再来看分析下,它还有什么问题?
试想这样一种场景:
1.客户端 1 加锁成功,开始操作共享资源
2.客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
3.客户端 2 加锁成功,开始操作共享资源
4.客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)
看到了么,这里存在两个严重的问题:
1.锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
2.释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁
导致这两个问题的原因是什么?我们一个个来看。
第一个问题,可能是我们评估操作共享资源的时间不准确导致的。
例如,操作共享资源的时间「最慢」可能需要 15s,而我们却只设置了 10s 过期,那这就存在锁提前过期的风险。
过期时间太短,那增大冗余时间,例如设置过期时间为 20s,这样总可以了吧?
这样确实可以「缓解」这个问题,降低出问题的概率,但依旧无法「彻底解决」问题。
为什么?
原因在于,客户端在拿到锁之后,在操作共享资源时,遇到的场景有可能是很复杂的,例如,程序内部发生异常、网络请求超时等等。
既然是「预估」时间,也只能是大致计算,除非你能预料并覆盖到所有导致耗时变长的场景,但这其实很难。
有什么更好的解决方案吗?
别急,关于这个问题,我会在后面详细来讲对应的解决方案。
我们继续来看第二个问题。
第二个问题在于,一个客户端释放了其它客户端持有的锁。
想一下,导致这个问题的关键点在哪?
重点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」!
如何解决这个问题呢?
有一个更加安全的方案是为 set 指令的 value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key。但是匹配 value 和删除 key 不是一个原子操作,Redis 也没有提供类似于 delifequals 这样的指令,这就需要使用 Lua 脚本来处理了,因为 Lua 脚本可以保证连续多个指令的原子性执行。
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn0
end
以上,就是基于 Redis 的 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁、解锁,不过在实际面试中,你不能仅停留在操作上,因为这并不能满足应对面试需要掌握的知识深度, 所以你还要清楚基于 Redis 实现分布式锁的优缺点;Redis 的超时时间设置问题;站在架构设计层面上 Redis 怎么解决集群情况下分布式锁的可靠性问题。需要注意的是,你不用一股脑全部将其说出来,而是要做好准备,以便跟上面试官的思路,同频沟通。
基于 Redis 实现分布式锁的优缺点
基于数据库实现分布式锁的方案来说,基于缓存实现的分布式锁主要的优点主要有三点。
a. 性能高效(这是选择缓存实现分布式锁最核心的出发点)。
b. 实现方便。很多研发工程师选择使用 Redis 来实现分布式锁,很大成分上是因为 Redis 提供了 setnx 方法,实现分布式锁很方便。但是需要注意的是,在 Redis2.6.12 的之前的版本中,由于加锁命令和设置锁过期时间命令是两个操作(不是原子性的),当出现某个线程操作完成 setnx 之后,还没有来得及设置过期时间,线程就挂掉了,就会导致当前线程设置 key 一直存在,后续的线程无法获取锁,最终造成死锁的问题,所以要选型 Redis 2.6.12 后的版本或通过 Lua 脚本执行加锁和设置超时时间(Redis 允许将 Lua 脚本传到 Redis 服务器中执行, 脚本中可以调用多条 Redis 命令,并且 Redis 保证脚本的原子性)。
c. 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。
当然,基于 Redis 实现分布式锁也存在缺点,主要是不合理设置超时时间,以及 Redis 集群的数据同步机制,都会导致分布式锁的不可靠性。
如何合理设置超时时间
通过超时时间来控制锁的失效时间,不太靠谱,比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,后续线程 B 又意外的持有了锁,当线程 A 再次恢复后,通过 del 命令释放锁,就错误的将线程 B 中同样 key 的锁误删除了。
所以,如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短,有可能业务阻塞没有处理完成,能否合理设置超时时间,是基于缓存实现分布式锁很难解决的一个问题。
那么如何合理设置超时时间呢? 你可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可。不过这种方式实现起来相对复杂,我建议你结合业务场景进行回答,所以针对超时时间的设置,要站在实际的业务场景中进行衡量。
Redis 如何解决集群情况下分布式锁的可靠性?
由于 Redis 集群数据同步到各个节点时是异步的,如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。其实 Redis 官方已经设计了一个分布式锁算法 Redlock 解决了这个问题。而如果你能基于 Redlock 原理回答出怎么解决 Redis 集群节点实现分布式锁的问题,会成为面试的加分项。那官方是怎么解决的呢?
为了避免 Redis 实例故障导致锁无法工作的问题,Redis 的开发者 Antirez 设计了分布式锁算法 Redlock,引入该算法后即使有某个 Redis 实例发生故障,因为锁的数据在其他实例上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。那 Redlock 算法是如何做到的呢?我们假设目前有 N 个独立的 Redis 实例, 客户端先按顺序依次向 N 个 Redis 实例执行加锁操作。这里的加锁操作和在单实例上执行的加锁操作一样,但是需要注意的是,Redlock 算法设置了加锁的超时时间,为了避免因为某个 Redis 实例发生故障而一直等待的情况。当客户端完成了和所有 Redis 实例的加锁操作之后,如果有超过半数的 Redis 实例成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功。
3. 基于Zookeeper实现分布式锁
在介绍 ZooKeeper 分布式锁前需要先了解一下 ZooKeeper 中节点(Znode),ZooKeeper 的数据存储数据模型是一棵树(Znode Tree),由斜杠(/)的进行分割的路径,就是一个 Znode(如 /locks/my_lock)。每个 Znode 上都会保存自己的数据内容,同时还会保存一系列属性信息。Znode 又分为以下四种类型:
Zookeeper实现分布锁的大致思想为:每个客户端对某个方法加锁时,在 Zookeeper 上与该方法对应的指定节点的目录下,生成一个唯一的临时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个临时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
1)排它锁
排他锁,又称写锁或独占锁。如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取或更新操作,其他任务事务都不能对这个数据对象进行任何操作,直到T1释放了排他锁。
排他锁核心是保证当前有且仅有一个事务获得锁,并且锁释放之后,所有正在等待获取锁的事务都能够被通知到。
Zookeeper 的强一致性特性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即Zookeeper将会保证客户端无法重复创建一个已经存在的数据节点。可以利用Zookeeper这个特性,实现排他锁。
1️⃣定义锁:通过Zookeeper上的数据节点来表示一个锁
2️⃣获取锁:客户端通过调用 create 方法创建表示锁的临时节点,可以认为创建成功的客户端获得了锁,同时可以让没有获得锁的节点在该节点上注册Watcher监听,以便实时监听到lock节点的变更情况
3️⃣释放锁:以下两种情况都可以让锁释放
当前获得锁的客户端发生宕机或异常,那么Zookeeper上这个临时节点就会被删除
正常执行完业务逻辑,客户端主动删除自己创建的临时节点
基于Zookeeper实现排他锁流程:
2)共享锁
共享锁,又称读锁。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁,直到该数据对象上的所有共享锁都被释放。
共享锁与排他锁的区别在于,加了排他锁之后,数据对象只对当前事务可见,而加了共享锁之后,数据对象对所有事务都可见。
1️⃣定义锁:通过Zookeeper上的数据节点来表示一个锁,是一个类似于 /lockpath/[hostname]-请求类型-序号 的临时顺序节点
2️⃣获取锁:客户端通过调用 create 方法创建表示锁的临时顺序节点,如果是读请求,则创建 /lockpath/[hostname]-R-序号 节点,如果是写请求则创建 /lockpath/[hostname]-W-序号节点
3️⃣判断读写顺序:大概分为4个步骤
1)创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听
2)确定自己的节点序号在所有子节点中的顺序
3.1)对于读请求:1. 如果没有比自己序号更小的子节点,或者比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑 2. 如果有比自己序号小的子节点有写请求,那么等待
3.2)对于写请求,如果自己不是序号最小的节点,那么等待
4)接收到Watcher通知后,重复步骤1)
4️⃣释放锁:与排他锁逻辑一致
基于Zookeeper实现共享锁流程:
3)羊群效应
在实现共享锁的 “判断读写顺序” 的第1个步骤是:创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听。这样的话,任何一次客户端移除共享锁之后,Zookeeper将会发送子节点变更的Watcher通知给所有机器,系统中将有大量的 “Watcher通知” 和 “子节点列表获取” 这个操作重复执行,然后所有节点再判断自己是否是序号最小的节点(写请求)或者判断比自己序号小的子节点是否都是读请求(读请求),从而继续等待下一次通知。
然而,这些重复操作很多都是 “无用的”,实际上每个锁竞争者只需要关注序号比自己小的那个节点是否存在即可。
当集群规模比较大时,这些 “无用的” 操作不仅会对Zookeeper造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个客户端释放了共享锁,Zookeeper服务器就会在短时间内向其余客户端发送大量的事件通知–这就是所谓的 “羊群效应”。
改进后的分布式锁实现:
1️⃣客户端调用 create 方法创建一个类似于 /lockpath/[hostname]-请求类型-序号 的临时顺序节点。
2️⃣客户端调用 getChildren 方法获取所有已经创建的子节点列表(这里不注册任何Watcher)。
3️⃣如果无法获取任何共享锁,那么调用 exist 来对比自己小的那个节点注册Watcher
读请求:向比自己序号小的最后一个写请求节点注册Watcher监听
写请求:向比自己序号小的最后一个节点注册Watcher监听
4️⃣等待Watcher监听,继续进入步骤2️⃣
Zookeeper羊群效应改进前后Watcher监听图:
三、 三种分布式锁对比
1. 数据库分布式锁实现的优点及缺点
a. 优点:
理解起来简单,不需要维护额外的第三方中间件(比如Redis,Zk)
b. 缺点:
1.db操作性能较差,并且有锁表的风险
2.非阻塞操作失败后,需要轮询,占用cpu资源;
3.长时间不commit或者长时间轮询,可能会占用较多连接资源
2. Redis(缓存)分布式锁实现的优点及缺点
a. 优点:
对于Redis实现简单,性能对比ZK和Mysql较好。如果不需要特别复杂的要求,那么自己就可以利用setNx进行实现,如果自己需要复杂的需求的话那么可以利用或者借鉴Redission。对于一些要求比较严格的场景来说的话可以使用RedLock。
b. 缺点:
1.锁删除失败 过期时间不好控制
2.非阻塞,操作失败后,需要轮询,占用cpu资源;
3. ZK分布式锁实现的优点及缺点
a.优点:
ZK可以不需要关心锁超时时间,实现起来有现成的第三方包,比较方便,并且支持读写锁,ZK获取锁会按照加锁的顺序,所以其是公平锁。对于高可用利用ZK集群进行保证。
b.缺点:
性能不如redis实现,主要原因是写操作(获取锁释放锁)都需要在Leader上执行,然后同步到follower。
根据性能、可靠性、实现的复杂性和理解的难易程度等方面,对上面的三种方案总结如下:
总之上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
Redlock解决Redis 集群节点实现分布式锁的问题,但是RedLock真的安全吗?欢迎读者们在评论区留言,一起探讨学习。
最后,如果我的文章对你有所帮助或者有所启发,欢迎关注公众号(微信搜索公众号:首席架构师专栏),里面有许多技术干货,也有我对技术的思考和感悟,还有作为架构师的验验分享;关注后回复 【面试题】,有我准备的面试题、架构师大型项目实战视频等福利 , 小编会带着你一起学习、成长,让我们一起加油!!!
这篇关于信不信,90%程序猿都不会正确使用分布式锁的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!