你们要的分布式锁

从最初的单机部署,到我们现在的分布式,微服务,随着开发场景越来越复杂,也随之产生了很多很多以前没有遇到过的问题,比如今天我们今天的话题-----分布式锁,而这个话题也可以说是面试中必问的一个问题了,当然,实现分布式锁的方式有很多种,比如mysql,zookeeper等,读完这篇文章你将学到:

什么是分布式锁?

为什么会出现分布式锁?

分布式锁有哪些特点?

分布式锁的实现方式有哪些?

怎么用redis实现分布式锁?

怎么用mysql实现分布式锁?

怎么用zookeeper实现分布式锁?

正文

什么是分布式锁?

简而言之,就是控制我们现在分布式环境下某一个资源的唯一开关,它是控制分布式系统之间互斥访问共享资源的一种方式。

为什么会出现分布式锁?

说为何会分布式锁之前我们先给大家说下我们经常见的单机锁,我们看下下面这张图:

在传统的单机项目中,如果同时有很多请求请求同一个资源,我们可以使用synchronized关键字来锁住请求资源的代码块,那么请求就会如下图一样顺序执行(这里也可能是请求2在前面,关键是串行)。

因为synchronized是jvm锁,是针对当前jvm来完成这个功能的。

所以,问题来了,如果我们在另一台服务器部署了相同的项目,只用synchronized会有什么问题呢?

这就是比较简单的一个分布式的请求图,我们把它再精简下

我们可以发现,使用synchronized是无法解决业务需求的。

于是问题就产生了:

我们的业务场景不允许相同的资源同时请求数据库,而synchronized又是一个单机锁,无法锁住不同jvm上的资源,所以,为了解决这个问题,出现了分布式锁

上图就是我们真实的业务场景中一个简单的分布式锁示意图

分布式锁具有哪些特点?

①互斥性:同一时刻只能有一个线程持有锁

②可重入性:同一节点上的同一个线程如果获取了锁之后能够再次获取锁

③锁超时:和中的锁一样支持锁超时,防止死锁

④高性能和高可用:加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效

⑤具备阻塞和非阻塞性:能够及时从阻塞状态中被唤醒

⑥容错:当部分节点(redis节点等)宕机时,客户端仍然能够获取锁和释放锁。

分布式锁的实现方式有哪些?

①基于mysql实现分布式锁

②基于redis实现分布式锁

③基于zookeeper

④基于etcd的实现

⑤.

基于mysql实现分布式锁

①利用数据库主键来实现分布式锁

利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。

②基于表字段版本号做分布式锁

这个策略源于mysql的mvcc机制(这个后期在mysql篇会给大家讲到),使用这个策略其实本身没有什么问题,唯一的问题就是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断sql每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。

③基于数据库排他锁做分布式锁

在查询语句后面增加forupdate,数据库会在查询过程中给数据库表增加排他锁(注意:InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

基于redis实现分布式锁

①使用Lua脚本(包含setnx和expire两条指令,最常用)

使用步骤

1、setnx(lockkey,1)如果返回0,则说明占位失败;如果返回1,则说明占位成功

2、expire()命令对lockkey设置超时时间,为的是避免死锁问题。

3、执行完业务代码后,可以通过delete命令删除key。

如果在第一步setnx执行成功后,在expire()命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题,所以我们一定要使用lua脚本去完成这两项操作,因为在lua脚本中的整段命令是具有原子性的,可以保证两个命令一起成功

②使用setkeyvalue[EXseconds][PXmilliseconds][NX|XX]命令

Redis在2.6.12版本开始,为SET命令增加一系列选项:

SETkeyvalue[EXseconds][PXmilliseconds][NX|XX]br
EXseconds:设定过期时间,单位为秒PXmilliseconds:设定过期时间,单位为毫秒NX:仅当key不存在时设置值XX:仅当key存在时设置值

set命令的nx选项,就等同于setnx命令,代码过程如下:

publicbooleantryLock_with_set(Stringkey,StringUniqueId,intseconds){return"OK".equals((key,UniqueId,"NX","EX",seconds));}

value必须要具有唯一性,我们可以用UUID来做,设置随机字符串保证唯一性,至于为什么要保证唯一性?假如value不是随机字符串,而是一个固定值,那么就可能存在下面的问题:

1、客户端1获取锁成功

2、客户端1在某个操作上阻塞了太长时间

3、设置的key过期了,锁自动释放了

4、客户端2获取到了对应同一个资源的锁

5、客户端1从阻塞中恢复过来,因为value值一样,所以执行释放锁操作时就会释放掉客户端2持有的锁,这样就会造成问题

所以通常来说,在释放锁时,我们需要对value进行验证,避免出现上述获取错误锁资源的情况

③基于Redlock做分布式锁(了解即可)

Redlock是Redis的作者antirez给出的集群模式的Redis分布式锁,它基于N个完全独立的Redis节点(通常情况下N可以设置成5)。

算法的步骤如下:

1、客户端获取当前时间,以毫秒为单位。

2、客户端尝试获取N个节点的锁,(每个节点获取锁的方式和前面说的缓存锁一样),N个节点以相同的key和value获取锁。

客户端需要设置接口访问超时,接口超时时间需要远远小于锁超时时间,比如锁自动释放的时间是10s,那么接口超时大概设置5-50ms。

这样可以在有redis节点宕机后,访问该节点时能尽快超时,而减小锁的正常使用。

3、客户端计算在获得锁的时候花费了多少时间,方法是用当前时间减去在步骤一获取的时间,只有客户端获得了超过3个节点的锁,而且获取锁的时间小于锁的超时时间,客户端才获得了分布式锁。

4、客户端获取的锁的时间为设置的锁超时时间减去步骤三计算出的获取锁花费时间。

5、如果客户端获取锁失败了,客户端会依次删除所有的锁。使用Redlock算法,可以保证在挂掉最多2个节点的时候,分布式锁服务仍然能工作,这相比之前的数据库锁和缓存锁大大提高了可用性,由于redis的高效性能,分布式缓存锁性能并不比数据库锁差。

使用zookeeper来实现分布式锁

①临时节点

步骤

1、让多个进程(或线程)竞争性地去创建同一个临时节点,由于ZooKeeper不允许存在两个完全相同节点,因此必然只有一个进程能够抢先创建成功;

2、假设是进程A成功创建了节点,则它获得该分布式锁。此时其他进程需要在parent_node上注册监听,监听其下所有子节点的变化,并挂起当前线程;

3、当parent_node下有子节点发生变化时候,它会通知所有在其上注册了监听的进程。这些进程需要判断是否是对应的锁节点上的删除事件。如果是,则让挂起的线程继续执行,并尝试再次获取锁。

如下图

这里之所以使用临时节点是为了避免死锁:进程A正常执行完业务逻辑后,会主动地去删除该节点,释放锁。但如果进程A意外宕机了,由于声明的是临时节点,因此该节点也会被移除,进而避免死锁。

当然,这种方式的缺点也很明显

1、由于多个进程监听了同一个父节点,所以只要此父节点下的任意一个子节点发生变动,那么zookeeper都要去通知这多个进程,会带来极大的网络开销,一个释放的消息,就好像一个牧羊犬进入了羊群,所有的羊都四散而开,随时可能冲破围栏(干掉机器),会占用服务资源,网络带宽等等,这就是羊群效应。

2、这种方式是非公平锁,也就是说在进程A释放锁后,进程B,C,D发起重试的顺序与其收到通知的时间有关,而与其第一次尝试获取锁的时间无关,即与等待时间的长短无关。

②临时有序节点方案

步骤

1、每个进程(或线程)都会尝试在parent_node下创建临时有序节点。

2、然后每个进程需要获取当前parent_node下该锁的所有临时节点的信息,并判断自己是否是最小的一个节点

如果是,则代表获得该锁。

如果不是,则挂起当前线程。并对其前一个节点注册监听(这里可以通过exists方法传入需要触发Watch事件)。

3、当进程A处理完成后,会触发进程B注册的Watch事件,此时进程B就知道自己获得了锁,从而可以将挂起的线程继续,并开始业务的处理

这里需要注意的是一种特殊的情况,其过程如下:

1、如果进程B创建了临时节点,并且通过比较后知道自己不是最小的一个节点,但还没有注册监听。

2、而A进程此时恰好处理完成并删除了01节点。

3、接着进程B再调用exist方法注册监听就会抛出IllegalArgumentException异常,通常代表前一个节点已经不存在了。

在这种情况下进程B应该再次尝试获取锁,如果获取到锁,则就可以开始业务的处理


今天的文章内容属实有点多,大家看起来可能比较累,慢慢消化,内容还是比较丰富的,本来想着只写redis实现分布式锁的方式,但是想了想,还是都写了,所以redis系列的文章就暂时画上一个句号了,其实redis中还有很多细节上的点可以讲,比如字典,sds内存预分配,zset跳跃链表等等,但是其实这些都属于小细节,当然也是很重要的,所以我也会在后续慢慢和大家聊到。

文章来自moon大佬的记录

发布于 2025-02-28
96
目录

    推荐阅读