关于分布式锁:分布式锁实现方案

5次阅读

共计 8288 个字符,预计需要花费 21 分钟才能阅读完成。

分布式锁是锁的一种,通常用来跟 JVM 锁做区别。JVM 锁就是咱们常说的 synchronized、Lock。JVM 锁只能作用于单个 JVM,能够简略了解为就是单台服务器(容器),而对于多台服务器之间,JVM 锁则没法解决,这时候就须要引入分布式锁。

分布式锁应具备的个性:互斥性:和咱们本地锁一样互斥性是最根本,然而分布式锁须要保障在不同节点的不同线程的互斥。可重入性:同一个节点上的同一个线程如果获取了锁之后那么也能够再次获取这个锁,无需从新竞争锁资源。锁超时:和本地锁一样反对锁超时,避免死锁。反对阻塞和非阻塞:和 ReentrantLock 一样反对 lock 和 trylock 以及 tryLock(long timeOut)。反对偏心锁和非偏心锁(可选):偏心锁的意思是依照申请加锁的程序取得锁,非偏心锁就相同是无序的。这个一般来说实现的比拟少。

基于 redis 分布式锁实现计划

Redis 锁次要利用 Redis 的 setnx 命令

  • 加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回胜利,否则返回失败。KEY 是锁的惟一标识,个别按业务来决定命名。
  • 解锁命令:DEL key,通过删除键值对开释锁,以便其余线程能够通过 SETNX 命令来获取锁。
  • 锁超时:EXPIRE key timeout, 设置 key 的超时工夫,以保障即便锁没有被显式开释,锁也能够在肯定工夫后主动开释,防止资源被永远锁住。
    则加解锁的伪代码如下:

    if (setnx(key, 1) == 1){expire(key, 30)
      try {//TODO 业务逻辑} catch (Exception e){logger.error(e);
      }finally {del(key)
      }
    }

    上述锁存在一些问题:
    1:SETNX 和 EXPIRE 非原子性:如果 SETNX 胜利,在设置锁超时工夫后,服务器挂掉、重启或网络问题等,导致 EXPIRE 命令没有执行,锁没有设置超时工夫变成死锁。
    解决方案:Redis 2.6.12 及更高版本中,set 命令增加了对”set iff not exist”、”set iff exist”和”expire timeout”语义的反对,即应用 setnx 命令同时反对设置过期工夫

    set key value [EX seconds | PX milliseconds] [NX | XX]

    2:锁误会除:如果线程 A 胜利获取到了锁,并且设置了过期工夫 30 秒,但线程 A 执行工夫超过了 30 秒,锁过期主动开释,此时线程 B 获取到了锁;随后 A 执行实现,线程 A 应用 DEL 命令来开释锁,但此时线程 B 加的锁还没有执行实现,线程 A 理论开释的线程 B 加的锁。
    解决方案:通过在 value 中设置以后线程加锁的标识,在删除之前验证 key 对应的 value 判断锁是否是以后线程持有。
    3:超时解锁导致并发:如果线程 A 胜利获取锁并设置过期工夫 30 秒,但线程 A 执行工夫超过了 30 秒,锁过期主动开释,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。

  • A、B 两个线程产生并发显然是不被容许的,个别有两种形式解决该问题:
  • 将过期工夫设置足够长,确保代码逻辑在锁开释之前可能执行实现。
  • 为获取锁的线程减少守护线程,为将要过期但未开释的锁减少无效工夫。

4:不可重入:当线程在持有锁的状况下再次申请加锁,如果一个锁反对一个线程屡次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,因为该锁曾经被持有,再次加锁会失败。Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时开释锁。

在本地记录记录重入次数,如 Java 中应用 ThreadLocal 进行重入次数统计,简略示例代码:private static ThreadLocal<Map<String, Integer>> LOCKERS = ThreadLocal.withInitial(HashMap::new);
// 加锁
public boolean lock(String key) {Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.containsKey(key)) {lockers.put(key, lockers.get(key) + 1);
    return true;
  } else {if (SET key uuid NX EX 30) {lockers.put(key, 1);
      return true;
    }
  }
  return false;
}
// 解锁
public void unlock(String key) {Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.getOrDefault(key, 0) <= 1) {lockers.remove(key);
    DEL key
  } else {lockers.put(key, lockers.get(key) - 1);
  }
}

5:无奈期待锁开释:setnx 命令执行都是立刻返回的,无奈进行阻塞期待获取锁资源。
解决方案:能够通过客户端轮询的形式解决该问题,当未获取到锁时,期待一段时间从新获取锁,直到胜利获取锁或期待超时。这种形式比拟耗费服务器资源,当并发量比拟大时,会影响服务器的效率。

针对应用 setnx 命令来实现分布式锁,会带来以上不具备可重入性,不反对续约和不具备阻塞能力等问题。Redis 官网举荐应用 Redisson 客户端来解决以上呈现的问题。

Redisson 是 Redis 官网的分布式锁组件。GitHub 地址:https://github.com/redisson/r…

Redisson 是一个在 Redis 的根底上实现的 Java 驻内存数据网格 (In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 罕用对象,还实现了可重入锁(Reentrant Lock)、偏心锁(Fair Lock、联锁(MultiLock)、红锁(RedLock)、读写锁(ReadWriteLock) 等,还提供了许多分布式服务。Redisson 提供了应用 Redis 的最简略和最便捷的办法。Redisson 的主旨是促成使用者对 Redis 的关注拆散(Separation of Concern),从而让使用者可能将精力更集中地放在解决业务逻辑上。并且反对单点模式、主从模式、哨兵模式、集群模式。

应用案例:

  public static void main(String[] args) {Config config = new Config();
        // 单机模式
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        RedissonClient redissonClient = Redisson.create(config);
        // 可重入锁
        RLock lock = redissonClient.getLock("LOCK_NAME");
        
        try {
            // 获取锁 默认 30s 过期工夫
            lock.lock();
            // TODO 进行业务逻辑解决 doSomething
       
        } catch (Exception e) {e.printStackTrace();
        } finally {
            // 开释锁
            lock.unlock();}
        
    }

底层实现原理流程:

只有线程一加锁胜利,就会启动一个 watch dog 看门狗,它是一个后盾线程,会每隔 10 秒检查一下,如果线程 1 还持有锁,那么就会一直的缩短锁 key 的生存工夫。因而,Redisson 就是应用 watch dog 解决了锁过期开释,业务没执行完问题。

Redis 的部署形式对锁的影响

下面面探讨的状况,都是锁在单个 Redis 实例中可能产生的问题,并没有波及到 Redis 的部署架构细节。

问题形容:在哨兵模式下,如果线程一在 Redis 的 master 节点上拿到了锁,然而加锁的 key 还没同步到 slave 节点。恰好这时,master 节点产生故障,一个 slave 节点就会降级为 master 节点。线程二就能够获取同个 key 的锁啦,但线程一也曾经拿到锁了,锁的安全性就没了。

解决方案:为了防止 Redis 实例故障而导致的锁无奈工作的问题,Redis 的开发者 Antirez 提出了分布式锁算法 Redlock。Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例顺次申请加锁,并且各实例没有从节点, 互相独立, 不存在主从复制或者其余集群协调机制。如果客户端可能和半数以上的实例胜利地实现加锁操作,那么咱们就认为,客户端胜利地取得分布式锁了,否则加锁失败。这样一来,即便有单个 Redis 实例产生故障,因为锁变量在其它实例上也有保留,所以,客户端依然能够失常地进行锁操作,锁变量并不会失落。

应用案例:

Config config1 = new Config();
config1.useSingleServer().setAddress("redis://192.168.0.1:6379")
        .setPassword("0000").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);

Config config2 = new Config();
config2.useSingleServer().setAddress("redis://192.168.0.2:6379")
        .setPassword("0000").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);

Config config3 = new Config();
config3.useSingleServer().setAddress("redis://192.168.0.3:6379")
        .setPassword("0000").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);

String resourceName = "LOCK_NAME";

RLock lock1 = redissonClient1.getLock(resourceName);
RLock lock2 = redissonClient2.getLock(resourceName);
RLock lock3 = redissonClient3.getLock(resourceName);
// 向 3 个 redis 实例尝试加锁
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {// isLock = redLock.tryLock();
    // 500ms 拿不到锁, 就认为获取锁失败。10000ms 即 10s 是锁生效工夫。isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
    System.out.println("isLock ="+isLock);
    if (isLock) {//TODO if get lock success, do something;}
} catch (Exception e) {
} finally {
    // 无论如何, 最初都要解锁
    redLock.unlock();}

​Redlock 缺点:
1:须要独自保护多个 Redis 实例,晋升零碎的保护老本,不反对原有的集群,主从和哨兵模式。
2:重大依赖零碎时钟,某个 master 的零碎工夫产生谬误,造成它持有的锁提前过期开释了,极其状况下 redlock 不能保障一致性。

基于 zookeeper 分布式锁实现计划

排它锁

排他锁,又称写锁或独占锁。如果事务 T1 对数据对象 O1 加上了排他锁,那么在整个加锁期间,只容许事务 T1 对 O1 进行读取或更新操作,其余工作事务都不能对这个数据对象进行任何操作,直到 T1 开释了排他锁。排他锁外围是保障以后有且仅有一个事务取得锁,并且锁开释之后,所有正在期待获取锁的事务都可能被告诉到。Zookeeper 的强一致性个性,可能很好地保障在分布式高并发状况下节点的创立肯定可能保障全局唯一性,即 Zookeeper 将会保障客户端无奈反复创立一个曾经存在的数据节点。能够利用 Zookeeper 这个个性,实现排他锁。

  • 定义锁:通过 Zookeeper 上的数据节点来示意一个锁
  • 获取锁:客户端通过调用 create 办法创立示意锁的长期节点,能够认为创立胜利的客户端取得了锁,同时能够让没有取得锁的节点在该节点上注册 Watcher 监听,以便实时监听到 lock 节点的变更状况。(羊群效应)
  • 开释锁:以下两种状况都能够让锁开释以后取得锁的客户端产生宕机或异样,那么 Zookeeper 上这个长期节点就会被删除。失常执行完业务逻辑,客户端被动删除本人创立的长期节点。

    总结:排它锁形式应用简略,但在并发问题比较严重的状况下,性能较低,次要起因是,所有的连贯都在对同一个节点进行监听,当服务器检测到删除事件时,要告诉所有的连贯,所有的连贯同时收到事件,再次并发竞争,触发羊群效应。偏心锁 基于 zookeeper 长期有序节点个性和 watch 机制实现。

    执行逻辑:

    1:当须要进行线程同步时,先申请加锁,加锁时,向 zookeeper 服务器指定的门路下 /lock 创立一个长期有序节点。
    2:若以后节点的编号是所有节点中最小的,则立即取得这把锁,可执行业务逻辑,执行完后(或者执行失败抛出异样导致节点删除),被动删除节点进行开释锁。
    3:若以后节点的编号不是最小的,则向比本人小的节点增加一个监听器,当比本人小的节点被删除会收到告诉,立即获取锁,执行业务逻辑。

    总结:

    基于偏心锁实现形式可能避免出现羊群效应,性能、效率晋升较大,能较好地实现阻塞式锁。

    基于数据库分布式锁实现计划

    惟一索引

    利用 mysql 惟一索引的个性,这个惟一的索引列就是分布式环境下互斥的资源,如果某个节点先插入了这个惟一索引对应的列值,那么其余节点就会插入失败,也就是获取锁失败了,也就达到了互斥性。

    表设计

CREATE TABLE `distributed_lock` (`id` bigint(20) NOT NULL AUTO_INCREMENT,  `unique_mutex` varchar(255) NOT NULL COMMENT '业务防重 id',  `holder_id` varchar(255) NOT NULL COMMENT '锁持有者 id',  `create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,  PRIMARY KEY (`id`),  UNIQUE KEY `mutex_index` (`unique_mutex`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;

id 字段是数据库的自增 id,unique_mutex 字段就是咱们的防重 id,也就是加锁的对象,此对象惟一。在这张表上咱们加了一个惟一索引,保障 unique_mutex 唯一性。holder_id 代表竞争到锁的持有者 id。

加锁:

insert into distributed_lock(unique_mutex, holder_id) values ('unique_mutex', 'holder_id');

如果以后 sql 执行胜利代表加锁胜利,如果抛出惟一索引异样 (DuplicatedKeyException) 则代表加锁失败,以后锁曾经被其余竞争者获取。

解锁:

delete from methodLock where unique_mutex='unique_mutex' and holder_id='holder_id';

解锁很简略,间接删除此条记录即可。

剖析

可重入锁:

就以上的计划来说,咱们实现的分布式锁是不可重入的,即是是同一个竞争者,在获取锁后未开释锁之前再来加锁,一样会加锁失败,因而是不可重入的。解决不可重入问题也很简略:加锁时判断记录中是否存在 unique_mutex 的记录,如果存在且 holder_id 和以后竞争者 id 雷同,则加锁胜利。这样就能够解决不可重入问题。

阻塞问题:

通过惟一索引这种计划自身是不反对阻塞期待获取锁的,须要在代码实现通过 while 循环 insert 插入记录直至胜利返回,这种形式对数据性能方面耗费较大。

锁开释机会:

构想如果一个竞争者获取锁时候,过程挂了,此时 distributed_lock 表中的这条记录就会始终存在,其余竞争者无奈加锁。为了解决这个问题,每次加锁之前咱们先判断曾经存在的记录的创立工夫和以后零碎工夫之间的差是否曾经超过超时工夫,如果曾经超过则先删除这条记录,再插入新的记录。另外在解锁时,必须是锁的持有者来解锁,其余竞争者无奈解锁。这点能够通过 holder_id 字段来断定。

超时工夫:

数据库表中减少一个超时工夫字段,通过定时工作定时清理过期的表记录。

数据库单点问题:

单个数据库容易产生单点问题:如果数据库挂了,咱们的锁服务就挂了。对于这个问题,能够思考实现数据库的高可用计划,例如 MySQL 的 MHA 高可用解决方案。

论断:

实现较为简单,通过数据库的惟一索引形式去实现分布式锁原生并不具备可重入、反对阻塞和锁超时工夫个性。能够通过减少字段、定时工作等形式反对相干个性,随之带来对数据性能效率影响也较大。

排它锁

利用 for update 加显式的行锁,解锁的时候只有开释 commit 这个事务,就能达到开释锁的目标。创立锁表:

lock(),trylock(long timeout),trylock() 这几个办法能够用上面的伪代码实现。lock()lock 个别是阻塞式的获取锁,意思就是不获取到锁誓不罢休,那么咱们能够写一个死循环来执行其操作:

mysqlLock.lcok 外部是一个 sql,为了达到可重入锁的成果,咱们应该先进行查问,如果有值,须要比拟 node_info 是否统一。这里的 node_info 能够用机器 IP 和线程名字来示意,如果统一就加可重入锁 count 的值,如果不统一就返回 false。如果没有值就直接插入一条数据。伪代码如下:


须要留神的是这一段代码须要加事务,必须要保障这一系列操作的原子性。tryLock() 和 tryLock(long timeout)tryLock() 是非阻塞获取锁,如果获取不到就会马上返回,代码如下:

tryLock(long timeout) 实现如下:

mysqlLock.lock 和下面一样,然而要留神的是 select … for update 这个是阻塞的获取行锁,如果同一个资源并发量较大还是有可能会进化成阻塞的获取锁。unlock()unlock 的话如果这里的 count 为 1 那么能够删除,如果大于 1 那么须要减去 1。

剖析:

排它锁跟惟一索引计划一样原生不反对可重入锁、锁超时和非阻塞个性,两者解决方案根本是统一,都是通过减少数据库字段,定时工作等形式来反对相干个性。

总结:

了解起来简略,不须要保护额定的第三方中间件(比方 Redis,ZK),然而实现起来较为繁琐,须要本人思考锁超时,加事务等等。性能局限于数据库,个别比照缓存来说性能较低。对于高并发的场景并不是很适宜。以下为分布式锁实现计划的个性比照数据:

比照点 数据
了解难易水平 数据库 > Redis > Zookeeper
实现复杂度 Zookeeper >= Redis > 数据库
性能 Redis > Zookeeper > 数据库
可靠性 Zookeeper > Redis > 数据库

实现难度

对于间接操纵底层 API 来说,实现难度都是差不多的,都须要思考很多边界场景。但因为 Zk 的 ZNode 人造具备锁的属性,所以间接上手的话,很简略。

Redis 须要思考太多异样场景,比方锁超时、锁的高可用等,实现难度较大。

服务端性能

Zk 基于 Zab 协定,须要一半的节点 ACK,才算写入胜利,吞吐量较低。如果频繁加锁、开释锁,服务端集群压力会很大。

Redis 基于内存,只写 Master 就算胜利,吞吐量高,Redis 服务器压力小。

客户端性能

Zk 因为有告诉机制,获取锁的过程,增加一个监听器就能够了。防止了轮询,性能耗费较小。

Redis 并没有告诉机制,它只能应用相似 CAS 的轮询形式去争抢锁,较多空转,会对客户端造成压力。

可靠性

这个就很显著了。Zookeeper 就是为协调而生的,有严格的 Zab 协定控制数据的一致性,锁模型强壮。

Redis 谋求吞吐,可靠性上稍逊一筹。即便应用了 Redlock,也无奈保障 100% 的健壮性,但个别的利用不会遇到极其场景,所以也被罕用。

正文完
 0