zoukankan      html  css  js  c++  java
  • Redis进阶二之分布式锁的实现

    前言

    分布式系统中,由于多个进程之间会存在操作共享数据的情况下,此时就需要一个协调系统进行各个进程之间的协调,避免多个进程之间同时修改数据导致互相影响的情况。通常可以采用数据库锁来实现数据不会再同一时间修改,但是数据库锁的悲观锁,比较影响整个系统的性能。并且如果修改的数据并非是数据库中的数据时,通过数据库锁就无法实现了。此时就需要一个分布式锁来进行分布式协调。

    一、分布式锁

    高可用的分布式锁需要达到以下几种特性:

    1、分布式系统中,同一个方法在同一时间只能被一个节点上的一个线程执行

    2、获取锁和释放锁性能较高

    3、具备锁失效的机制,避免死锁

    4、可重入性

    5、非阻塞性,避免获取锁失败之后一直阻塞

    二、redis实现分布式锁

    由于redis是单线程工作的,所以redis天然就具备了同一时间只能有一个线程执行的条件。而redis分布式锁根据redis部署方式不同又分成两个版本,一个是redis单机部署版本,一个是redis集群部署版本。两种redis部署方式的分布式锁的实现也完全不同

    2.1、redis单机模式分布式锁

    redis的字符串操作API中提供了setnx key value方法,该方法的作用是只有在key不存在时才会设置value,redis的分布式锁主要就依赖此方法来实现。

    方案一:采用命令setnx命令获取锁,设置值成功表示获取锁成功,如果设置失败表示key已经存在则表示获取锁失败。通过delete命令删除key的方式释放锁

    步骤如下:

    1、客户端执行setnx命令获取锁,返回值为1表示获取锁成功,返回为0表示当前key已经存在获取锁失败

    2、获取到锁的客户端执行业务逻辑

    3、客户端执行delete命令删除key释放锁

    问题:当客户端执行setnx获取锁之后程序异常或崩溃,则无法通过delete命令来释放锁,此时就会导致锁一直无法释放导致死锁的情况

    方案二:采用命令setnx命令获取锁,并通过expire命令给key添加过期时间,最后通过delete释放锁

    步骤如下:

    1、客户端执行setnx命令获取锁,返回值为1表示获取锁成功,返回为0表示当前key已经存在获取锁失败

    2、获取到锁成功之后执行expire命令给key增加过期时间

    3、获取到锁的客户端执行业务逻辑

    4、客户端执行delete命令删除key释放锁

    5、如果客户端异常或崩溃,可以通过key达到过期时间释放锁的效果

    问题:setnx命令和expire命令是两个命令,所以两个操作不是原子操作,也就是说可能会存在setnx执行成功而expire执行失败的情况。

    方案三:采用set方法加参数的方式达到加锁和过期时间原子操作

    步骤如下:

    1、客户端执行SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]

    其中EX表示过期时间单位为秒,seconds值为过期时长;PX表示过期时间单位为毫秒,millisseconds表示过期时长, NX表示只有当key不存在时才设置,XX表示只有当key存在时才设置

    如 set test_key test_value PX 5 NX, 则表示设置test_key的值为test_value,并且设置了5秒的过期时间,同时只有当key不存在时才执行设置操作

    2、获取到锁的客户端执行业务逻辑

    3、客户端执行delete命令删除key释放锁

    问题:由于delete命令没有做任何校验,所以会存在误删的情况,比如客户端A执行set方法获取锁成功然后执行业务逻辑,由于业务逻辑执行时间较长超过了过期时间,此时key会被删除,而此时客户端B执行set方法获取锁成功之后,客户端A的业务逻辑执行完成,开始执行delete操作释放锁,此时就会将客户端B获取到的锁给释放了,从而出现了误删锁的情况。

    方案四:采用set方法加参数的方式加锁,在释放锁之前需要判断锁释放被当前线程占用

    步骤如下:

    1、客户端执行SET KEY VALUE PX 5 NX进行加锁

    2、获取到锁的客户端执行业务逻辑

    3、执行get(key)获取锁的值和之前设置过的值进行比较,如果期望的值和实际的值相同,则执行delete命令释放锁,如果期望的值和实际的值不相同,则不执行delete命令

    问题:由于get操作和delete操作是两个命令,也就是说get和delete操作并非是原子操作,所以理论上还是会出现get操作的时候确实是期望的值,但是在delete之前实际的值已经变成了新的值了,此时还是会出现误删的情况。

    方案五:采用set方法加参数的方式加锁,采用eval函数原子操作释放锁

    redis的eval函数是执行一段Lua脚本,执行Lua脚本是原子执行的,所以可以通过eval函数在Lua脚本中判断当前锁是否可以释放并且最终释放锁。

    步骤如下:

    1、客户端执行SET KEY VALUE PX 5 NX进行加锁

    2、获取到锁的客户端执行业务逻辑

    3、执行Lua脚本来删除key,删除之前判断是否是原先的值,只不过eval方法是原子操作。

    1  String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return   redis.call('del', KEYS[1]) else return 0 end";
    2   Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

    上面的脚步含义是当前的值是否和期望的值相等,如果相等的话就执行delete操作删除key。

    eval方法是将整个Lua脚本发送给redis执行,eval中的Lua会被当作原子操作交给redis来执行。

    在单机部署redis的情况下,方案五可以达到原子性加锁和原子性释放锁的效果,从而可以达到分布式锁的效果

    2.2、redis集群模式分布式锁

    在集群模式下,单机模式的方案五就会存在不可用的风险,比如以下场景:

    客户端A从master节点获取锁成功,锁信息还没有同步到slave节点就宕机了,此时slave节点升级为新的master节点,客户端B从新的master节点获取锁成功,就导致同时有两个客户端都成功获取到了锁。显然就破坏了分布式锁同一时间只能有一个客户端获取锁的原则。

    Redis官方提供了一种RedLock算法来实现了集群模式下的分布式锁的实现

    2.2.1、RedLock算法

    1、客户端获取当前Unix时间戳

    2、客户端按顺序依次向N个redis节点设置相同的key和唯一的value获取锁的操作(获取锁的过程和单机模式获取锁的方式一样),且需要设置超时时间,超时时间需要小于锁过期的时间

    3、如果满足以下几个条件,那么就表示获取锁成功,否则就表示获取锁失败

    a、锁的可用时间 = (当前时间 - 请求时间) < 锁的过期时间;

    b、超过半数以上的节点都获取锁成功,也就是满足a的要求

    4、获取锁成功之后,锁的过期时间应该重新计算,锁的实际有效时间 = 锁的过期时间 - 获取锁消耗的时间

    5、业务逻辑处理完成之后,需要将所有节点发送释放锁的命令,因为获取锁失败的节点可能已经占锁成功了,只是由于网络原因导致的失败。

    RedLock理论上还是会存在一定的风险,比如当前有A、B、C三个redis节点,客户端1从A、B两个节点获取锁成功,C获取锁失败了;然后节点A宕机重启,且没有持久化。重启之后客户端2从A和C两个节点获取锁成功,就会出现客户端1和客户端2同时占有锁的情况。

    对于这样的问题可以通过节点持久化来避免,或者宕机之后延迟重启,延迟时间为锁的过期时间,这样就可以保证重启之后锁已经完全被释放了。

    2.2.2、RedLock算法的使用Redisson

    目标Redis官方推荐的RedLock算法的实现为Redisson工具包,Redisson底层通过Netty框架实现,提供了redis不同部署环境下的分布式锁的实现。

    1、Redisson的使用

    a、添加maven依赖

    <dependency>
         <groupId>org.redisson</groupId>
         <artifactId>redisson</artifactId>
         <version>3.6.0</version>
    </dependency>

    b、Redisson支持单机模式、主从模式、哨兵模式、集群模式下的分布式锁的实现,只需要通过配置redis节点的主机信息即可。案例如下:

     1         Config config = new Config();
     2         //1.单机模式
     3         config.useSingleServer().setAddress("redis://122.51.172.201");
     4         //2.主从模式
     5         Set<URI> slaveUrls = new HashSet<>();
     6         config.useMasterSlaveServers().setMasterAddress("redis://122.51.172.201").setSlaveAddresses(slaveUrls);
     7         //3.哨兵模式
     8         config.useSentinelServers().addSentinelAddress("redis://122.51.172.201").setMasterName("redis://122.51.172.201");
     9         //4.集群模式
    10         config.useClusterServers().addNodeAddress("redis://122.51.172.201", "redis://122.51.172.201");

    通过创建Config对象并使用指定的模式,然后添加对应模式下的redis节点信息即可

    c、创建Redisson客户端并获取和释放锁

     1         /**获取Redisson客户端 通过静态方法根据Config配置创建Redisson客户端*/
     2         RedissonClient client = Redisson.create(config);
     3         /**设置分布式锁的key*/
     4         String lockKey = "test_lock_key";
     5         /**获取分布式锁*/
     6         RLock lock = client.getLock(lockKey);
     7 
     8         boolean isLock = false;
     9         try{
    10             while (!isLock) {
    11                 isLock = lock.tryLock(5, TimeUnit.SECONDS);//尝试获取分布式锁,有效期为5秒
    12                 if (isLock) {
    13                     //获取锁成功
    14                     System.out.println(Thread.currentThread().getName() + "获取锁成功");
    15                     //TODO 执行业务逻辑
    16                     Thread.sleep(5000L);
    17                 } else {
    18                     System.out.println(Thread.currentThread().getName() + "获取锁失败");
    19                 }
    20             }
    21         }catch (Exception e){
    22             e.printStackTrace();
    23         }finally {
    24             if(isLock) {
    25                 System.out.println(Thread.currentThread().getName() + "释放锁");
    26                 lock.unlock();//释放锁
    27             }
    28         }

    可以发现使用起来十分便捷,对于客户端而言,只需要配置好redis集群的节点信息即可,通过客户端获取RLock对象,然后调用tryLock进行加锁,调用unlock进行释放锁即可。

    2.2、Redisson的工作流程

    Redisson整体工作流程如下图示:

    加锁时,如果采用的是集群模式,会根据hash选择指定的节点,执行Lua脚本进行加锁操作,脚本如下:

    "if (redis.call('exists', KEYS[1]) == 0) then " +
        "redis.call('hset', KEYS[1], ARGV[2], 1); " +
        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
        "return nil; " +
        "end; " +
     "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]); " +
         "return nil; " +
         "end; " +
         "return redis.call('pttl', KEYS[1]);",

    通过Lua脚本执行可以保证命令的原子性,上述脚本的意思是:

    如果key不存在则通过原子操作向hash中添加key并设置过期时间;

    如果key已存在则通过原子操作向hash中的value执行自增操作并重新设置过期时间

    该脚本一个中参数说明:

    KEYS[1] : 表示解锁的key

    ARGV[1] : 锁的key的过期时间

    ARGV[2]:加锁的客户端唯一ID

    1、首先第一个判断当前的key是否存在,如果存在表示锁被占用,如果锁不存在才会执行后面逻辑

    2、再判断key对应的hash的value值是否是当前客户端的唯一ID标识,如果值则加锁成功,如果不是则加锁失败

    3、当获取锁失败时会不断循环尝试获取锁

    另外redisson实现的锁对应的数据结构时hash类型,key是锁的key,field为加锁的客户端标识,value为自增的数字,表示重入的次数。

    释放锁是通过自减的方式修改value值,如果值为0了就直接删除key即可。

  • 相关阅读:
    win10家庭版安装Docker for Windows
    docker镜像拉取速度过慢的解决
    解决docker: error pulling image configuration: Get https://registry-1.docker.io/v2/library/mysql/: TLS handshake timeout.
    ubuntu16.04下安装docker
    Ubuntu 16.04执行 sudo apt-get update非常慢解决方案
    [转载]MySQL存储过程详解  mysql 存储过程
    除非 Windows Activation Service (WAS)和万维网发布服务(W3SVC)均处于运行状态,
    windows资源管理器已经停止工作
    Shell脚本sqlldr导入数据压缩文件截断
    oracle 日常运维
  • 原文地址:https://www.cnblogs.com/jackion5/p/13747584.html
Copyright © 2011-2022 走看看