SETNX分布锁详解

前提

为何要使用分布式锁?
其实使用分布式锁的目的与平常使用synchronized锁,Lock锁的目的一致,就是为了确保多线程并发时,在某些业务场景中让这些线程串行执行。
通常在一个JVM中让线程串行执行是比较容易实现的,例如synchronized锁,Lock锁等。但是现在为了应对更大的并发量,通常会将服务拆分或者搭建集群,此时synchronized锁,Lock锁就无法让处于不同服务器中的线程互斥,或者说让处于不同JVM中的线程互斥。此时就需要使用分布式锁让这些不同服务中的线程互斥,从而串行执行。
集群下锁失效
那什么是分布式锁?
分布式锁:在分布式系统下或者集群模式下能够实现多个线程之间互斥并且可见。
分布式锁的思想就是让处于不同服务器中的线程共同使用同一把锁,从而保证这些线程在某些场景下串行执行
分布式锁有那些?
说到分布式锁其实还是有很多的,像mysql分布式锁,redis分布式锁,zookeeper分布式锁等
现在我们采用Redis作为分布式锁,那么将上述图片的中的两个监视器换为一个redis在外部,那么图片就形象生动的出现在脑海中,脑补

SETNX命令的特性

SETNX:向Redis中添加一个key,只用当key不存在的时候才添加并返回1,存在则不添加返回0。并且这个命令是原子性的。
使用SETNX作为分布式锁时,添加成功表示获取到锁,添加失败表示未获取到锁。至于添加的value值无所谓可以是任意值(根据业务需求),只要保证多个线程使用的是同一个key,所以多个线程添加时只会有一个线程添加成功,就只会有一个线程能够获取到锁。而释放锁锁只需要将锁删除即可。
总结:

  1. 获取锁:通过setnx添加
  2. 释放锁:通过del将锁删除

SETNX分布式锁

设置过期时间防止死锁

假设线程1通过SETNX获取到锁并且正常执行然后释放锁那么一切ok,其它线程也能获取到锁。但是线程1现在"耍脾气"了,线程1抱怨说"工作太久有点累需要休息一下,你们想要获取锁等着吧,等我把活干完你们再来获取锁"。此时其它线程就无法向下继续执行,因为锁在线程1手中。这种长期不释放锁情况就有可能造成死锁
为了防止像线程1这种"耍脾气"的现象发生,我们可以设置key的过期时间来解决。设置过期时间过后其它线程可不会惯着线程1,其它线程表示你要休息可以,休息了指定时间把锁让出来然后拍拍屁股走人,没人惯着你。
Redis命令:

在添加时存在则添加,不存在则不添加。同时设置过期时间,单位秒
SET key value NX EX time
原命令及其参数   
SET key value [NX | XX] [GET] [EX seconds | PX milliseconds |
  EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]

JAVA代码:

//通过java代码实现SETNX同时设置过期时间
//key--键   value--值  time--过期时间  TimeUnit--时间单位枚举
stringRedisTemplate.opsForValue().setIfAbsent(key, value , time, TimeUnit);

SETNX分布式锁时误删情况

情况一
设置过期时间线程1被治得服服帖帖,此时线程1又开始不当人了。线程1想既然你抢我得锁,等你获得锁后我就将锁删除毕竟我还要有备用钥匙,让你也锁不住,让其它线程也执行
线程1休息的时间超过了过期时间,此时锁会自动释放。线程2现在脱颖而出抢到了锁然后开心的继续执行。但是现在线程1醒了,发现线程2抢走了锁。线程1表示小子胆挺肥啊,敢抢我的锁,等我执行完了就将你锁删除,让其它"哥们"也进来。此时就会发生蝴蝶效应,线程1删除了线程2的锁,线程2删除了线程3的锁,直到最后一个"哥们:wc,我锁了?"。当然线程是无感知,其实线程1乃至其它线程都不知道删除的是别人的锁,全部线程都以为删除的是自己的锁。直到最后一个线程无锁可删。
这种误删锁的情况让锁的存在荡然无存,本来应该串行执行的线程,在一定程度上都开始并发执行了。
那么误删情况该如何解决了?
我们可以给锁加上线程标识,只有锁是当前线程的才能删除,否则不能删除。在添加key的时候,key的value存储当前线程的标识,这个标识只要保证唯一即可。可以使用UUID或者一个自增数据。在删除锁的时候,将线程标识取出来进行判断,如果相同就表示锁是自己的能够删除,否则不能删除。

获取锁:

//获取线程前缀,同时也是线程表示。通过UUID唯一性
private static final String ID_PREFIX= UUID.randomUUID().toString(true)+"-";
//与线程id组合
public boolean tryLock(long timeOut) {
        //获取线程id
        String id =ID_PREFIX+ Thread.currentThread().getId();
        //获取锁
        Boolean absent = stringRedisTemplate.opsForValue().setIfAbsent(key, id , timeOut, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(absent);
    }

释放锁:

public void unLock() {
		//获取存储的线程标识
        String value = stringRedisTemplate.opsForValue().get(key);
        //当前线程的线程标识
        String id =ID_PREFIX+ Thread.currentThread().getId();
        //线程标识相同则删除否,则不删除
        if (id.equals(value)){
            stringRedisTemplate.delete(key);
        }
    }

情况二
加入线程标识后,线程一不能随便删除其它线程的锁,但是线程1又开始不当人了。线程1表示判断线程标识和释放锁的操作我可以分开执行,这又不是一个原子性的操作,线程1干完活以后就准备去释放锁,当线程1判断锁是自己的后表示开锁太累了,休息一会在开。此时其它线程就想无所谓,反正过期时间一到锁就会自动释放。但是线程1已经判断了锁是自己的以后就不会执行判断锁的操作(线程1已经执行了if判断,只是没有执行方法体),当线程2获得锁后,线程1仍然能删除线程2的锁。
特别说明:

  1. 线程1执行的时间片到了就会发生线程切换
  2. JVM执行垃圾回收,因为垃圾回收会暂停用户线程,让垃圾回收线程单独执行。尽管现在的CMS,G1垃圾回收器能够做到并发收集,但是CMS和G1的初始标记以及CMS的重新标记,G1的最终标记仍然需要STW(Stop The World)
public void unLock() {
        String value = stringRedisTemplate.opsForValue().get(key);
        //在此处发生垃圾回收,线程1暂停并且超过了过期时间,线程2先获得了锁
        String id =ID_PREFIX+ Thread.currentThread().getId();
        if (id.equals(value)){
        //在此处发生垃圾回收,线程1暂停并且超过了过期时间,线程2先获得锁
            stringRedisTemplate.delete(key);
        }
    }

此时就需要将判断锁的操作和删除锁的操作作为一个整体执行,要么全部成功,要么全部失败,保证删除锁的原子性。

Lua脚本解决多条命令原子性问题

关于Lua语言参考此链接:Lua
导致线程1删除线程2锁的原因就是判断锁和删除锁的操作不是原子性的。此时可以使用Lua脚本保证多条命令的原子性。
Redis提供的函数:

redis.call('命令名称', 'key', '其它参数', ...)
例如:
redis.call('set','name','jack')---->set name jack

Lua脚本: 会将key存储在KEYS数组中,会将value存储在ARVG数组中。下标从1开始

if (redis.call('get',KEYS[1])==ARGV[1]) then
    return redis.call('DEL',KEYS[1])
end
    return 0

JAVA:通过execute()方法执行Lua脚本,DefaultRedisScript加载脚本

private static final DefaultRedisScript<Long> DEFAULT_REDIS_SCRIPT;
    static {
        DEFAULT_REDIS_SCRIPT=new DefaultRedisScript<Long>();
        DEFAULT_REDIS_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        DEFAULT_REDIS_SCRIPT.setResultType(Long.class);
    }
     public void unLock() {
        stringRedisTemplate.execute(DEFAULT_REDIS_SCRIPT, Collections.singletonList(RedisConstants.KEY_PREFIX + name),ID_PREFIX+ Thread.currentThread().getId());
    }

最后

文中提及SETNX作为分布式锁的原理以及存在的问题都是学习过程中的总结。
SETNX分布锁锁存在的问题:

  1. 重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
  2. 不可重试:是指目前的分布式只能尝试一次,合理的情况是:当线程在获得锁失败后,应该能再次尝试获得锁。
  3. 超时释放:加锁时增加了过期时间,可以防止死锁,但是如果卡顿的时间超长,虽然采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患
  4. 主从一致性: 如果Redis提供了主从集群,当向集群写数据时,主机需要异步的将数据同步给从机,如果在同步过去之前,主机宕机了,就会出现死锁问题。
  • 25
    点赞
  • 119
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
分布的主要目的是在分布式系统中实现多个线程之间的互斥和可见性,从而让这些线程在某些业务场景下可以串行执行。通常情况下,在单个JVM中,可以通过使用synchronized或Lock来实现线程的串行执行。但是在分布式系统中,由于服务拆分或集群的需要,单个JVM中的无法实现不同服务器或不同JVM中线程的互斥。因此,需要使用分布来实现这种互斥。 分布有多种实现方式,比如基于MySQL、Redis、Zookeeper等。在使用Redis作为分布时,可以利用SETNX命令来实现的获取和释放。SETNX命令会向Redis中添加一个key,只有当该key不存在时才添加并返回1,若key已存在则不添加并返回0。因为SETNX命令是原子性的,所以多个线程同时对同一个key执行SETNX命令时,只会有一个线程成功添加,从而实现了互斥。释放只需要将对应的key删除即可。 为了防止死,可以给设置过期时间,以防止某个线程获取后未能释放导致其他线程无法获取的情况。此外,分布还存在一些问题,如重入问题、不可重试问题、超时释放和主从一致性问题等。这些问题需要根据具体情况进行解决。 综上所述,使用SETNX命令来实现分布可以确保多线程之间的互斥和可见性。通过设置过期时间等方法可以增加分布的稳定性和安全性。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [SETNX分布详解](https://blog.csdn.net/qq_47288175/article/details/127441620)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值