【Java业务需求解决方案】分布式锁应用详情,多种方案选择,轻松解决,手把手操作(非全数字编码依次加一问题)

文章详细描述了在使用Redis的setnx实现分布式锁过程中遇到的问题,如锁释放、误删、内存溢出和原子性等,并通过添加过期时间、使用Lua脚本和Redisson框架优化,最终提供了一个可重入锁的Java实现。文中强调了分布式锁的四个基本条件和Redisson在简化锁管理上的优势。
摘要由CSDN通过智能技术生成

目录

背景:

解决方案:

分布式锁方案一(不建议,但原理得懂):Redis锁setnx与业务代码处理

雏形代码

产生问题一:锁释放问题

代码改造:锁添加过期时间

产生问题二:锁被别的线程误删

代码改造:添加setnx锁请求标识防勿删

产生问题三:递归容易造成内存溢出

代码改造:递归改造while循环

产生问题四:查询锁并且删除锁产生原子性问题

代码改造:Lua原子性操作

产生问题五:业务还没执行完,锁就过期了

代码改造:setnx 锁自动续期

终极版:java代码实现(嫌前面麻烦直接看这个,不懂再去翻前面)

总结:

会出现的问题

分布式方案二:开源框架:Redisson

Redisson 概述

官网介绍

入门整合(嫌麻烦直接看这个)

测试

Redisson 分布式锁测试

扩展知识

常见分布式锁方案对比

分布式锁需满足四个条件

redisson加锁&解锁Lua脚本

1、加锁Lua脚本

2、解锁Lua脚本


背景:

现有编码格式为业务常量+数字,每新增一条数据在基础上+1,比如:

          文件类型1                编码为ZS01
          文件类型1下文件1   编码为ZS0101
          文件类型1下文件2   编码为ZS0102
          文件类型2                编码为ZS02
          文件类型2下文件1   编码为ZS0201
          文件类型2下文件2   编码为ZS0202

解决方案:

使用mysql中count()函数与where条件,查询出条数充当最大值,再此基础上加1,生成编码,通过编码工具类实现格式统一,并使用redis分布式锁解决并发问题。

分布式锁方案一(不建议,但原理得懂):Redis锁setnx与业务代码处理

redis 的 setnx区别于普通set,他是 set key if not exist ,当一个key不存在的时候,可以设置成功。那么,我们就可以把 setnx 来设定某个key为一把锁,这个key存在的时候,则表示获得锁,那么请求无法操作共享资源,除非这个key不存在了,那就行。

第一次设置成功,第二次设置不成功,因为这个key没有释放,除非删除了,或者超时清除了,那么才可以。

从上面操作可以看得出来,这其实也是分布式锁的3个关键步骤,加锁设值,删除解锁,重试(死循环或者递归)
通过如下流程可以更好梳理思路:

雏形代码

产生问题一:锁释放问题

代码改造:锁添加过期时间

思考问题:
如果业务执行的过程抛出异常了,怎么办?锁会一直没释放。
如果当前运行这段代码的计算机节点突然停电了,代码正准备删除lock,这个时候咋办?锁也会一直存在。

提出的两个问题,其实我们要保证锁最终不管怎样都要释放,所以,我们可以为锁添加过期时间,如上图。
一旦后续发生故障,那么30秒后还是能释放锁。但是这个时候还是会有问题,程序正好运行到1.1还没来得及设置过期时间,拉电了,此时锁设置成功,但是没有设置过期时间,还是有问题,所以,要么全设置成功,原子性必须得保证。我们可以使用 setnx内置的,可以多加时间参数来设置。

产生问题二:锁被别的线程误删

代码改造:添加setnx锁请求标识防勿删

产生问题三:递归容易造成内存溢出

代码改造:递归改造while循环

目前所使用的递归方案,高并发时也容易造成内存溢出,那么其实可以改造一下,改为死循环即可只要获得锁失败,则返回去尝试获得锁即可

产生问题四:查询锁并且删除锁产生原子性问题

代码改造:Lua原子性操作

图中箭头处,当我们拿出锁后,并且判断也成功了,在这一刹那间,锁也可能正好失效吧。这个时候已经进入了判断内部了,所以会执行删除锁,但是这个时候因为锁恰好失效,所以其他请求就占有锁,那么自己在删除锁的时候,其实删除的是别人的锁,这样在极端的情况下其实也会出问题的。此时怎么办?

查询锁并且删除锁,这其实也是原子性操作,因为上一节课说了,这里也是可能会删除其他的锁的因为原子性保证不了。
所以接下来我们所需要做的,就是保证查询以及判断都是原子性的操作。这里就需要结合使用LUA脚本来解决这个问题
可以打开redis官网:https://redis.io/commands

解释:get命令获得key与参数比对,如果比对一致,则删除,否则返回0。这是一段脚本,是一个命令一起运行的,所以要比我们程序代码中的调用要来的更好,因为这是原子性操作。要么全成功,要么全失败。
在命令行可以通过eval命令来进行操作:

把上述脚本转换为一个字符串(大家可以直接复制)

 // 使用LUA脚本执行删除key操作,为了保证原子性
            String lockScript =
                    " if redis.call('get',KEYS[1]) == ARGV[1] "
                            + " then "
                            +   " return redis.call('del',KEYS[1]) "
                            + " else "
                            +   " return 0 "
                            + " end "
                    ;

在通过redis调用即可

产生问题五:业务还没执行完,锁就过期了

代码改造:setnx 锁自动续期

遗留问题思考:
我在这里设置了30秒,如果业务执行时间很长,需要35秒,这个时候还没等业务执行完毕就释放锁了,那么其他请求就会进来处理共享资源,那么锁其实就失效了,没起到作用了。而且在第个请求执行到第35秒的时候,会被第一个请求的del给删除锁,这个时候完全乱套了,各自没有删除自己的锁而是删的其他请求的锁,整个都乱了,怎么办?前面我们设置了超时时间,但是如果真的业务执行很耗时,超时了,那么我们应该给他自动续期啊开启(fork)一个子线程,定时检查,如果lock还在,则在超时时间重置,如此循环,直到业务完成后删除锁。(或者使用while死循环也行)
LUA脚本:

 // if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('expire',KEYS[1],30) else return 0 end

        String refreshScript =
                " if redis.call('get',KEYS[1]) == ARGV[1] "
                        + " then "
                        +   " return redis.call('expire',KEYS[1],30) "
                        + " else "
                        +   " return 0 "
                        + " end "
                ;

终极版:java代码实现(嫌前面麻烦直接看这个,不懂再去翻前面)

那么执行过程中,会经历几次续期,结束了,就释放timer。

 @Transactional
   @Override
    public void modifyCompanyInfo3(ModifyCompanyInfoBO companyInfoBO, Integer num) throws Exception {

        String distLock = "redis-lock";
        String selfId = UUID.randomUUID().toString();
        Integer expireTimes = 30;

        while (redis.setnx(distLock, selfId, expireTimes)) {
            // 如果加锁失败,则重试循环
            System.out.println("setnx 锁生效中,一会重试~");
            Thread.sleep(50);
        }

        // 一旦获得锁,则开启新的timer执行定期检查,做lock的自动续期
        autoRefreshLockTimes(distLock, selfId, expireTimes);

        try {
            System.out.println("获得锁,执行业务~");
            // 加锁成功,执行业务
            Thread.sleep(40000);
            this.doModify(companyInfoBO);
        } finally {
            // 业务执行完毕,释放锁
//            String selfIdLock = redis.get(distLock);
//            if ( StringUtils.isNotBlank(selfIdLock) && selfIdLock.equals(selfId)) {
//                redis.del(distLock);
//            }

            // 使用LUA脚本执行删除key操作,为了保证原子性
            String lockScript =
                    " if redis.call('get',KEYS[1]) == ARGV[1] "
                            + " then "
                            +   " return redis.call('del',KEYS[1]) "
                            + " else "
                            +   " return 0 "
                            + " end "
                    ;
            long unLockResult = redis.execLuaScript(lockScript, distLock, selfId);
            if (unLockResult == 1) {
                lockTimer.cancel();
                System.out.println("释放锁,并且取消timer~");
            }
        }
    }

    private Timer lockTimer = new Timer();

    // 自动续期
    private void autoRefreshLockTimes(String distLock, String selfId, Integer expireTimes) {

        // if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('expire',KEYS[1],30) else return 0 end

        String refreshScript =
                " if redis.call('get',KEYS[1]) == ARGV[1] "
                        + " then "
                        +   " return redis.call('expire',KEYS[1],30) "
                        + " else "
                        +   " return 0 "
                        + " end "
                ;
        lockTimer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("自动续期,重置到30秒");
                redis.execLuaScript(refreshScript, distLock, selfId);
            }
        },
        expireTimes/3*1000,
        expireTimes/3*1000);
    }
   private void doModify(ModifyCompanyInfoBO companyInfoBO) {

       //业务代码
    }

总结:

会出现的问题

这种方案能解决方案一的原子性问题,但是依然会存在很大的问题,如下所示:
1、时钟不同步:如果不同的节点的系统时钟不同步,可能导致锁的过期时间计算不准确。
解决方案:使用相对时间而非绝对时间,或者使用时钟同步工具确保系统时钟同步。
2、死锁:在某些情况下,可能出现死锁,例如由于网络问题导致锁的释放操作未能执行。
解决方案:使用带有超时和重试的锁获取和释放机制,确保在一定时间内能够正常操作。
3、锁过期与业务未完成:如果业务逻辑执行时间超过了设置的过期时间,锁可能在业务未完成时自动过期,导致其他客户端获取到锁。
解决方案:可以设置更长的过期时间,确保业务有足够的时间完成。或者在业务未完成时,通过更新锁的过期时间来延长锁的生命周期。
4、锁的争用:多个客户端同时尝试获取锁,可能导致锁的频繁争用。
解决方案:可以使用带有重试机制的获取锁操作,或者采用更复杂的锁实现,如 Redlock 算法。
5、锁的释放问题:客户端获取锁后发生异常或未能正常释放锁,可能导致其他客户端无法获取锁。
6、锁被别的线程误删:假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完。

分布式方案二:开源框架:Redisson

Redisson 概述

总结一下上面的解决问题的历程和问题,用SETNX+EXPIRE可以解决分布式锁的问题,但是这种方式不是原子性操作。因此,在提出的有关原子性操作解决方法,但是依然会出现几个问题,在会出现的问题中简单罗列了几种问题与解决方法,其中一个问题中有锁过期与业务未完成有一个系统的解决方案,即接下来介绍的Redison。
Redisson 是一个基于 Redis 的 Java 驱动库,提供了分布式、高性能的 Java 对象操作服务,这里只探讨分布式锁的原理:

只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了锁过期释放,业务没执行完问题。

Watchdog 定期续期锁:
当客户端成功获取锁后,Redisson 启动一个 Watchdog 线程,该线程会定期(通常是锁过期时间的一半)检查锁是否过期,并在过期前对锁进行续期。
Watchdog 使用 Lua 脚本确保原子性:
为了确保 Watchdog 操作的原子性,Redisson 使用 Lua 脚本执行 Watchdog 操作。这样在 Watchdog 检查和续期锁的过程中,可以保证整个操作是原子的,防止出现竞争条件。
Watchdog 续期锁的过期时间:
Watchdog 线程会通过使用 PEXPIRE 或者 EXPIRE 命令来续期锁的过期时间。这样在业务未完成时,锁的过期时间会不断延长,直到业务完成释放锁。

Redisson 是 java 的 Redis 客户端之一,是 Redis 官网推荐的 java 语言实现分布式锁的项目。

Redisson 提供了一些 api 方便操作 Redis。因为本文主要以锁为主,所以接下来我们主要关注锁相关的类,以下是 Redisson 中提供的多样化的锁:

  • 可重入锁(Reentrant Lock)
  • 公平锁(Fair Lock)
  • 联锁(MultiLock)
  • 红锁(RedLock)
  • 读写锁(ReadWriteLock)
  • 信号量(Semaphore) 等等

总之,管你了解不了解,反正 Redisson 就是提供了一堆锁… 也是目前大部分公司使用 Redis 分布式锁最常用的一种方式。

本文中 Redisson 分布式锁的实现是基于 RLock 接口,而 RLock 锁接口实现源码主要是 RedissonLock 这个类,而源码中加锁、释放锁等操作都是使用 Lua 脚本来完成的,并且封装的非常完善,开箱即用。

接下来主要以 Redisson 实现 RLock 可重入锁为主。

源码地址:GitHub - niceyoo/redis-redlock: redis分布式锁之redlock应用篇

官网介绍

入门整合(嫌麻烦直接看这个)

和Jedis以及RedisTemplate-样,Redisson其实也是redis的一个客户端
Redisson里面封装了很多有用的api和功能实现,非常实用,当然也包含了分布式锁。Jedis这样的客户端仅仅只是把提供了客户端调用,很多功能其实需要自己去实现封装的。Redisson所提供的是实用redis最简单最便捷的方法,Redisson的宗旨也是让我们使用者关注业务本身,而不是要更关注redis,要把redis这块分离,使得我们的精力更加集中于业务上。
Redisson内部结合实用了LUA脚本实现了分布式锁,并且可以对其做到续约释放等各项功能,非常完善。当然也包含了gc里面的一些锁,JC里面的只能在本地实现,集群分布式下则失效,如果要使用则可以使用Redisson提供的工具来实现锁就行了。

上面的代码其实就是设计为可重入锁,不多整述,简单来讲,就是方法运行,可以多次使用同一把锁。或者说一个线程在不释放的情况下可以获得锁多次,不过在释放的时候也需要释放多次。(有兴趣课后建议去学习一下gc相关内容)

Redisson 支持单点模式、主从模式、哨兵模式、集群模式,这里以单点模式为例,redisson这个框架重度依赖了Lua脚本和Netty,代码很牛逼,各种Future及FutureListener的异步、同步操作转换。

测试

apipost测试接口最终结果的顺序即可

Redisson 分布式锁测试

import org.redisson.config.Config;

测试

1.拔电源测试会否解锁
2.自动续期测试(看门狗)
3.lock设置自定义时间,比如15秒,超时是否自动续期(无看门狗)
4. 测试可重入锁(用同一把锁):重入2次,释放2次

扩展知识

常见分布式锁方案对比

分类方案实现原理优点缺点
基于数据库基于mysql 表唯一索引1.表增加唯一索引
2.加锁:执行insert语句,若报错,则表明加锁失败
3.解锁:执行delete语句
完全利用DB现有能力,实现简单1.锁无超时自动失效机制,有死锁风险
2.不支持锁重入,不支持阻塞等待
3.操作数据库开销大,性能不高
基于MongoDB findAndModify原子操作1.加锁:执行findAndModify原子命令查找document,若不存在则新增
2.解锁:删除document
实现也很容易,较基于MySQL唯一索引的方案,性能要好很多1.大部分公司数据库用MySQL,可能缺乏相应的MongoDB运维、开发人员
2.锁无超时自动失效机制
基于分布式协调系统基于ZooKeeper1.加锁:在/lock目录下创建临时有序节点,判断创建的节点序号是否最小。若是,则表示获取到锁;否,则则watch /lock目录下序号比自身小的前一个节点
2.解锁:删除节点
1.由zk保障系统高可用
2.Curator框架已原生支持系列分布式锁命令,使用简单
需单独维护一套zk集群,维保成本高
基于缓存基于redis命令1. 加锁:执行setnx,若成功再执行expire添加过期时间
2. 解锁:执行delete命令
实现简单,相比数据库和分布式系统的实现,该方案最轻,性能最好1.setnx和expire分2步执行,非原子操作;若setnx执行成功,但expire执行失败,就可能出现死锁
2.delete命令存在误删除非当前线程持有的锁的可能
3.不支持阻塞等待、不可重入
基于redis Lua脚本能力1. 加锁:执行SET lock_name random_value EX seconds NX 命令

2. 解锁:执行Lua脚本,释放锁时验证random_value 
-- ARGV[1]为random_value,  KEYS[1]为lock_name

if redis.call("get", KEYS[1]) == ARGV[1] then

    return redis.call("del",KEYS[1])

else

    return 0

end

同上;实现逻辑上也更严谨,除了单点问题,生产环境采用用这种方案,问题也不大。不支持锁重入,不支持阻塞等待

表格中对比了几种常见的方案,redis+lua基本可应付工作中分布式锁的需求。然而,当偶然看到redisson分布式锁实现方案(传送门),相比以上方案,redisson保持了简单易用、支持锁重入、支持阻塞等待、Lua脚本原子操作,不禁佩服作者精巧的构思和高超的编码能力。下面就来学习下redisson这个牛逼框架,是怎么实现的。

分布式锁需满足四个条件

首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁。
  4. 具有容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁。

  • 38
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值