分布式- 分布式锁

分布式锁

什么场景需要使用锁?

使用锁的场景有两个特征:

  • 存在共享资源竞争
  • 存在共享资源互斥

分布式锁要解决的问题?

保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行

分布式锁需要具备的特征?

  • 互斥性,在任意时刻,只有一个客户端能持有锁,其他尝试获取锁的客户端都将获取锁失败而直接返回或者阻塞等待
  • 健壮性:一个客户端持有锁期间崩溃了而没有主动释放锁,也需要保证后序客户端能够加锁
  • 唯一性:加锁和解锁都必须是同一个客户端
  • 高可用:加锁和解锁操作必须高效

一,基于MySQL实现

基于MySQL的分布式锁的实现是使用MySQL的唯一索引去实现,主要流程:

  • 获取锁:向数据库锁记录表插入一条记录,插入成功则表示成功获取到锁
  • 执行业务逻辑
  • 释放锁:删除锁记录表中的记录

唯一索引可以保证这一条数据只被插入一次,即只有一个客户端能正确插入,其他都将返回插入失败

eg:

CREATE TABLE `method_lock` (
    `id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '自增id',
    `method_name` VARCHAR(64) NOT NULL COMMENT '方法名',
    `method_desc` VARCHAR(1024) NOT NULL COMMENT '方法描述',
    `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (`id`),
    UNIQUE INDEX `uniq_method_name` (`method_name`)
)
COMMENT='分布式锁'
COLLATE='utf8_general_ci'
ENGINE=InnoDB;

唯一索引是方法名,即同一个方法只能被插入一次,不能重复插入,即只有一个客户端能获取到锁,当该客户端业务逻辑执行完毕后,需要将该记录删除,即释放锁

  • 加锁:
insert into method_lock(method_name, method_desc) values("methodName", "desc");
  • 解锁:
delete from method_lock where method_name = "methodName";

基于MySQL实现分布式锁有哪些缺陷?

  • 锁没有失效时间,如果解锁失败,那么这条记录会一直存在于数据库表中,导致其他节点无法获取到锁。
  • 非阻塞锁,插入失败返回0(insert ignore)或者直接抛异常且无法重试
  • 可用性,数据库如果挂了,那么整个需要加锁的业务逻辑就会不可用
  • 不可重入,因为是基于唯一索引实现的,获取到锁的线程无法再获取锁

解决方法:

  • 针对锁没有失效时间:
    • 加监控节点,定时检查数据库中有无超过超时时间还没删除的锁记录,有则删除
  • 针对可用性:
    • 数据库主从集群部署

二,基于redis实现

2.1 基于setNxsetEx实现

redis命令setnx

  • 如果key不存在则set,成功返回1,失败返回0
  • 如果key存在则直接返回0

加解锁:

if (setnx(key, 1) == 1){
    
    
    expire(key, 30)
    try {
    
    
        //TODO 业务逻辑
    } finally {
    
    
        del(key)
    }
}

存在问题:

  • 单条redis指令是原子的,但是组合并不具备原子性setnx + expire组合是非原子性的,可能会出现setnx成功但是expire失败的场景,没有成功设置超时时间,还是会出现其他节点无法获取到锁的情况

    • 解决方法:LUA脚本,LUA脚本在redis中的执行是原子的

      if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
      then return 0;
      end;
      redis.call('expire', KEYS[1], tonumber(ARGV[2]));
      return 1;
      
      // 使用实例
      EVAL "if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('expire',KEYS[1],tonumber(ARGV[2])); return 1;" 1 key value 100
      

redis命令setex,解决了setnx + expire组合非原子的问题

  • setex是一个原子命令,是setnx + expire key timeout的组合
  • setex key time value
  • 设置超时时间,防止了因为获取锁的节点处理完业务逻辑后没有释放锁 del key或者获取锁的节点崩了导致锁无法释放,导致后序节点无法正常加锁完成相关业务逻辑

加解锁:

if (setex(key,30,1) == 1){
    
    
    try {
    
    
        //TODO 业务逻辑
    } finally {
    
    
        del(key)
    }
}

第二个问题:锁误消除,即加锁和解锁的线程不是同一个

场景:线程A成功获取到锁,且锁失效时间是30s,但是线程A因为网络阻塞等原因,执行了超过30s,那么当到了30s时,线程A还没执行结束,锁过期自动删除,线程B获取到锁,执行线程B的业务逻辑,此时线程A也执行完成,执行线程A的finally块的del(key),但是此时解锁的线程B的锁

img

解决方法:

  • setex key timeout value,其中value设置当前线程的一些唯一标识 + UUID串,解锁时先判断是否是加锁线程

第三个问题, 超时解锁导致并发

场景:如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。

img

解决方法:

  • 粗暴:将过期时间设置的足够长,确保业务逻辑能在锁释放之前执行完
  • 为获取锁的线程增加守护线程,为将要过期但还没释放的锁增加有效时间

锁重入实现

在redis实现的分布式锁中,Redisson实现的分布锁通过 redis 的hashmap结构实现了可重入锁

Redisson加锁解锁源码分析:

首先Redisson底层也是基于lua脚本实现原子加锁解锁,获取不到锁的客户端会监听锁topic

  • 加锁:

    在这里插入图片描述
  • 解锁:

    在这里插入图片描述

redis的可用性问题

redis的副本异步复制导致无法担保锁的互斥性,可能导致两个客户端持有锁

为了保证 Redis 的可用性,一般采用主从方式部署。主从数据同步有异步和同步两种方式,Redis 将指令记录在本地内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一致的状态,一边向主节点反馈同步情况。

在包含主从模式的集群部署方式中,当主节点挂掉时,从节点会取而代之,但客户端无明显感知。当客户端 A 成功加锁,指令还未同步,此时主节点挂掉,从节点提升为主节点,新的主节点没有锁的数据,当客户端 B 加锁时就会成功。此时就会导致有两个客户端持有锁

2.2 redis集群环境的分布式锁

redlock算法,redis集群中只要大部分节点存活客户端就可以进行加锁和解锁逻辑

使用多个redis实例来完成分布式锁,这是为了保证在发生单点故障时仍然可用

redLock步骤:

  • 获取当前 Unix 时间,以毫秒为单位。
  • 依次尝试从 5 个实例,使用相同的 key 和具有唯一性的 value(例如 UUID)获取锁。当向 Redis 请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个 Redis 实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
  • 如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个 Redis 实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

失败重试机制:

  • 如果一个client在申请锁时失败了,那么该client会等待一个随机时间再去该实例上重新申请锁,避免多个 client 同时申请锁的情况,超过获取锁超时时间则退出重试

解锁:

  • 直接对所有redis实例执行del key

如果我们的节点没有持久化机制,client 从 5 个 master 中的 3 个处获得了锁,然后其中一个重启了,这是注意 整个环境中又出现了 3 个 master 可供另一个 client 申请同一把锁! 违反了互斥性。如果我们开启了 AOF 持久化那么情况会稍微好转一些,因为 Redis 的过期机制是语义层面实现的,所以在 server 挂了的时候时间依旧在流逝,重启之后锁状态不会受到污染。但是考虑断电之后呢,AOF部分命令没来得及刷回磁盘直接丢失了,除非我们配置刷回策略为 fsnyc = always,但这会损伤性能。解决这个问题的方法是,当一个节点重启之后,我们规定在 max TTL 期间它是不可用的,这样它就不会干扰原本已经申请到的锁,等到它 crash 前的那部分锁都过期了,环境不存在历史锁了,那么再把这个节点加进来正常工作。

集群环境可能出现的问题:

  • 主从架构数据同步延迟或者在故障转移时的主备切换数据同步延迟,可能会导致两个客户端同时持有锁或者解锁延迟的情况
  • 网络分区,可能出现两个master节点,这时候也会出现两个客户端同时持有锁的情况

三,基于zookeeper实现

Zookeeper 提供了一种树形结构级的命名空间,/app1/p_1 节点的父节点为 /app1。

在这里插入图片描述

节点类型

  • 持久节点,客户端创建后不会因为客户端会话的结束和删除
  • 临时节点,客户端会话结束或者超时后删除
  • 顺序节点,会在节点名的后面加一个数字后缀,并且是有序的,例如生成的有序节点为 /lock/node-0000000000,它的下一个有序节点则为 /lock/node-0000000001,以此类推。

监听器watch机制:

为节点注册一个监听器 watch node ,当节点node状态发生改变,会给客户端发送消息

实现:

  • 创建锁目录 /lock
  • 当客户端需要去获取锁时,在 lock下创建临时顺序子节点
  • 客户端获取/lock下的子节点目录,如果自己创建的子节点是当前子节点目录下序号最小的节点,则认为成功获取到锁,否则则监听自己的前一个子节点,相当于排队,获得子节点变更后,继续比较自己创建的节点序号是否是最小
  • 执行业务代码
  • 释放锁:删除对应子节点

锁超时

如果一个已经获得锁的会话超时了,因为创建的是临时节点,所以该会话对应的临时节点会被删除,其它会话就可以获得锁了。可以看到,Zookeeper 分布式锁不会出现数据库的唯一索引实现的分布式锁释放锁失败问题。

羊群效应:

还有一种实现是以成功创建节点代表成功获取锁,没有获取到锁的客户端监听该节点,那么在锁释放节点被删除时,所有客户端都会收到通知,导致等待的所有节点一起尝试去向zookeeper服务端创建节点,可能会导致网络的一时阻塞

一个节点未获得锁,只需要监听自己的前一个子节点,这是因为如果监听所有的子节点,那么任意一个子节点状态改变,其它所有子节点都会收到通知(羊群效应),而我们只希望它的后一个子节点收到通知。

四,优缺点总结

  • 基于MySQL实现:

    • 性能较低:局限于数据库,性能有限,不适合高并发场景,需要自己去考虑锁超时,事务等情况
    • 优点就是不需要维护中间件,实现简单
  • 基于ZK实现:

    • ZK性能其实和MySQL相差不大
    • watch机制可以让我们不需要关注锁超时时间,ZK实现的是公平的分布式锁
  • 基于Redis实现:

    • 高可用性:需要维护Redis集群,如果要实现RedLock那么需要维护更多的集群。

猜你喜欢

转载自blog.csdn.net/weixin_41922289/article/details/108019042