当前位置: 首页 > 后端技术 > Java

分布式锁及其实现

时间:2023-04-02 09:31:47 Java

大家一定对Java中的锁不陌生。Java中的synchronized关键字和ReentrantLock可重入锁在我们的代码中很常见。一般我们都是在多线程环境下使用,但是随着分布式的快速发展,局部加锁往往不能满足我们的需求,而上面加锁的方式在我们的分布式环境下就会失去作用。为了在分布式环境下达到本地锁的效果,人们提出了分布式锁的概念。分布式锁分布式锁场景一般需要使用分布式锁如下:效率:使用分布式锁可以防止不同节点重复相同的工作,比如避免重复执行定时任务等;正确性:使用分布式锁也可以避免破坏数据的正确性,如果两个节点对同一块数据进行操作,可能会出现并发问题。分布式锁特性一个完美的分布式锁需要满足以下特性:互斥:互斥是获取的基本特性,分布式锁需要根据需要在线程或节点级别保证互斥。;重入:同一个节点或者同一个线程获取锁,可以重入获取锁;锁超时:支持锁超时释放,防止某个节点持有的锁不可用;效率:加锁和解锁效率高,可以支持高并发;高可用:需要高可用机制来防止锁服务不可用,比如递增降级;blocking:支持阻塞和非阻塞获取锁;公平性能:支持两种类型的锁:公平锁和非公平锁。公平锁可以保证按照加锁的顺序获取锁,非公平锁则不能。分布式锁的实现分布式锁有三种常见的实现方式。下面我们将一一介绍这三种锁的实现方法:基于数据库的分布式锁;基于redis的分布式锁;基于Zookeeper的分布式锁。基于数据库的分布式锁基于数据库的分布式锁可以用不同的方式实现。本文将介绍笔者在实际生产中使用的一种数据库非阻塞分布式锁的实现方案。方案概述上面我们列出了分布式锁需要满足的特性,使用数据库实现分布式锁也需要满足这些特性。下面一一介绍实现方法:互斥:通过数据库更新的原子性,可以实现两次获取锁之间的互斥;可重入性:在数据库中预留一个字段,用于存放当前的锁持有者;locktimeout:在数据库中存储锁的获取时间点和超时时间;效率:数据库本身可以支持比较高的并发;高可用:可以增加主从数据库逻辑,提高数据库的可用性;阻塞:可以通过看门狗轮询实现线程阻塞;公平性:可以加锁队列,但不建议实现,看起来比较复杂。表结构设计数据库表名为lock,各字段定义如下:字段名名称字段类型描述lock_keyvarchar锁唯一标识lock_timetimestample锁时间lock_durationinteger锁超时时间,单位可由业务自定义,通常secondslock_ownervarchar锁持有者,可以是节点或线程的唯一标识符。不同可重入粒度的锁有不同的含义。lockedboolean当前锁是否被占用。获取锁的SQL语句分为不同的情况。如果锁不存在,那么需要先创建锁,创建锁的线程才能获取到锁:insertintolock(lock_key,lock_time,lock_duration,lock_owner,locked)values('xxx',now(),1000,'ownerxxx',true)如果锁已经存在,尝试更新锁信息。如果更新成功,则表示获取锁成功,如果更新失败,则表示获取锁失败。更新锁定设置locked=true,lock_owner='ownerxxxx',lock_time=now(),lock_duration=1000wherelock_key='xxx'and(lock_owner='ownerxxxx'orlocked=falseordate_add(lock_time,intervallock_durationsecond)>now())释放锁的SQL语句当用户使用完锁需要释放锁时,直接将locked标志更新为false即可。updatelocksetlocked=false,wherelock_key='xxx'andlock_owner='ownerxxxx'andlocked=true看门狗通过上面的步骤,我们可以获取和释放锁,那么看门狗是做什么的呢??试想一下,如果用户获取锁到释放锁的时间大于锁的超时时间,会不会有问题?有没有可能多个节点同时获取锁?这时候就需要看门狗了。看门狗可以通过定时任务不断刷新锁的获取事件,从而从用户获取锁到释放锁一直持有锁。基于Redis的分布式锁Redis的Java客户端Redisson实现了分布式锁。我们可以通过类似ReentrantLock的加锁-释放锁逻辑来实现分布式锁。RLockdisLock=redissonClient.getLock("DISLOCK");disLock.lock();try{//业务逻辑}finally{//不管怎样,disLock.unlock()最后一定要解锁;}Redisson分布式底层原理locks下图展示了Redisson客户端加锁和释放锁的逻辑:加锁机制可以从上图看出。当Redisson客户端需要获取锁时,需要发送Lua脚本到Redis集群执行。为什么要使用Lua脚本??因为可以将一段复杂的业务逻辑封装在一个Lua脚本中发送给Redis,从而保证这段复杂业务逻辑执行的原子性。Lua源码分析:以下是Redisson锁定的Lua源码,接下来我们将分析源码。源码输入参数:Lua脚本有3个输入参数:KEYS[1]、ARGV[1]和ARGV[2],含义如下:KEYS[1]表示锁定的Key,例如RLocklock=redisson.getLock(""myLock"in"myLock");ARGV[1]表示锁Key的默认生命周期,默认为30秒;ARGV[2]表示被锁客户端的ID,类似如下:8743c9c0-0795-4907-87fd-6c719a6b4586:1。Lua脚本和锁定步骤显示在以下代码块中。可以看出总的原则是:当锁不存在时,创建锁并设置过期时间;当锁存在时,如果是可重入场景,刷新锁过期事件;否则返回锁失败和锁过期时间。--判断锁是否存在if(redis.call('exists',KEYS[1])==0)then--添加锁,并设置客户端和初始锁重入次数redis.call('hincrby',钥匙[1],ARGV[2],1);--设置锁的超时事件redis.call('pexpire',KEYS[1],ARGV[1]);--返回锁成功返回nil;结尾;--判断当前锁持有者是否为请求锁的请求者if(redis.call('hexists',KEYS[1],ARGV[2])==1)then--当前锁为请求者持有,重入锁,增加锁重入次数redis.call('hincrby',KEYS[1],ARGV[2],1);--刷新锁的过期时间redis.call('pexpire',KEYS[1],ARGV[1]);--返回锁成功返回nil;结尾;--返回当前锁的过期时间returnredis.call('pttl',KEYS[1]);watchdoglogicclient1lock锁密钥的默认生命周期只有30秒。如果超过30秒,客户端1还想持有锁,怎么办?只要客户端1被成功锁定,就会启动看门狗。此后台线程将每10秒检查一次。如果client1还持有lockkey,则继续延长lockkey的生命周期。释放锁机制如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也很简单。即myLock数据结构中的锁数每次减1。如果发现锁的数量为0,说明客户端不再持有锁。这时候会使用“delmyLock”命令从Redis中删除Key。而另一个client2可以尝试完成锁。这就是所谓的分布式锁开源Redisson框架的实现机制。一般在生产系统中,我们可以使用Redisson框架提供的类库来实现基于Redis的分布式锁的加锁和释放。Redisson分布式锁的缺陷Redis分布式锁有一个缺陷,就是在Redissentinel模式下:client1向一个master节点写一个redisson锁,此时会异步复制到对应的slave节点。但是在这个过程中一旦主节点宕机,主备切换,从节点就从主节点变为主节点。当客户端2尝试锁定时,它也可以锁定新的主节点。此时多个客户端将完成对同一个分布式锁的加锁。系统在业务语义上肯定会出现问题,导致各种脏数据的产生。这个缺陷导致在sentinel模式或者master-slave模式下,如果master实例宕机,可能会有多个client同时完成加锁。基于Zookeeper的分布式锁Zookeeper实现的分布式锁适用于引入Zookeeper的服务。如下图,有两个服务注册到Zookeeper,都需要获取Zookeeper上的分布式锁。流程是什么?Step1假设客户端A迈出了第一步,发起了向ZK添加分布式锁的请求。这个锁请求使用了ZK中一个特殊的概念,叫做“临时时序节点”。简单点说,就是在锁节点“my_lock”的正下方创建一个时序节点。这个时序节点有一个由ZK自己维护的节点序号。比如第一个client来获取顺序节点,ZK会在内部生成名称xxx-000001。然后第二个client来获取顺序节点,ZK内部会生成名字xxx-000002。最后一个数字顺序递增,从1开始依次递增。ZK将维护此订单。所以客户端A先发起请求,会生成一个时序节点,如下图:因为客户端A是第一个发起请求的,所以节点名最后的数字是“1”。客户端A创建一个完整的sequence段后,会查询锁下的所有节点,按照最后一个数字升序排序,判断当前节点是否为首节点。如果是第一个节点,则加锁成功。Step2客户端A加锁完毕,客户端B过来要加锁。此时会在锁节点下创建一个临时的顺序节点,节点名最后的数字为“2”。客户端B会判断加锁逻辑,查询加锁节点下的所有子节点,并按照序号顺序排列。此时第一个就是客户端A创建的时序节点,序号为“01”。所以锁定失败。锁失效后,客户端B会通过ZK的API为其时序节点的前一个时序节点添加监听。ZK自然可以监控某个节点。Step3客户端A加锁后,可能会处理一些代码逻辑,然后释放锁。Zookeeper释放锁实际上是删除客户端A创建的顺序节点zk_random_000001。删除客户端A的节点后,Zookeeper会负责通知监听这个节点的监听器,即在客户端B之前添加监听器。监听器clientB的clientB知道最后一个顺序节点被删除了,也就是说,他之前的一个client已经释放了锁。此时客户端B会再次尝试获取锁,即获取锁节点下的子节点集合,判断是否为第一个节点,从而获取锁。三种锁的优缺点是基于数据库的分布式锁:数据库的并发性能差;阻塞锁的实现比较复杂;公平锁的实现比较复杂。基于redis的分布式锁:在主从切换的情况下,可能会有多个客户端获取锁;Lua脚本在单机上是原子的,但在主从同步时不是原子的。基于Zookeeper的分布式锁:需要引入Zookeeper集群,比较重量级;分布式锁的可重入粒度只能在节点级别;参考我是狐神文档中三种分布式锁和分布式锁三种实现的对比,欢迎大家关注我的微信公众号:wzm2zsd本文首发于微信公众号,版权所有,禁止转载!