分布式锁

  分布式锁是控制分布式系统或不同系统之间共同访问共享资源的一种锁实现,如果不同的系统或同一个系统的不同主机之间共享某个资源时,往往需要互斥来防止彼此干扰来保证一致性。

为什么要使用分布式锁

  所有的请求都会分配到当前服务器的JVM内部,然后映射为操作系统的线程进行处理,而这个共享变量只是在这个JVM内部的一块内存空间!后来业务发展,需要做集群,一个应用需要部署到几台机器上然后做负载均衡,大致如下图:

  

  可以看到,变量A存在JVM1、JVM2、JVM3三个JVM内存中(变量A主要体现是在一个类中的一个成员变量,是一个有状态的对象,例如:UserController控制器中的一个整形类型的成员变量),如果不加任何控制的话,变量A同时都会在JVM分配一块内存,三个请求发过来同时对这个变量操作,显然结果是不对的!即使不是同时发过来,三个请求分别操作三个不同JVM内存区域的数据,变量A之间不存在共享,也不具有可见性,处理的结果也是不对的

  为了保证一个方法或属性在高并发情况下,同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。在单机环境中,Java中提供很多并发处理相关的API。

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

二、分布式锁应该具备哪些条件
  1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
  2、高可用的获取锁与释放锁;
  3、高性能的获取锁与释放锁;
  4、具备可重入特性;
  5、具备锁失效机制,防止死锁;
  6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
三、分布式锁的三种实现方式
  分布式的CAP理论告诉“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
  在很多场景中,为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,需要保证一个方法在同一时间内只能被同一个线程执行。

基于数据库实现分布式锁; 
基于缓存(Redis等)实现分布式锁; 
基于Zookeeper实现分布式锁;

四、基于数据库的实现方式
  基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。

  (1)创建一个表

DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
  `desc` varchar(255) NOT NULL COMMENT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

  (2)想要执行某个方法,就使用这个方法名向表中插入数据

  INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');
  因为对method_name做唯一性约束,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么就可以认为操作成功的那个线程获得该方法的锁,可以执行方法体内容。

  注意:
  一般的批量插的 sql语句数据违反唯一性约束时,出现重复数据将会直接报错并停止执行,语句将会报错并停止执行 Warning: (1062, "Duplicate entry ' ' for key '索引'")
  在语句中添加 ignore 关键字
  insert ignore into tb_name (field1,field2) values(f11,f12),(f21,f22)...
  语句数据违反唯一性约束时,出现重复数据则会将会直接跳过

 (3)成功插入则获取锁,执行完成后删除对应的行数据释放锁:

      delete from method_lock where method_name ='methodName';

  使用基于数据库的这种实现方式很简单,但是对于分布式锁应该具备的条件来说,有一些问题需要解决及优化:

  1、因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;
  2、不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;
  3、没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;
  4、不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。
  5、在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。

基于Redis的实现方式
  确保锁的实现满足以下三个特性:
  安全属性(Safety property): 独享(相互排斥)。在任意一个时刻,只有一个客户端持有锁。
  活性A(Liveness property A): 无死锁。即便持有锁的客户端崩溃(crashed)或者网络被分裂(gets partitioned),锁仍然可以被获取。
  活性B(Liveness property B): 容错。 只要大部分Redis节点都活着,客户端就可以获取和释放锁.
  1、选用Redis实现分布式锁原因:
  (1)Redis有很高的性能;
  (2)Redis命令对此支持较好,实现起来比较方便
  2、使用命令介绍:
       (1)SETNX(SET if Not eXists: 如果不存在,则 SET)
               SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。
       (2)expire
               expire key timeout:为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。
       (3)delete
              delete key:删除key
             在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。

实现思想:
      (1)获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过在释放锁的时候进行判断。
      (2)获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
      (3)释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。
        通过Maven引入Jedis开源组件,在pom.xml文件加入下面的代码:

<dependency> 
     <groupId>redis.clients</groupId>
     <artifactId>jedis</artifactId>
     <version>2.9.0</version>
</dependency>

  

public class RedisTool {

    private static final String LOCK_SUCCESS="OK";
    private static final String SET_IF_NOT_EXISTS="NX";
    private static final String SET_WITH_EXPIRE_TIME="PX";

    /**
     * 尝试获取分布式锁
     * @param jedis      jedis客户端
     * @param lockKey    锁
     * @param requestId  请求标识
     * @param expireTime 超时时间
     * @return
     */
    public static boolean tryGetDistributedLock(Jedis jedis,String lockKey,String requestId,int expireTime){
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXISTS, SET_WITH_EXPIRE_TIME, expireTime);
        if(LOCK_SUCCESS.equalsIgnoreCase(result)){
            return true;
        }else{
            return false;
        }
    }
}

  加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:

  第一个为key,使用key来当锁,因为key是唯一的。
  第二个为value,传的是requestId,为什么还要用到value?原因就是在上面讲到可靠性时,通过给value赋值为requestId,就知道这把锁是哪个请求加的,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
  第三个为nxxx,参数填的是NX,意思是SET IF NOT EXIST,即当key不存在时,进行set操作;若key已经存在,则不做任何操作;
  第四个为expx,参数传的是PX,是要给这个key加一个过期的设置,具体时间由第五个参数决定。
  第五个为time,与第四个参数相呼应,代表key的过期时间。
  执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。
  首先,set()加入NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于对锁设置过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。由于只考虑Redis单机部署的场景,所以容错性暂不考虑。

  错误示例

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
        // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
        jedis.expire(lockKey, expireTime);
    }
}

  由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。

释放锁

 /**
     * 尝试释放锁
     * @param jedis
     * @param lockKey
     * @param requestId
     * @return
     */
public static boolean releaseDistributedLock(Jedis jedis,String lockKey,String requestId){
            String script="if redis.call('get',KEYS[1]) == ARGV[1]  " +
                     "    then return redis.call('del',KEYS[1]) " +
                     "    else return 0  " +
                     "   end";
            Object result = jedis.eval(script, Collections.singletonList(lockKey),     
                            Collections.singletonList(requestId));
             if(RELEASE_SUCCESS.equals(result)){
                   return true;
              }else return false;
}

  就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

错误示例

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
        
    // 判断加锁与解锁是不是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) {
        // 若在此时,这把锁突然不是这个客户端的,则会误解锁
        jedis.del(lockKey);
    }
}

  比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。

       这类琐最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:

  1. 在Redis的master节点上拿到了锁;
  2. 但是这个加锁的key还没有同步到slave节点;
  3. master故障,发生故障转移,slave节点升级为master节点;
  4. 导致锁丢失。

  Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock

 基于Zookeeper实现分布式锁

  基于zookeeper临时有序节点可以实现的分布式锁。

  每个客户端对某个方法加锁时,在zookeeper上与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
  锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
  非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑。
  不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
  单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。

  Curator提供的InterProcessMutex是分布式锁的实现。acquire方法用户获取锁,release方法用于释放锁。

  Zookeeper实现的分布式锁其实存在一个缺点,就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同部到所有的Follower机器上。
  使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)
  三种方案的比较
  上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
  从理解的难易程度角度(从低到高)
  数据库 > 缓存 > Zookeeper
  从实现的复杂性角度(从低到高)
  Zookeeper >= 缓存 > 数据库
  从性能角度(从高到低)
  缓存 > Zookeeper >= 数据库
  从可靠性角度(从高到低)
  Zookeeper > 缓存 > 数据库

 

  

 

 

 

  


  

 

posted on 2020-03-01 11:00  溪水静幽  阅读(181)  评论(0)    收藏  举报