Java:锁方案

加锁的前提

共享资源被多个线程同时所消费,造成业务逻辑错误

加锁位置

有两种方案:

  1. 共享资源在哪,就对哪进行加锁
  1. 如果共享资源在应用上,可以使用JVM自带的本地锁,如synchronized和lock
  2. 如果在数据库上,使用数据库自带的锁
  1. 在统一的第三方程序上加锁(如redis、zookeeper)

共享资源在应用上

大家常常看到演示的例子,设个静态变量作为应用共享资源,然后使用JVM本地锁来处理,这是没有任何问题的。

共享资源在Mysql上

实际生产中,共享变量极少是在放在应用上,大部分都是放在数据库中进行保存。如果此时再使用JVM本地锁加锁,就会出现并发问题。举例一段代码:

public void updateStock(){
        lock.lock();
        try {
            Stock stock = this.stockMapper.selectone(new QueryWrapper<Stock>().eq("product_code","1001");
            if (stock != null && stock.getCount() > 0){
                stock.setcount(stock.getCount() - 1);
                this.stockMapper.updateById(stock);
            }
        }finally {
            lock.unlock();
        }
    }

上面的业务逻辑是:

  1. 查询库存
  2. 代码层数量减一
  3. 更新库存

由于这段业务代码会出现并发问题,因此我们加了JVM本地锁。但在三种情况下,本地锁并不能保证业务安全。

  1. 锁对象是多例
  2. 事务
  3. 集群部署

锁对象多例

这个很好理解,我们说过加锁有个隐藏的前提,锁是同一把。这种其实还挺好避免,实际开发spring默认是单例,只要我们不去瞎改,一般不会此种情况

事务

有人好奇加了事务,咋就突然有数据安全问题呢。先给大家贴下代码:

    @Transactional
    public void updateStock(){
        lock.lock();
        try {
            Stock stock = this.stockMapper.selectone(new QueryWrapper<Stock>().eq("product_code","1001");
            if (stock != null && stock.getCount() > 0){
                stock.setcount(stock.getCount() - 1);
                this.stockMapper.updateById(stock);
            }
        }finally {
            lock.unlock();
        }

        //执行后续代码
        ....
    }

给大家贴一张因为事务导致的流程示例:
image
问题在于第5步释放锁后,还未提交事务。由于mysql的默认隔离几倍是:可重复读。解决了脏读、不可重复读,另一线程拿到锁查询库存时,查询的是最近提交最新的记录,因此读到的还是21。这样更新了2次库存20

集群部署

集群部署和多例很相似,但有那么点不一样。多例是一个应用上有多个锁对象,而集群部署是一个应用只有一个锁对象,但是有多个应用部署在不同服务器上,总的来说还是多例。此情况和单机部署不加锁是一样的

解决方案

既然共享资源在数据库上,那我们就在数据库层面进行加锁,不要在应用上加锁了。

方案1:使用1条SQL语句解决

我们知道,mysql的inset,delete,update是自带锁机制的。因此上面的代码可通过1条sql语句搞定:

update stock set count = count -1 where product_code = #{productCode}

然后只需要1行代码去调这个SQL即可。

局限性
  1. 锁范围。
  2. 代码逻辑简单,不需查询多次数据库进行判断适用,但在某些业务下,我们一定是要在代码层面多次操作数据库,判断然后进行更新操作。这是一条sql处理不来的
  3. 无法记录更新前后值的情况。
锁范围

大家先理解一个事情:单条SQL也是具有事务的。
innoDB中,有表级锁和行级锁。1张表中表级锁只有1个,但行级锁会有多个,一行一个行级锁。
先说一些重要的结论:

  1. 在实际开发中,mysql的锁不需要我们去加,只要sql符合规则,就会触发相应的锁
  2. 绝大部分场景下要用到行级锁,少部分是表级锁

现在我们的目标只有2个:

  1. 在什么情况下会用到行级锁,什么情况用表级锁?
  2. 在什么时候释放锁?

在什么情况下会用到行级锁,什么情况用表级锁?
单条SQL(insert,delete,update)中,如果触发了索引(什么索引都可),就会用行级锁,否则默认表级锁。一般的select语句不会用到锁

在什么时候释放锁?
在事务结束才会释放锁。

PS:如果第1条SQL拿到了A表级锁,后面要对表A进行操作,只能等待A表级锁释放,不然你SQL即使会使用行级锁也没用

方案2:Mysql悲观锁

悲观锁概念

当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【又名“悲观锁”】。

悲观锁分类

悲观锁主要分为共享锁和排他锁:

共享锁

共享锁【shared locks】又称为读锁,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。

排它锁

排他锁【exclusive locks】又称为写锁,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。

实际开发悲观锁实现方案

说明:Mysql悲观锁不需要我们去手动加锁,Mysql会帮我们加。但需要我们知道SQL在什么样的情况下会去触发悲观锁而已。Mysql悲观锁包括:表锁,行锁,写锁,读锁

  1. 单条更新SQL(insert,delete,update)中,如果触发了索引(什么索引都可),就会用行级锁,否则默认表级锁。
PS: 如果需要select也使用到悲观锁,写法需要是:select...for update; 如果触发索引则使用行级锁,否则表锁。
  1. 搭配事务@Transactional。大家要知道,普通的select语句是没有锁的,即使在事务的情况下,不同事务的select同1条数据是允许的,但往往有业务要求:当查询A数据时,其他数据不可读写。所以如果有此类的要求,可以使用select..for update,同时搭配上@Transactional
悲观锁局限性
  1. 性能问题。很多场景会在事务的情况下使用悲观锁,事务结束才会释放锁,因此相对而言,悲观锁的效率是不如单条SQL语句的
  2. 死锁问题。演示个例子(在事务情况下),按时间顺序如下:
1. A线程执行了代码1,发送 select * from user where id = 1 for update
2. B线程执行了代码2,发送 select * from user where id = 2 for update
3. A线程执行了代码2,发送 select * from user where id = 2 for update,此时由于B线程还未结束事务不会释放锁,A线程在阻塞中
4. B线程执行了代码1,发送 select * from user where id = 1 for update,此时A线程也同理,也拿着锁不释放,B线程阻塞中

于是,死锁的问题就产生了。如何避免?说实话在实际中是很难避免的,在代码流程里尽量保证加锁顺序(SQL顺序)一致,但此方案也只是增加容错率,并不是彻底的解决方案

  1. 对某些表select语句有要求。在读多写多(重要是写多)的情况下(比如说库存表),在mapper.xml中开发人员尽量都使用select..for update对某表进行查询。

方案2:Mysql乐观锁

乐观锁概念

乐观锁是相对悲观锁而言的,乐观锁在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量。
这里概念还是很模糊,可以简单理解:乐观锁允许线程们同时查询同1条数据,但如果同时对此条数据进行更新时,只有第1条能成功,后面其他更新失败,需要再次查询此数据,再更新

乐观锁实现方案

表添加version字段。

1. 在表里添加version字段,数字类型,可默认0。
2. 每次进行update时,version+1,且where条件version必须等于旧值。示例SQL:
select * from sys_user where id = 1; 假设查询出来的version=0
update sys_user set user_name = 'zhangsan',version=version+1 where id = 1 and version = 0
乐观锁优缺点

优点:

  1. 性能比悲观锁好点
  2. 不会产生死锁

缺点:

  1. 高并发场景不适用。乐观锁性能是比悲观锁好,但从业务时间成本来说,是远大于悲观锁的,一旦更新失败,就需要重试(同步或异步都可,看业务),直至成功。
  2. 数据库读写分离不适用。同A表,在B,C数据库都有一张,一张负责写,一张负责读;有时候会由于同步不到位,导致明明已写最新的version了,但读到的还是旧的version

悲观锁和乐观锁的区别

说本质点,两者区别如下:

  1. 悲观锁是 读的时候就开始加锁,其他线程不可访问,只有到事务结束才释放锁,下个线程继续。乐观锁是读的时候没有加锁,允许多个线程读相同的数据,等到更新的时候才体现锁的作用。
  2. 悲观锁是让线程们顺序访问数据,不会出现更新失败的情况。乐观锁是会出现更新失败(没更新到,影响条数为0)的情况,需要再次查询,再次更新

Mysql锁总结

image
PS:他这里悲观锁性能大于乐观锁,是考虑了乐观锁重试的成本。

共享资源在Redis上

实际开发,数据会放在mysql进行保存,但是由于mysql在高并发可能会宕掉,因此很多时候会把热点数据缓存在redis中,则可能会存在共享数据在redis进行保存,因此也可能会存在并发问题

比如下面的这段代码,可能会引发并发问题:

    @Autowired
    private StringRedisTemplate redisTemplate;

    public void testRedis() {
        //获取库存
        String stockCount = redisTemplate.opsForValue().get("stock");

        //库存大于0,则进行减库存操作
        if(StringUtils.isNotEmpty(stockCount)){
            Integer count = Integer.valueOf(stockCount);
            if (count > 0){
                //减库存
                redisTemplate.opsForValue().set("stock", String.valueOf(count--));
            }
        }
    }

解决方案1:Redis乐观锁

Redis提供3个指令来达到乐观锁机制。分别是:watch(监听)、multi(开启事务)、exec(提交事务)
这3个指令是搭配使用,使用顺序:watch -> multi -> exec。

  1. watch。如:watch [key]:监听某个key。
  2. multi。示例:multi
  3. 执行自己的业务操作
  4. exec。示例:exec
    作用:在watch后,如果有其他人去修改了这个key对应的value,则本次的exec将执行失败。

代码实现redis乐观锁

    @Autowired
    private StringRedisTemplate redisTemplate;

    public void testRedis(){
        redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations redisOperations){
                redisOperations.watch("stock");
                //获取库存
                String stockCount = redisTemplate.opsForValue().get("stock");

                //库存大于0,则进行减库存操作
                if(StringUtils.isNotEmpty(stockCount)){
                    Integer count = Integer.valueOf(stockCount);
                    if (count > 0){
                        redisOperations.multi();//开启事务
                        redisTemplate.opsForValue().set("stock", String.valueOf(count--));//减库存
                        List result = redisOperations.exec();//提交事务

                        if(CollectionUtils.isEmpty(result)){
                            //重试
                            try {
                                Thread.sleep(50);
                                testRedis();
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        }

                        return result;
                    }
                }

                return null;
            }
        });
    }

redis乐观锁局限性

  1. 性能差
    实际开发中使用redis乐观锁的情况很少,并不多,也不推荐使用

共享资源在不确定的数据库上

在上面我们讲了,共享资源在确定的数据库上进行保存,比如mysql,redis...但有些数据库人家天生就没自带锁机制,那我们要如何保证并发安全?
无论数据库有无自带锁机制,我们都能通过一套通用的锁机制来解决?答案是可以,即分布式锁

分布式锁概念

分布式锁是控制分布式系统(分布式微服务系统)之间同步访问共享资源的一种方式。
如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,这个时候,便需要使用到分布式锁。

分布式锁常见应用场景

  1. 超卖现象(不解释)
  2. 缓存击穿。

实际生产会在mysql前加一个redis来抵挡高并发,且提示性能。但是redis能缓存的数据有限,因此一般会给redis设置过期时间,减少内存占满的概率。
那问题也来了,redis设置了过期时间,也就意味着,一定会有大量请求钻空挡来到mysql,严重情况mysql宕掉,这一情况就叫缓存击穿。为了防止这一情况,在mysql前加锁,而这个锁就是分布式锁,加了分布式锁后,只会有1个请求能拿到锁,其他请求不会阻塞而是直接失败,拿到锁的请求会获取mysql数据并重新缓存redis中,其他失败在重试时就又能在redis中获取了

PS:在redis给key加了过期时间,通常两个作用:1. 减少内存被占满的概率 2. 防止加了分布式锁后,锁一直不释放。

分布式锁实现方式

  1. redis分布式锁
  2. zookeeper分布式锁
  3. 基于mysql实现

redis分布式锁

redis实现分布式锁,通过两个指令来实现:setnx 和 del

  1. setnx。setnx和set非常类似,也是设置key和value,但不同是set可以重复设置同一个key做更新操作,而setnx只能设置一次相同key。示例:setnx [key] [value]
  2. del,删除setnx设置的key,删除后便可以重新之前的key。示例:del [key]

利用setnx和del的特性,便可实现分布式锁。只会有一个请求setnx成功,然后del。因此setnx也被称为加锁,del为解锁

代码实现redis分布式锁
    public void testRedis(){
        //获取缓存数据
        String stockCount = redisTemplate.opsForValue().get("stock");

        if(StringUtils.isNotEmpty(stockCount)){
            //执行业务代码
            Integer count = Integer.valueOf(stockCount);
            if (count > 0){
                redisTemplate.opsForValue().decrement("stock");//减库存
            }
        }

        //加锁,加锁如果失败重试,成功则进行下面流程
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");//加锁
        
        if(!lock){
            //获取锁失败,重试
            try {
                Thread.sleep(50);
                this.testRedis();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }else {
            //获取mysql库存数据
            Long stock = stockMapper.selectStock();

            //缓存到redis
            redisTemplate.opsForValue().set("stock", String.valueOf(stock));

            //解锁
            redisTemplate.delete("lock");
        }

    }

上面代码有如下缺陷:

  1. 重试是递归调用,有可能会导致栈内存溢出。但在这里改成循环也实现不了,但建议如果能循环重试就循环,再接考虑递归
  2. 如果释放锁的时候,刚好有线程拿到锁了,会再次获取并缓存
  3. 如果加锁后,未释放锁之前应用挂了,就会导致其他线程一直不断重试。

代码优化:解决第3点。给锁加一个过期时间,如果出现加锁后未释放锁的情况,锁会自动过期,避免照成其他线程一直重试。

就这一行代码发生变化。
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111",3, TimeUnit.SECONDS);//加锁,锁时间存在3秒

好。加了过期时间后,会有一个问题:如果加锁后,还未释放锁前,key刚好过期了。这种情况有导致非常严重的问题,举个例子:

假设加锁和解锁中间的业务代码需要执行5秒,然后锁的过期时间设为3秒

  1. 线程A获取锁,并设置3秒过期时间
  2. 线程A执行到第3秒,锁过期了
  3. 线程B马上拿到锁,并设置3秒过期时间
  4. 线程A执行到第5秒,释放锁。此时线程B才执行了2秒的业务,锁又释放掉了
  5. 线程C拿到锁....下面就开始乱了

本质原因是:B线程的锁被A线程的释放掉了。为了解决这个问题,只要这个锁只能被当前线程给释放掉就能避免这个问题。于是我们的代码可以改成如下:

    public void testRedis(){
        //获取缓存数据
        String stockCount = redisTemplate.opsForValue().get("stock");

        if(StringUtils.isNotEmpty(stockCount)){
            //执行业务代码
            Integer count = Integer.valueOf(stockCount);
            if (count > 0){
                redisTemplate.opsForValue().decrement("stock");//减库存
            }
        }

        String uuid = UUID.randomUUID().toString();
        //加锁,加锁如果失败重试,成功则进行下面流程
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid,3, TimeUnit.SECONDS);//加锁,锁时间存在3秒

        if(!lock){
            //获取锁失败,重试
            try {
                Thread.sleep(50);
                this.testRedis();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }else {
            try {
                //获取mysql库存数据
                Long stock = stockMapper.selectStock();

                //缓存到redis
                redisTemplate.opsForValue().set("stock", String.valueOf(stock));
            } finally {
                //解锁;如果等于当前的uuid,才可以解锁成功
                if(redisTemplate.opsForValue().get("lock").equals(uuid)){
                    redisTemplate.delete("lock");
                }
            }
        }

    }

为了追求完美,可能还会出现一个问题,当代码执行到:

if(redisTemplate.opsForValue().get("lock").equals(uuid))

这时候刚好key过期,下个线程拿到锁,接着又被上个线程释放掉,显然不是我们要看到的结果。
如果能把 判断uuid和释放锁归为一个原子,那就能解决这个问题,那么redis有没有提供指令判断并删除呢?
很可惜,没有!但redis对lua脚本语言是支持的,可以通过编写lua脚本对redis发出指令。脚本里我们写判断和释放锁的逻辑即可,因此代码改写为这样:

    public void testRedis(){
        //获取缓存数据
        String stockCount = redisTemplate.opsForValue().get("stock");

        if(StringUtils.isNotEmpty(stockCount)){
            //执行业务代码
            Integer count = Integer.valueOf(stockCount);
            if (count > 0){
                redisTemplate.opsForValue().decrement("stock");//减库存
            }
        }

        String uuid = UUID.randomUUID().toString();
        //加锁,加锁如果失败重试,成功则进行下面流程
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid,3, TimeUnit.SECONDS);//加锁,锁时间存在3秒

        if(!lock){
            //获取锁失败,重试
            try {
                Thread.sleep(50);
                this.testRedis();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }else {
            try {
                //获取mysql库存数据
                Long stock = stockMapper.selectStock();

                //缓存到redis
                redisTemplate.opsForValue().set("stock", String.valueOf(stock));
            } finally {
                String script = "if redis.call('get',KEYS[1]) == ARGV[1] " +
                        "then return redis.call('del',KEYS[1]) " +
                        "else return 0 " +
                        "end";
                //解锁;如果等于当前的uuid,才可以解锁成功
                redisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class),Arrays.asList("lock"),uuid);
            }
        }

    }

分布式锁之分布式可重入锁

可重入锁概念

排它锁,如悲观锁、分布式锁等,都只能有一个线程能到锁,而在这么一种场景下会导致一个问题:不可重入

不可重入:线程A执行方法1获取lock锁,方法1中又调用了方法2,方法2也需要lock锁,而由于锁已被获取,则导致方法2无法进行,进而影响方法1。

解决这个问题关键在于:调用方法2的时候,如果是同一线程就允许可重入(相当再次获取同一把锁),同一线程允许可重入的锁,称为可重入锁。只要允许可重入,他就是可重入锁
接下来要把redis分布式锁再次升级,具有可重入的属性

ReentrantLock可重入锁

java并发包下提供的ReentrantLock具有可重入属性,下面大概说下他可重入的核心代码流程:、

加锁流程
  1. ReentrantLock的默认无参构造实现是:非公平锁
    image
  2. NonfairSync的lock()为加锁方法,先进行CAS操作换取锁,获取成功设置当前线程为有锁线程。获锁失败调用acquire()
    image
  3. acquire()会去先调tryAcquire(),tryAcquire()又去调nonfairTryAcquire(),nonfairTryAcquire()的业务实现大概这么个意思:再次尝试CAS获取锁,获锁成功设置当前线程为有锁线程,获锁失败判断当前线程是否是有锁线程,如果是则state+1(相当获锁成功),可重入性就体现在这
    image
  4. 如果当前线程也不是有锁线程,则获取失败进入队列排队,线程如何入队这块代码如下(这块代码了解即可):
    image

公平锁:线程获取锁时,先排队获取锁
非公平锁:线程获取锁时,直接去获取锁,获锁成功则成功,失败则再入队排队.注意:这里失败是指锁并没有被其他线程拿到,但你获取时却拿不到。线程A加锁还未释放锁的过程期间,其他线程拿不到锁会通过循环重试,并不会去入队。

解锁流程
  1. 释放锁首先调用Sync的unlock(),unlock()—>release()—>tryRelease(),tryRelease的业务流程:state-1后如果为0则释放锁,state-1实际也就是去重入1次,当为0的时候就没有重入了
    image

redis分布式锁升级可重入锁

redis分布式锁升级可重入锁:hash结构 + lua脚本实现

加锁思路
  1. 判断锁释放存在(exists [锁名]),不存在则获取锁(这里也会记一次重入)
  2. 存在则判断是否是自己的锁(hxists [锁名] [线程ID]),是则重入(hincrby [锁名] [线程ID] 1)
  3. 不是则加锁失败,重试(递归或循环)

lua脚本实现:

if redis.call('exists',KEYS[1] == 0) or redis.call('hexists',KEYS[1],ARGV[1]) == 1
	then
		redis.call('hincyby',KEYS[1],ARGV[1],1)
		redis.call('expire',KEYS[1],ARGV[2])
		return 1
else
	return 0
end

KEYS[1]传锁名,ARGV[1]传线程ID,ARGV[2]传过期时间。

解锁思路
  1. 判断自己的锁释放存在,不存在则返回nil
  2. 存在则释放1次,释放后判断是否等于0,为0则执行解锁操作
  3. 不为0则返回0
    在这里nil表示锁不存在,1解锁成功,0是释放一次还未解锁成功

lua脚本实现:

if redis.call('hexists',KEYS[1],ARGV[1]) == 0
	then
		return nil
elseif redis.call('hincrby',KEYS[1],ARGV[1],-1) == 0
	then
		return redis.call('del',KEYS[1])
else
	return 0
end

KEYS[1]传锁名,ARGV[1]传线程ID

代码实现redis可重入锁

DistributedLockFactory:分布式锁工厂类,可能后面还会有其他类型分布式锁,redis只是其一

@Component
public class DistributedLockFactory {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private String threadIdPrefix;

    public DistributedLockFactory(){
        this.threadIdPrefix = UUID.randomUUID().toString();
    }

    //redis分布式锁
    public RedisDistributedLock getRedisDistributedLock(String lockName,Long expireTime){
        return new RedisDistributedLock(redisTemplate,lockName,threadIdPrefix,expireTime);
    }

}

RedisDistributedLock:redis可重入锁

public class RedisDistributedLock implements Lock {

    private StringRedisTemplate redisTemplate;
    private String lockName;
    private String threadIdPrefix;
    private String threadId;
    private Long expireTime;

    //加锁
    @Override
    public void lock() {
        this.tryLock();
    }

    public RedisDistributedLock(StringRedisTemplate redisTemplate,String lockName,String threadIdPrefix,Long expireTime){
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
        this.threadIdPrefix = threadIdPrefix;
        this.threadId = threadIdPrefix + ":" + Thread.currentThread().getId();

        if(expireTime == null){
            expireTime = 30L;
        }
        this.expireTime = expireTime;
    }
    //尝试加锁
    @Override
    public boolean tryLock() {
        try {
            return tryLock(expireTime,TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    //尝试加锁
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        String accQuireLockScript =
                "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
                "then " +
                "   redis.call('hincrby', KEYS[1], ARGV[1],1) " +
                "   redis.call('expire', KEYS[1], ARGV[2]) " +
                "   return 1 " +
                "else " +
                "   return 0 " +
                "end";

        if(redisTemplate.execute(new DefaultRedisScript<>(accQuireLockScript,Boolean.class), Arrays.asList(lockName), threadId,String.valueOf(expireTime))){
            //加锁成功,开始监听是否需要续期
            this.autoRenewal();
            return true;
        }

        return false;
    }

    @Override
    public void unlock() {
        String unLockScript = "if redis.call('hexists',KEYS[1],ARGV[1]) == 0 " +
                "then " +
                "   return nil " +
                "elseif redis.call('hincrby',KEYS[1],ARGV[1],-1) == 0 " +
                "   then return redis.call('del',KEYS[1]) " +
                "else" +
                "   return 0 " +
                "end";

        Long result = redisTemplate.execute(new DefaultRedisScript<>(unLockScript, Long.class), Arrays.asList(lockName), threadId);

        if(result == null) throw new IllegalMonitorStateException("锁名:"+lockName+"在redis不存在!");

    }

    @Override
    public Condition newCondition() {
        return null;
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    //自动续期
    private void autoRenewal(){
        String autoRenewalScript = "if redis.call('hexists',KEYS[1],ARGV[1]) == 1 " +
                "then " +
                "   return redis.call('expire',KEYS[1],ARGV[2]) " +
                "else " +
                "   return 0 " +
                "end";

        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                if(redisTemplate.execute(new DefaultRedisScript<>(autoRenewalScript,Boolean.class),Arrays.asList(lockName),threadId,String.valueOf(expireTime))){
                    autoRenewal();
                };
            }
        }, this.expireTime * 1000 / 3);
    }

}

解释:

  1. threadId 是 uuid + 线程ID组成。这样好处是可以保证分布式系统中每个线程有唯一标识。不能仅仅用线程ID,因为不同的进程(微服务)线程ID是可以重复的,因此用UUID来区分不同的进程,当应用初始化时,由于DistributedLockFactory是单例的,则可以初始化其时生成一个UUID,这样相当每个进程都有属于他的UUID
  2. 如果加锁失败会返回false,是否需要重试由调用者决定。一般重试要么递归,要么循环
  3. 自动续期的目的是为了尽可能让业务逻辑能顺利完成,由定时任务来处理。但是自动续期时长根据具体业务要求决定,像上面的代码就是得等锁释放掉才不再续期
  4. 定时任务在判断是否自动续期时,threadId必须和加锁解锁的threadId一致。

redis分布式锁局限性

  1. 上面说的redis分布式锁,只适用于单个redis适用。如果是主从redis(主机挂掉,从机顶上)或redis集群则不适用
  2. 单点故障

Redisson分布式锁

感谢各位看官看到这,熬出头了!上面讲了一大堆,最后得出的结论居然是:只适用于单个redis情况,看着想打人
关于redis主从或是集群,redis分布式锁要如何写,这个不需要我们来考虑,有一个框架:Redisson,以redis为基础提供了大量的分布式类可供我们使用,其中就包含分布式锁,并且他的分布式锁能满足单机、主从、集群...各种场景下的redis部署情况
说白了,到头来还是用别人封装好的框架哈哈

Redisson分布式锁使用步骤

  1. 引入依赖

  2. 根据系统redis部署情况(单机、集群、主从),编写对应的配置代码(具体看官方文档),下面示例为单节点redis配置:
    image

  3. 使用。使用时屏蔽了redis部署情况,代码还是lock()加锁和unlock()解锁,和上面演示例子代码没啥区别

分布式可重入锁

使用示例代码:

@Autowired
    private RedissonClient RedissonClient;

    @RequestMapping(value = "/testOne")
    public void testOne() {
        //RedisDistributedLock lock = lockFactory.getRedisDistributedLock("lock", null);
        RLock lock = RedissonClient.getLock("lock");

        lock.lock();

        try {
            Thread.sleep(1000 * 30);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("重入中1=======================");
        tesTwo();

        lock.unlock();
    }

    public void tesTwo() {
        RLock lock = RedissonClient.getLock("lock");
        lock.lock();
        System.out.println("重入中2=======================");
        lock.unlock();
    }
公平锁
RLock lock = RedissonClient.getFairLock("lock");
红锁

image

读写锁

读写锁有点小特殊。我们上面讲的全部锁都是排他的,线程1拿到A锁了,其他线程不管是哪里的代码块同样要获取A锁,就得等线程1释放
而我们刚好有这样的需求:

  1. 不同代码块一写一写不能并发
  2. 不同代码块一读一写不能并发
  3. 不同代码块一读一读能并发

如果采用全排他锁,1能实现,3不加锁就行。而2实现不了
因此读写锁刚好能处理这3个问题。
示例代码如下:

RReadWriteLock rwlock = RedissonClient.getReadWriteLock("rwlock");
rwlock.writeLock().lock();//写锁加锁,不能并发写,也不允许读
//写操作
rwlock.writeLock().unlock();//写锁解锁
RReadWriteLock rwlock = RedissonClient.getReadWriteLock("rwlock");
rwlock.readLock().lock();//读锁加锁,允许并发读,但是不允许写
//写操作
rwlock.readLock().unlock();//读锁解锁
posted @ 2022-10-31 10:02  爱编程DE文兄  阅读(38)  评论(0编辑  收藏  举报