0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

Java手写分布式锁的实现

jf_ro2CN3Fa 来源:稀土掘金 2023-11-17 15:51 次阅读

Part1 前言

随着互联网业务的发展,原本单机部署的系统演化成如今的分布式集群系统后,由于分布式系统多线程,多进程并且分布在不同的机器上,这会使原本的单机锁失效,而且单纯的Java API并不能提供分布式锁的能力,为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题。

本文将从实现本地锁所产生的问题入手,从而介绍分布式锁主流的实现方案,重点实现基于Redis的分布式锁。

本地锁会出现的问题(此篇幅代码图片过多,因此放在最后)

Part2 分布式锁的实现

1 分布式锁主要的实现有:

基于数据库实现分布式锁

基于缓存(Redis等,本文基于Redis实现手写分布式锁 ,因为这样可以更好的理解分布式锁的原理及实现,当然也可以使用Redisson)

基于Zookeeper

2 每种分布式锁的解决方案都有各自的优缺点

性能角度:redis > zk > mysql

安全角度:zk > redis == mysql

难易程度:zk > redis > mysql

3 分布式锁要具备的特点:

独占排他互斥

可以通过 setnx (redis命令:执行多次,只有一次能够成功)

set key value ex 3 nx

防死锁发生

请求获取到锁之后,服务器挂掉了,导致锁无法释放:给lock锁添加过期时间

可以通过redis命令 expire

或者通过 set key value ex 3 nx

保证原子性

redis是单线程的,接受或者执行指令遵循one-by-one原则。只要指令之间不被插入其他指令即可保证原子性,lua脚本批量发送多个指令给redis服务器,lua脚本也可以实现一些业务逻辑,redis集成了lua脚本,可以直接使用eval指令执行lua脚本。

获取锁和设置过期时间之间

判断和删除之间:lua脚本

防误删:

uuid给每个线程的锁添加唯一标识

自动续期

可重入:hash数据结构 + lua脚本

集群情况下,可能导致锁失效:RedLock算法(redis特有的)

一个请求从主中获取到锁,从还没来得及同步数据,主就挂掉了,从就升级为新主,新的请求就可以从新主中获取锁

4 基于redis分布式锁的基本实现

我们可以借助于redis中的命令setnx(key, value),key不存在就新增,存在就什么都不做。同时有多个客户端发送setnx命令,只有一个客户端可以成功,返回1(true);其他的客户端返回0(false),流程图如下图所示:

多个客户端同时尝试获取锁(setnx)

获取成功,执行业务逻辑,执行完成释放锁(del)

其他客户端等待重试

f3480f14-851c-11ee-939d-92fbcf53809c.jpg

代码实现:

publicvoidtestLock(){
//1.从redis中获取锁,setnx
Booleanlock=this.redisTemplate.opsForValue().setIfAbsent("lock","111");
if(lock){
//查询redis中的num值
Stringvalue=this.redisTemplate.opsForValue().get("num");
//没有该值return
if(StringUtils.isBlank(value)){
return;
}
//有值就转成成int
intnum=Integer.parseInt(value);
//把redis中的num值+1
this.redisTemplate.opsForValue().set("num",String.valueOf(++num));
//2.释放锁del
this.redisTemplate.delete("lock");
}else{
//3.每隔1秒钟回调一次,再次尝试获取锁
try{
Thread.sleep(1000);
testLock();
}catch(InterruptedExceptione){
e.printStackTrace();
}
}
}

那么以上代码是否可以解决全部问题呢? 显示是不能的,我们假设setnx刚好获取到锁,业务逻辑出现异常,导致锁无法释放,怎么办呢?

5 优化分布式锁_设置过期时间

设置过期有俩种方式可以选择:

通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)

在set时指定过期时间(推荐

f35de50a-851c-11ee-939d-92fbcf53809c.jpg

代码实现优化就是在设置锁的时候设置过期时间:

publicvoidtestLock(){
//1.从redis中获取锁,setnx
Booleanlock=this.redisTemplate.opsForValue().setIfAbsent("lock","111",3,TimeUnit.MINUTES);
if(lock){
//与之前相同代码略过
...
}
}

那么还会不会存在问题呢?

场景:如果业务逻辑的执行时间是7s。执行流程如下:

index1业务逻辑没执行完,3秒后锁被自动释放。

index2获取到锁,执行业务逻辑,3秒后锁被自动释放。

index3获取到锁,执行业务逻辑

index1业务逻辑执行完成,开始调用del释放锁,这时释放的是index3的锁,导致index3的业务只执行1s就被别人释放。

最终等于没锁的情况。

解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁

6 优化分布式锁_防止误删除

f3712fca-851c-11ee-939d-92fbcf53809c.jpg

publicvoidtestLock(){
//1.从redis中获取锁,setnx
Stringuuid=UUID.randomUUID().toString();
Booleanlock=this.redisTemplate.opsForValue().setIfAbsent("lock",uuid,3,TimeUnit.MINUTES);
if(lock){
//与之前相同代码略过
...
//2.释放锁del
if(StringUtils.equals(redisTemplate.opsForValue().get("lock"),uuid)){
this.redisTemplate.delete("lock");
}
}
}

场景:

index1执行删除时,查询到的lock值确实和uuid相等

index1执行删除前,lock刚好过期时间已到,被redis自动释放

index2获取了lock

index1执行删除,此时会把index2的lock删除

问题:缺乏原子性

7 优化分布式锁_LUA脚本保证删除的原子性

首先我们先简单介绍一下lua脚本的基本知识(lua脚本是c语言

定义变量:

全局变量:a = 11

局部变量:local b = 22

redis不允许lua脚本创建全局变量,只能声明局部变量

流程控制:

if(exp)then
业务逻辑
elseif(exp)then
业务逻辑
else
业务逻辑
end

redis中执行lua脚本:

eval script numkeys keys[] args[] : eval指令的输出不是lua脚本的打印而是lua脚本的返回值

script:lua脚本字符串,定义动态变量:KEYS[1] ARGV[1]

numkeys:key数组的元素个数

keys:keys数组

args:argv数组

redis集群执行lua脚本可能会报错:如果所有keys不在同一个分片上,lua脚本就会报错:解决方案是:

keys只传一个

可以使用CLUSTER KEYSLOT bb{xx}

删除LUA脚本:

ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1])elsereturn0end
publicvoidtestLock(){
//1.从redis中获取锁,setnx
Stringuuid=UUID.randomUUID().toString();
Booleanlock=this.redisTemplate.opsForValue().setIfAbsent("lock",uuid,3,TimeUnit.SECONDS);
if(lock){
//与之前相同代码略过
...
//2.释放锁del
Stringscript="ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1])elsereturn0end";
this.redisTemplate.execute(newDefaultRedisScript<>(script),Arrays.asList("lock"),uuid);
}else{
//3.每隔1秒钟回调一次,再次尝试获取锁
try{
Thread.sleep(1000);
testLock();
}catch(InterruptedExceptione){
e.printStackTrace();
}
}
}

8 优化分布式锁_可以重入

上述加锁命令使用了 SETNX ,一旦键存在就无法再设置成功,这就导致后续同一线程内继续加锁,将会加锁失败。当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的子任务代码,可重入性就保证线程能继续执行,而不可重入就是需要等待锁释放之后,再次获取锁成功,才能继续往下执行。

可重入锁最大特性就是计数,计算加锁的次数。所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。

我们基于Redis Hash 实现方案

Redis 提供了 Hash (哈希表)这种可以存储键值对数据结构。所以我们可以使用 Redis Hash 存储的锁的重入次数,然后利用 lua 脚本判断逻辑。

加锁

if(redis.call('exists',KEYS[1])==0orredis.call('hexists',KEYS[1],ARGV[1])==1)
then
redis.call('hincrby',KEYS[1],ARGV[1],1);
redis.call('expire',KEYS[1],ARGV[2]);
return1;
else
return0;
end

假设值为:KEYS:[lock], ARGV[uuid, expire]

如果锁不存在或者这是自己的锁,就通过hincrby(不存在新增,存在就加1)获取锁或者锁次数加1。 代码实例如下:

privateBooleantryLock(StringlockName,Stringuuid,Longexpire){
Stringscript="if(redis.call('exists',KEYS[1])==0orredis.call('hexists',KEYS[1],ARGV[1])==1)"+
"then"+
"redis.call('hincrby',KEYS[1],ARGV[1],1);"+
"redis.call('expire',KEYS[1],ARGV[2]);"+
"return1;"+
"else"+
"return0;"+
"end";
if(!this.redisTemplate.execute(newDefaultRedisScript<>(script,Boolean.class),Arrays.asList(lockName),uuid,expire.toString())){
try{
//没有获取到锁,重试
Thread.sleep(200);
tryLock(lockName,uuid,expire);
}catch(InterruptedExceptione){
e.printStackTrace();
}
}
//获取到锁,返回true
returntrue;
}

解锁

--判断hashset可重入key的值是否等于0
--如果为nil代表自己的锁已不存在,在尝试解其他线程的锁,解锁失败
--如果为0代表可重入次数被减1
--如果为1代表该可重入key解锁成功
if(redis.call('hexists',KEYS[1],ARGV[1])==0)then
returnnil;
end;
--小于等于0代表可以解锁
if(redis.call('hincrby',KEYS[1],ARGV[1],-1)>0)then
return0;
else
redis.call('del',KEYS[1]);
return1;
end;

这里之所以没有跟加锁一样使用 Boolean ,这是因为解锁 lua 脚本中,三个返回值含义如下:

1 代表解锁成功,锁被释放

0 代表可重入次数被减 1

null 代表其他线程尝试解锁,解锁失败

如果返回值使用 Boolean,Spring-data-redis 进行类型转换时将会把 null 转为 false,这就会影响我们逻辑判断,所以返回类型只好使用 Long。

privatevoidunlock(StringlockName,Stringuuid){
Stringscript="if(redis.call('hexists',KEYS[1],ARGV[1])==0)then"+
"returnnil;"+
"end;"+
"if(redis.call('hincrby',KEYS[1],ARGV[1],-1)>0)then"+
"return0;"+
"else"+
"redis.call('del',KEYS[1]);"+
"return1;"+
"end;";
//这里之所以没有跟加锁一样使用Boolean,这是因为解锁lua脚本中,三个返回值含义如下:
//1代表解锁成功,锁被释放
//0代表可重入次数被减1
//null代表其他线程尝试解锁,解锁失败
Longresult=this.redisTemplate.execute(newDefaultRedisScript<>(script,Long.class),Lists.newArrayList(lockName),uuid);
//如果未返回值,代表尝试解其他线程的锁
if(result==null){
thrownewIllegalMonitorStateException("attempttounlocklock,notlockedbylockName:"
+lockName+"withrequest:"+uuid);
}
}

使用

publicvoidtestLock(){
//加锁
Stringuuid=UUID.randomUUID().toString();
Booleanlock=this.tryLock("lock",uuid,300l);
if(lock){
//读取redis中的num值
StringnumString=this.redisTemplate.opsForValue().get("num");
if(StringUtils.isBlank(numString)){
return;
}
//++操作
Integernum=Integer.parseInt(numString);
num++;
//放入redis
this.redisTemplate.opsForValue().set("num",String.valueOf(num));
//测试可重入性
this.testSubLock(uuid);
//释放锁
this.unlock("lock",uuid);
}
}
//测试可重入性
privatevoidtestSubLock(Stringuuid){
//加锁
Booleanlock=this.tryLock("lock",uuid,300l);
if(lock){
System.out.println("分布式可重入锁。。。");
this.unlock("lock",uuid);
}
}

9 优化分布式锁_自动续期

A线程超时时间设为10s(为了解决死锁问题),但代码执行时间可能需要30s,然后redis服务端10s后将锁删除。 此时,B线程恰好申请锁,redis服务端不存在该锁,可以申请,也执行了代码。

那么问题来了, A、B线程都同时获取到锁并执行业务逻辑,这与分布式锁最基本的性质相违背:在任意一个时刻,只有一个客户端持有锁(即独享排他)。

锁延期方法:开启子线程执行延期

/**
*锁延期
*线程等待超时时间的2/3时间后,执行锁延时代码,直到业务逻辑执行完毕,因此在此过程中,其他线程无法获取到锁,保证了线程安全性
*@paramlockName
*@paramexpire单位:毫秒
*/
privatevoidrenewTime(StringlockName,Stringuuid,Longexpire){
Stringscript="if(redis.call('hexists',KEYS[1],ARGV[1])==1)thenredis.call('expire',KEYS[1],ARGV[2]);return1;elsereturn0;end";
newThread(()->{
while(this.redisTemplate.execute(newDefaultRedisScript<>(script,Boolean.class),Lists.newArrayList(lockName),uuid,expire.toString())){
try{
//到达过期时间的2/3时间,自动续期
Thread.sleep(expire/3);
}catch(InterruptedExceptione){
e.printStackTrace();
}
}
}).start();
}

获取锁成功后,调用延期方法给锁 定时延期:

privateBooleantryLock(StringlockName,Stringuuid,Longexpire){
Stringscript="if(redis.call('exists',KEYS[1])==0orredis.call('hexists',KEYS[1],ARGV[1])==1)"+
"then"+
"redis.call('hincrby',KEYS[1],ARGV[1],1);"+
"redis.call('expire',KEYS[1],ARGV[2]);"+
"return1;"+
"else"+
"return0;"+
"end";
if(!this.redisTemplate.execute(newDefaultRedisScript<>(script,Boolean.class),Arrays.asList(lockName),uuid,expire.toString())){
try{
//没有获取到锁,重试
Thread.sleep(200);
tryLock(lockName,uuid,expire);
}catch(InterruptedExceptione){
e.printStackTrace();
}
}
//锁续期
this.renewTime(lockName,uuid,expire*1000);
//获取到锁,返回true
returntrue;
}

10 优化分布式锁_Redlock算法

redis集群状态下的问题:

客户端A从master获取到锁

在master将锁同步到slave之前,master宕掉了。

slave节点被晋级为master节点

客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。安全失效 解决集群下锁失效,参照redis官方网站针对redlock文档:redis.io/topics/dist… [1]

11 本地锁会出现的问题

我们知道java中有synchronized、lock锁、读写锁ReadWriteLock,众所周知这些锁都是本地锁。

提到锁就不得不提JUC:java.util.concurrent包,又称concurrent包。jdk1.5提供,为多线程高并发编程而提供的包,但此文章的场景是分布式场景,后续会出JUC的文章。

简单的介绍一下synchronized及lock锁

synchronized是一个关键字,lock是一个接口,ReentrantLock是实现了lock接口的一个类

ReentrantLock:悲观的独占的互斥的排他的可公平可不公平的可重入锁

synchronized:悲观的独占的互斥的排他的非公平的可重入锁

准备

redis、ab工具(压测)

不使用任何锁的情况下

我们首先创建一个测试方法,testNoLock

@GetMapping("/test")
publicvoidtestNoLock(){
Stringcount=(String)this.redisTemplate.opsForValue().get("count");
if(count==null){
//没有值直接返回
return;
}
//有值就转成成int
intnumber=Integer.parseInt(count);
//把redis中的num值+1
this.redisTemplate.opsForValue().set("count",String.valueOf(++number));
}

测试之前的查看值为1

@GetMapping("/getCount")
publicStringgetCount(){
Stringcount=String.valueOf(this.redisTemplate.opsForValue().get("count"));
returncount;//1
}

接下来使用ab压力测试工具

//ab-n(一次发送的请求数)-c(请求的并发数)访问路径
ab-n100-c50http://127.0.0.1:8080/test/test

再次查询结果为6,说明问题很大

f38c2064-851c-11ee-939d-92fbcf53809c.jpg

使用本地锁

publicsynchronizedvoidtestNoLock(){
Stringcount=String.valueOf(this.redisTemplate.opsForValue().get("count"));
if("null".equals(count)){
//没有值直接返回
return;
}
//有值就转成成int
intnumber=Integer.parseInt(count);
//把redis中的num值+1
this.redisTemplate.opsForValue().set("count",String.valueOf(++number));
}

再次使用ab压力测试工具

ab-n100-c50http://127.0.0.1:8080/test/test

此次结果为106,说明结果是正确的,看样子结果是非常完美的,但是真的很完美吗?

f3a2203a-851c-11ee-939d-92fbcf53809c.jpg

使用集群+本地锁

我们只需要在idea中在启动俩个服务,修改端口号,三个运行实例的名称是相同的,并且网关的配置就是通过服务名在负载均衡,所以我们只需要访问网关,网关就会给我们做负载均衡了。

f3b506be-851c-11ee-939d-92fbcf53809c.jpg

再次使用ab压力测试工具(将count重置为1)

ab-n100-c50http://127.0.0.1:8080/test/test

此次的结果为58!!!

f3cb8696-851c-11ee-939d-92fbcf53809c.jpg

到此我们可以知道,本地锁是有局限性的。







审核编辑:刘清

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • JAVA
    +关注

    关注

    19

    文章

    2904

    浏览量

    102994
  • JVM
    JVM
    +关注

    关注

    0

    文章

    152

    浏览量

    12126
  • Hash算法
    +关注

    关注

    0

    文章

    43

    浏览量

    7362

原文标题:Java手写分布式锁的实现(非常牛逼)

文章出处:【微信号:芋道源码,微信公众号:芋道源码】欢迎添加关注!文章转载请注明出处。

收藏 人收藏

    评论

    相关推荐

    HarmonyOS开发实例:【分布式手写板】

    使用设备管理及分布式键值数据库能力,实现多设备之间手写板应用拉起及同步书写内容的功能。
    的头像 发表于 04-17 21:45 174次阅读
    HarmonyOS开发实例:【<b class='flag-5'>分布式</b><b class='flag-5'>手写</b>板】

    分布式软件系统

    降到最低。负载在各处理机之间分担,可以避免临界瓶颈。 4、当现有机构中已存在几个数据库系统,而且实现全局应用的必要性增加时,就可以由这些数据库自下而上构成分布式数据库系统。 5、相等规模的分布式
    发表于 07-22 14:53

    基于Java分布式缓存优化在网络管理系统中的应用

    问题,除了要提升服务器的硬件档次、提高网络带宽外,对网管服务器端的缓存进行高效的使用和管理也是非常重要的。本文讨论了一种采用Java实现分布式缓存系统来优化网管系统的设计方案。该分布式
    发表于 09-19 09:20

    分布式整流桥测试系统的设计与实现

    分布式整流桥测试系统的设计与实现
    发表于 08-07 00:20

    Java 中利用 redis 实现一个分布式服务

    Java 中利用 redis 实现一个分布式服务
    发表于 07-05 13:14

    如何在集群部署时实现分布式session?

    集群部署时的分布式 session 如何实现
    发表于 07-17 06:57

    分布式系统的优势是什么?

    当讨论分布式系统时,我们面临许多以下这些形容词所描述的 同类型: 分布式的、删络的、并行的、并发的和分散的。分布式处理是一个相对较新的领域,所以还没有‘致的定义。与顺序计算相比、并行的、并发的和
    发表于 03-31 09:01

    HarmonyOS应用开发-分布式任务调度

    1. 介绍本篇CodeLab将实现的内容HarmonyOS是面向全场景多终端的分布式操作系统,使得应用程序的开发打破了智能终端互通的性能和数据壁垒,业务逻辑原子化开发,适配多端。通过一个简单应用开发
    发表于 09-18 09:21

    HarmonyOS应用开发-分布式设计

    设计理念HarmonyOS 是面向未来全场景智慧生活方式的分布式操作系统。对消费者而言,HarmonyOS 将生活场景中的各类终端进行能力整合,形成“One Super Device”,以实现
    发表于 09-22 17:11

    HarmonyOS教程一基于分布式调度的能力,实现远程FA的启动

    1. 介绍开发者在应用中集成分布式调度能力,通过调用指定能力的分布式接口,实现跨设备能力调度。根据Ability模板及意图的不同,分布式任务调度向开发者提供六种能力:启动远程FA(Fe
    发表于 09-10 10:07

    如何高效完成HarmonyOS分布式应用测试?

    , getText等。② 提供远程和本地描述方式一致的分布式持测试API,仅参数不同,使用简单方便。通过UIDriver来实现。③ 分布式UI测试框架集成于IDE,开发者一键开展自动
    发表于 12-13 18:07

    分布式软总线实现近场设备间统一的分布式通信管理能力如何?

    现实中多设备间通信方式多种多样(WIFI、蓝牙等),不同的通信方式使用差异大,导致通信问题多;同时还面临设备间通信链路的融合共享和冲突无法处理等挑战。那么分布式软总线实现近场设备间统一的分布式通信管理能力如何呢?
    发表于 03-16 11:03

    基于OpenHarmony3.1开发的一个分布式手写板应用

    1.介绍基于TS扩展的声明开发范式开发一个分布式手写板应用。涉及的OS特性有分布式拉起和分布式数据管理,使用这两个特性
    发表于 04-07 11:42

    OpenHarmony3.1分布式技术资料合集

    手写板应用。涉及的OS特性有分布式拉起和分布式数据管理,使用这两个特性实现不同设备间拉起与笔迹同步,即每台设备在书写的时候,连接的其他设备都能实时同步笔迹,效果图如下:2.代码结构整个
    发表于 04-11 11:50

    鸿蒙版JS如何实现分布式仿抖音应用

       之前大家看过了 Java 版的《 HarmonyOS 分布式之仿抖音应用 》,现在讲讲 JS 如何实现分布式仿抖音应用,通过 JS 方式开发视频播放,
    的头像 发表于 11-15 09:44 2085次阅读