首页 > 其他分享 >分布式锁

分布式锁

时间:2023-12-10 16:34:25浏览次数:22  
标签:Redis 获取 线程 分布式 节点 客户端

为什么需要分布式锁?

  在多线程环境中,如果多个线程同时访问共享资源(例如商品库存、外卖订单),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行。我们需要使用互斥操作对共享资源进行保护,即同一时刻只允许一个线程访问共享资源,其他线程需要等待当前线程释放后才能访问。这样可以避免数据竞争和脏数据问题,保证程序的正确性和稳定性。

如何才能实现共享资源的互斥访问呢?

  悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。对于单机多线程来说,在 Java 中,我们通常使用 ReetrantLock 类、synchronized 关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。

  分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁 就诞生了。

分布式锁应该具备哪些条件?

分布式锁最核心的就是,把锁放在第三方,这样所有的线程都可以去拿。

一个最基本的分布式锁需要满足:

  • 互斥:任意一个时刻,锁只能被一个线程持有。
  • 高可用:锁服务是高可用的,当一个锁服务出现问题,能够自动切换到另外一个锁服务。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。这一般是通过超时机制实现的。
  • 可重入:一个节点获取了锁之后,还可以再次获取锁。

除了上面这三个基本条件之外,一个好的分布式锁还需要满足下面这些条件:

  • 高性能:获取和释放锁的操作应该快速完成,并且不应该对整个系统的性能造成过大影响。
  • 非阻塞:如果获取不到锁,不能无限期等待,避免对系统正常运行造成影响。

分布式锁的常见实现方式有哪些?

常见分布式锁实现方案如下:

  • 基于关系型数据库比如 MySQL 实现分布式锁。
  • 基于分布式协调服务 ZooKeeper 实现分布式锁。
  • 基于分布式键值存储系统比如 Redis 、Etcd 实现分布式锁。

关系型数据库的方式一般是通过唯一索引或者排他锁实现。不过,一般不会使用这种方式,问题太多比如性能太差、不具备锁失效机制。

基于 ZooKeeper 或者 Redis 实现分布式锁这两种实现方式要用的更多一些。通常情况下,我们一般会选择基于 Redis(有性能要求)或者 ZooKeeper(有可靠性要求)实现分布式锁,Redis 用的要更多一点。

 

基于Redis实现分布式锁:
  不论是本地锁还是分布式锁,核心都在于“互斥”。
  在 Redis中,SETNX命令可以帮助我们实现互斥。SETNX即SET if Not exists (对应Java中的setIfAbsent方法),如果key不存在的话,才会设置key的值。如果key已经存在,SETNX 啥也不做。
  释放锁的话,直接通过DEL命令删除对应的 key 即可。
    为了防止误删其他锁,这里建议使用Lua脚本通过key对应的value(唯一值)来判断。
    选用Lua脚本是为了保证解锁操作的原子性。因为 Redis 在执行Lua脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。
> SETNX lockKey uniqueValue
(integer) 1
> SETNX lockKey uniqueValue
(integer) 0
> DEL lockKey
(integer) 1
// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1]
then  
    return redis.call("del",KEYS[1])
else  
    return 0end 
为什么要给锁设置一个过期时间?
  为了避免锁无法被释放,可以给这个 key(也就是锁) 设置一个过期时间。
127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
  ·  lockKey :加锁的锁名;
·  uniqueValue :能够唯一标示锁的随机字符串;
·  NX :只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功;
·  EX :过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。
  一定要保证设置指定key的值和过期时间是一个原子操作!!!不然的话,依然可能会出现锁无法被释放的问题。
  这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。
如何实现锁的优雅续期?
  如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!Redisson!
 Redisson是一个开源的Java语言Redis客户端,提供了很多开箱即用的功能,不仅包括多种分布式锁的实现,并且支持Redis单机、Redis Sentinel、Redis Cluster等多种部署架构。
  Redisson中的分布式锁自带自动续期机制,使用起来非常简单,其提供了一个专门用来监控和续期锁的Watch Dog(看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。
 
  默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。
  Watch Dog 通过调用 renewExpirationAsync() 方法实现锁的异步续期.
  renewExpirationAsync 方法其实是调用 Lua 脚本实现的续期,这样做主要是为了保证续期操作的原子性。
  这里以Redisson的分布式可重入锁 RLock为例来说明如何使用 Redisson 实现分布式锁:
//1.获取指定的分布式锁对象 
RLock lock = redisson.getLock("lock"); 
// 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制 
lock.lock(); 
// 3.执行业务 ... 
// 4.释放锁 
lock.unlock();
    只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。
// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制
lock.lock(10, TimeUnit.SECONDS);
 
如何实现可重入锁:
  可重入锁:指的是在一个线程中可以多次获取同一把锁。
  Java 中的 synchronized 和 ReentrantLock 都属于可重入锁。
  不可重入的分布式锁基本可以满足绝大部分业务场景了,一些特殊的场景可能会需要使用可重入的分布式锁。
  可重入分布式锁的实现核心思路:线程在获取锁的时候判断是否为自己的锁如果是的话,就不用再重新获取了。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于0时,则锁被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。
  推荐使用我们上面提到的 Redisson ,其内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。
 
Redis如何解决集群情况下分布式锁的可靠性:
  为了避免单点故障,生产环境下的 Redis 服务通常是集群化部署的。
  由于Redis集群数据同步到各个节点时是异步的,如果在Redis主节点获取到锁后,在没有同步到其他节点时,Redis主节点宕机了,此时新的Redis主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。导致新的进程有可能拿到锁,但之前的进程以为自己还有锁,那么就出现两个进程拿到了同一个锁的问题。
Redis之父antirez设计了Redlock算法来解决:
  Redlock算法思想让客户端向Redis集群中的多个独立的Redis实例依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
即使部分Redis节点出现问题,只要保证Redis集群中有半数以上的Redis节点可用,分布式锁服务就是正常的。
  Redlock是直接操作Redis节点的,并不是通过Redis集群操作的,这样才可以避免Redis 集群主从切换导致的锁丢失问题。
  假设目前有 N 个独立的 Redis 实例, 客户端先按顺序依次向 N 个 Redis 实例执行加锁操作。这里的加锁操作和在单实例上执行的加锁操作一样,但是需要注意的是,Redlock 算法设置了加锁的超时时间,为了避免因为某个 Redis 实例发生故障而一直等待的情况。当客户端完成了和所有 Redis 实例的加锁操作之后,如果有超过半数的 Redis 实例成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功。
  Redis主从集群切换数据丢失问题:异步复制同步丢失(如果此时master还没来得及同步给slave节点时发生宕机,那么master内存中的数据会丢失)、集群产生脑裂数据丢失(在发现问题之后,旧的master降为slave同步新的master数据,那么之前的数据被刷新掉,大量数据丢失)
    基于Zookeeper实现分布式锁:
  ZooKeeper是一个分布式的,开放源码的分布式应用程序协调服务,是Google的Chubby一个开源的实现,是Hadoop和Hbase的重要组件。它是一个为分布式应用提供一致性服务的软件,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。
  ZooKeeper分布式锁是基于临时顺序节点和Watcher(事件监听器)实现的。
  获取锁:
  ·首先我们要有一个持久节点/locks,客户端获取锁就是在locks下创建临时顺序节点。
  · 假设客户端1创建了/locks/lock1节点,创建成功之后,会判断 lock1是否是 /locks 下最小的子节点。
  ·如果 lock1是最小的子节点,则获取锁成功。否则,获取锁失败。
  ·如果获取锁失败,则说明有其他客户端已经成功获取锁。客户端1并不会不停地循环去尝试加锁,而是在前一个节点比如/locks/lock0上注册一个事件监听器。这个监听器的作用是当前一个节点释放锁之后通知客户端 1(避免无效自旋),这样客户端1就加锁成功了。
  释放锁:
  ·成功获取锁的客户端在执行完业务流程之后,会将对应的子节点删除。
  ·成功获取锁的客户端在出现故障之后,对应的子节点由于是临时顺序节点,也会被自动删除,避免了锁无法被释放。
  ·我们前面说的事件监听器其实监听的就是这个子节点删除事件,子节点删除就意味着锁被释放。

  实际项目中,推荐使用Curator来实现 ZooKeeper分布式锁。Curator是Netflix公司开源的一套ZooKeeper Java客户端框架,相比于ZooKeeper自带的客户端zookeeper来说,Curator的封装更加完善,各种API都可以比较方便地使用。
Curator主要实现了下面四种锁:
  InterProcessMutex:分布式可重入排它锁
  InterProcessSemaphoreMutex:分布式不可重入排它锁
  InterProcessReadWriteLock:分布式读写锁
  InterProcessMultiLock:将多个锁作为单个实体管理的容器,获取锁的时候获取所有锁,释放锁也会释放所有锁资源(忽略释放失败的锁)。
CuratorFramework client = ZKUtils.getClient();
client.start();
// 分布式可重入排它锁
InterProcessLock lock1 = new InterProcessMutex(client, lockPath1);
// 分布式不可重入排它锁
InterProcessLock lock2 = new InterProcessSemaphoreMutex(client, lockPath2);
// 将多个锁作为一个整体
InterProcessMultiLock lock = new InterProcessMultiLock(Arrays.asList(lock1, lock2));
if (!lock.acquire(10, TimeUnit.SECONDS)) {
   throw new IllegalStateException("不能获取多锁");
}
System.out.println("是否有第一个锁: " + lock1.isAcquiredInThisProcess());
System.out.println("是否有第二个锁: " + lock2.isAcquiredInThisProcess());
为什么要用临时顺序节点:
  每个数据节点在 ZooKeeper 中被称为 znode,它是ZooKeeper中数据的最小单元。
我们通常是将 znode 分为 4 大类:
  持久(PERSISTENT)节点:一旦创建就一直存在,即使ZooKeeper集群宕机,直到将其删除。
  临时(EPHEMERAL)节点:临时节点的生命周期是与客户端会话(session)绑定的,会话消失则节点消失。并且临时节点只能做叶子节点,不能创建子节点。
  持久顺序(PERSISTENT_SEQUENTIAL)节点 :除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如/node1/app0000000001、/node1/app0000000002。
  临时顺序(EPHEMERAL_SEQUENTIAL)节点 :除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。
 
  临时节点相比持久节点,最主要的是对会话失效的情况处理不一样,临时节点会话消失则对应的节点消失。这样的话,如果客户端发生异常导致没来得及释放锁也没关系,会话失效节点自动被删除,不会发生死锁的问题。
  使用Redis实现分布式锁的时候,我们是通过过期时间来避免锁无法被释放导致死锁问题的,而ZooKeeper直接利用临时节点的特性即可。
  假设不适用顺序节点的话,所有尝试获取锁的客户端都会对持有锁的子节点加监听器。当该锁被释放之后,势必会造成所有尝试获取锁的客户端来争夺锁(惊群现象),这样对性能不友好。使用顺序节点之后,只需要监听前一个节点就好了,对性能更友好。
 
为什么要设置对前一个节点的监听?
  Watcher(事件监听器),是ZooKeeper中的一个很重要的特性。ZooKeeper允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper服务端会将事件通知到感兴趣的客户端上去,该机制是ZooKeeper实现分布式协调服务的重要特性。
  同一时间段内,可能会有很多客户端同时获取锁,但只有一个可以获取成功。如果获取锁失败,则说明有其他的客户端已经成功获取锁。获取锁失败的客户端并不会不停地循环去尝试加锁,而是在前一个节点注册一个事件监听器。
  事件监听器的作用:当前一个节点对应的客户端释放锁之后(也就是前一个节点被删除之后,监听的是删除事件),通知获取锁失败的客户端(唤醒等待的线程,Java中的 wait/notifyAll),让它尝试去获取锁,然后就成功获取锁了。
 
如何实现可重入锁?
  这里以Curator的 InterProcessMutex 对可重入锁的实现来介绍。
  当我们调用InterProcessMutex#acquire方法获取锁的时候,会调用InterProcessMutex#internalLock方法。
private boolean internalLock(long time, TimeUnit unit) throws Exception {
  // 获取当前请求锁的线程
  Thread currentThread = Thread.currentThread();
  // 拿对应的 lockData
  LockData lockData = threadData.get(currentThread);
  // 第一次获取锁的话,lockData 为 null
  if (lockData != null) {
    // 当前线程获取过一次锁之后
    // 因为当前线程的锁存在, lockCount 自增后返回,实现锁重入.
    lockData.lockCount.incrementAndGet();
    return true;
  }
  // 尝试获取锁
  String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
  if (lockPath != null) {
    LockData newLockData = new LockData(currentThread, lockPath);
     // 获取锁成功之后,将当前线程和对应的 lockData 放到 threadData 中
    threadData.put(currentThread, newLockData);
    return true;
  }
  return false;
}
  lockData包含锁的信息和加锁的次数,是实现可重入锁的关键。第一次获取锁的时候, lockData为null。获取锁成功之后,会将当前线程和对应的lockData放到threadData中。
如果已经获取过一次锁,后面再来获取锁的话,直接就会在if (lockData != null) 这里被拦下了,然后就会执行lockData.lockCount.incrementAndGet();将加锁次数加 1。
  整个可重入锁的实现逻辑非常简单,直接在客户端判断当前线程有没有获取锁,有的话直接将加锁次数加1就可以了  

如何选择:

  • 如果对性能要求比较高的话,建议使用 Redis 实现分布式锁(优先选择 Redisson 提供的现成的分布式锁,而不是自己实现)。
  • 如果对可靠性要求比较高的话,建议使用 ZooKeeper 实现分布式锁(推荐基于 Curator 框架实现)。不过,现在很多项目都不会用到 ZooKeeper,如果单纯是因为分布式锁而引入 ZooKeeper 的话,那是不太可取的,不建议这样做,为了一个小小的功能增加了系统的复杂度。
 

标签:Redis,获取,线程,分布式,节点,客户端
From: https://www.cnblogs.com/cjhtxdy/p/17892676.html

相关文章

  • 架构核心技术之分布式消息队列
    Java全能学习+面试指南:https://javaxiaobear.cn今天我们来学习分布式消息队列,分布式消息队列的知识结构如下图。主要介绍以下内容:同步架构和异步架构的区别。异步架构的主要组成部分:消息生产者、消息消费者、分布式消息队列。异步架构的两种主要模型:点对点模型和发布订阅模型......
  • 基于Docker容器搭建hadoop完全分布式集群环境
    简介物理机:windows10宿主机:Centos7虚拟机,需要安装Docker服务hadoop集群节点:3个centos7的容器,hadoop1、hadoop2、hadoop3组件:容器镜像:Centos7DockerCE24.0.7JDK1.8.0_181Hadoop3.1.31.新建虚拟机安装CentOS72.安装Docker2.1安装docker服务yum-yinstalldocke......
  • 分布式ID
    1.分布式ID什么是ID?日常开发中,我们需要对系统中的各种数据使用ID唯一表示,比如用户ID对应且仅对应一个人,商品ID对应且仅对应一件商品,订单ID对应且仅对应一个订单。简单来说,ID就是数据的唯一标识。什么是分布式ID?分布式ID是分布式系统下的ID。举一......
  • 分布式学习记录,第三天
       在分布式学习的探索之旅中,我们继续深入学习并实践了分布式学习的核心概念和技巧。第三天,我们主要关注于分布式学习中的同步和异步策略,以及如何优化通信开销以进一步提高学习效率。    首先,我们讨论了分布式学习中的同步策略。同步策略是指在所有计算节点上同时进......
  • 分布式架构和微服务架构的概念理解
    分布式架构相当于物理上的拆分,微服务架构相当于逻辑上的拆分。比如一个互联网平台有mes系统,wms系统,把mes系统单独部署在一个服务器上,把wms系统单独部署在另一个服务器上,这就相当于是一个物理拆分的分布式架构。如果mes的生产模块会有大量的请求此时只能针对整个mes系统进行集群部署......
  • 解密 ArcGraph 分布式一致性:Raft 协议与分布式事务实现丨技术专栏
    导读:本文提出了一种将事务日志和Raft日志融合在一起的机制,从而实现了分布式事务和数据一致性的场景。01背景介绍分布式系统是伴随着互联网的高速发展而出现的。其出现为了应对单机系统无法解决的高并发、高可用性、容错性等问题。分布式系统将传统的系统扩容模式,从scaleup......
  • 鸿蒙原生应用开发——分布式数据对象
     01、什么是分布式数据对象在可信组网环境下,多个相互组网认证的设备将各自创建的对象加入同一个sessionId,使得加入的多个数据对象之间可以同步数据,也就是说,当某一数据对象属性发生变更时,其他数据对象会检测到这一变更,同时将自身属性更新。此时,该sessionId下的所有数据对象属......
  • 分布式系统-负载均衡
    分布式系统中,业务系统都是集群部署,而且一般会做微服务化。那么,从一个系统rpc请求到另一个系统,应该路由到哪一台节点呢?这,就涉及到负载均衡。负载均衡的核心,便是负载均衡算法,算法有很多,主要介绍其中六种,并说一说各自算法的优缺点。1.加权随机加权随机,顾名思义,就是路由到哪一台节......
  • 分布式资源调度框架YARN
    1、YARN=YetAnotherResourceNegotiator2、主从架构,一主多从3、主=ResourceManager,从=NodeManager4、ResourceManager由ApplicationManager和ResourceScheduler组成5、NodeManager的核心组件包括ApplicationMaster和Container6、工作流程分为五个阶段:(1)作业提交阶段(2)作业初始化......
  • redis分布式锁实现原理
    在.netcore中,可以使用StackExchange.Redis实现redis分布式锁,///<summary>///分布式锁///</summary>///<paramname="Redis">RedisDB</param>///<paramname="Key">锁标识</param>///<paramname="Seconds">过......