分布式锁实现方案

2年前 (2022) 程序员胖胖胖虎阿
200 0 0

分布式锁是锁的一种,通常用来跟 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%的健壮性,但一般的应用不会遇到极端场景,所以也被常用。

版权声明:程序员胖胖胖虎阿 发表于 2022年9月13日 下午1:16。
转载请注明:分布式锁实现方案 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...