redis-13 redis 和 zookeeper 实现分布式锁

1、为什么需要分布式锁

  随着互联网世界的发展,单体应用已经越来越无法满足复杂互联网的高并发需求,转而慢慢朝着分布式方向发展。所以同样,我们需要引入 分布式锁 来解决分布式应用之间 访问共享资源 的并发问题。

  一般情况下,我们使用分布式锁主要有两个场景:

  1. 避免不同节点重复相同的工作:比如用户执行了某个操作有可能不同节点会发送多封邮件;

  2. 避免破坏数据的正确性:如果两个节点在同一条数据上同时进行操作,可能会造成数据错误或不一致的情况出现;

2、java 常见锁方式

  锁的本质:同一时间只允许一个用户操作。所以理论上,能够满足这个需求的工具我们都能够使用的有:

  1. 基于 MySQL 中的锁:MySQL 本身有自带的 悲观锁 for update 关键字,也可以自己实现 悲观/乐观锁 来达到目的;

    1. select ...... for update 这个就是阻塞的获取行锁,如果同一个资源并发量较大还有可能会退化成阻塞的获取锁。
    2. 乐观锁:在实际项目中也会经常使用乐观锁,因为我们加行锁的性能消耗比较大,通常我们会对于一些竞争不是那么激烈但是又需要保证我们并发的顺序执行时使用乐观锁进行处理,即我们可以对我们表增加一个 version 版本号字段,查询时得到当前 version,update 或 delete 时判断当前数据库和查询出来的 version 是否一致,若一致则执行,不一致就采用其他策略,这样略像我们 JAVA 并发中的 CAS(Compare And Swap)。
  2. 基于 Zookeeper 有序节点:Zookeeper 允许临时创建有序的子节点,这样客户端获取节点列表时,就能够通过当前子节点列表中的序号判断是否能够获得锁;

  3. 基于 Redis 的单线程:由于 Redis 是单线程,所以命令会以串行的方式执行,并且本身提供了像 SETNX(set if not exists) 这样的指令,本身具有互斥性;

  每个方案都有各自的优缺点,例如 MySQL 虽然直观理解容易,但是实现起来却需要额外考虑 锁超时、加事务 等,并且性能局限于数据库,一般对比缓存来说性能较低。对于高并发的场景并不是很适合。诸如此类我们在此不作讨论,重点关注 Redis 和 zookeeper。

3、redis 分布式锁的实现

  分布式锁类似于 “占坑”,而 SETNX(SET if Not eXists) 指令就是这样的一个操作,只允许被一个客户端占有,如果不存在则更新,其可以很好的用来实现我们的分布式锁。对于某个资源加锁我们只需要

 setNx key value

  这里有个问题,加锁了之后如果机器宕机那么这个锁就不会得到释放所以会加入过期时间,加入过期时间需要 和 setNx同一个原子操作,在Redis2.8之前我们需要使用Lua脚本达到我们的目的,但是redis2.8之后redis 支持 nx 和 ex 操作是同一原子操作。

 set key value ex 5 nx

3.1、Redission 实现

  作为 java 开发人员,很多人都了解 Jedis 是 Redis 的 java 客户端实现。其实 Redission 也是 Redis 的客户端。Jedis 简单使用阻塞的 I/O 和 redis 交互,Redission 通过 Netty 支持非阻塞 I/O。

  Redission 封装了锁的实现,其继承了java.util.concurrent.locks.Lock的接口,让我们像操作我们的本地Lock一样去操作Redission的Lock,下面介绍一下其如何实现分布式锁。

    RedissionClient redission = Redission.create();
    RLock rLock = redission.getLock("key");
    // 直接加锁
    rLock.lock();
    // 尝试加锁 5s,锁过期时间 10s
    rLock.tryLock(5, 10, TimeUnit.SECONDS);
    // 至此非阻塞异步操作
    RFuture<Boolean> rFuture = rLock.tryLockAsync(5, 10, TimeUnit.SECONDS);
    rFuture.whenCompleteAsync((result, throwable) -> {
        System.out.println("current lock detail is :" + result + throwable);
    });
    // 释放锁
    rLock.unlock();

  Redission 对应的 tryLock 具体执行过程如下:

  1. 尝试加锁:首先会尝试进行加锁,由于保证操作是原子性,那么就只能使用lua脚本。脚本中并没有使用我们的 sexNx 来进行操作,而是使用的 hash 结构,我们的每一个需要锁定的资源都可以看做是一个 HashMap,锁定资源的节点信息是Key,锁定次数是value。通过这种方式可以很好的实现可重入的效果,只需要对 value 进行 加1 操作,就能进行可重入锁。当然这里也可以用之前我们说的本地计数进行优化。

  2. 如果尝试加锁失败,判断是否超时,如果超时则返回false。

  3. 如果加锁失败之后,没有超时,那么需要在名字为 redisson_lock__channel + lockName 的 channel 上进行订阅,用于订阅解锁消息,然后一直阻塞直到超时,或者有解锁消息。

  4. 重试步骤 1,2,3,直到最后获取到锁,或者某一步获取锁超时。

   同理,对于我们的 unlock 方法比较简单也是通过lua脚本进行解锁,如果是可重入锁,只是减1。如果是非加锁线程解锁,那么解锁失败。

3.2、RedLock 引入

  我们想象一个这样的场景当 机器A 申请到一把锁之后,如果 Redis主 宕机了,这个时候 从机 并没有同步到这一把锁,那么 机器B 再次申请的时候就会再次申请到这把锁,为了解决这个问题Redis作者提出了 RedLock红锁 的算法,在 Redission 中也对 RedLock 进行了实现。

    // 三个 Redis 集群
    RLock lock1 = redissionInstance1.getLock("lock1");
    RLock lock2 = redissionInstance2.getLock("lock2");
    RLock lock3 = redissionInstance3.getLock("lock3");

    RedissionRedLock locks = new RedissionRedLock(lock1, lock2, lock3);
    // locks:lock1、lock2、lock3
    locks.lock();
    ......
    locks.unlock();

  通过上面的代码,我们需要实现多个Redis集群,然后进行 红锁 的 加锁,解锁。具体的步骤如下:

  1. 首先生成多个 Redis 集群的 Rlock,并将其构造成 RedLock。

  2. 依次循环对三个集群进行加锁,加锁的过程 和 3.1 里面一致。

  3. 如果循环加锁的过程中加锁失败,那么需要判断加锁失败的次数是否超出了最大值,这里的最大值是根据集群的个数,比如三个那么只允许失败一个,五个的话只允许失败两个,要保证多数成功。

  4. 加锁的过程中需要判断是否加锁超时,有可能我们设置加锁只能用3ms,第一个集群加锁已经消耗了3ms了。那么也算加锁失败。

  5. 3,4步里面加锁失败的话,那么就会进行解锁操作,解锁会对所有的集群在请求一次解锁。

  可以看见 RedLock 基本原理是利用多个Redis集群,用多数的集群加锁成功,减少Redis某个集群出故障,造成分布式锁出现问题的概率。

3.3、Redis 分布式锁总结

  • 优点:对于Redis实现简单,性能对比ZK和Mysql较好。如果不需要特别复杂的要求,那么自己就可以利用setNx进行实现,如果自己需要复杂的需求的话那么可以利用或者借鉴Redission。对于一些要求比较严格的场景来说的话可以使用RedLock。

  • 缺点:需要维护Redis集群,如果要实现RedLock那么需要维护更多的集群。

4、ZooKeeper 实现分布式锁

  在介绍 zookeeper 实现分布式锁的机制之前,先粗略介绍一下 zk 是什么东西: zk 是一种提供配置管理、分布式协同以及命名的中心化服务。它的模型是这样的:包含一系列的节点,叫做znode,就好像文件系统一样每个 znode 表示一个目录,然后 znode 有一些特性,我们可以把它们分为四类:

  • 持久化节点(zk断开节点还在)
  • 持久化顺序节点(如果是第一个创建的子节点,那么生成的子节点为 /lock/node-0000000000,下一个节点则为 /lock/node-0000000001,依次类推)
  • 临时节点(客户端断开后节点就删除了)
  • 临时顺序节点

  zookeeper分布式锁恰恰应用了临时顺序节点,下面我们就用图解的方式来看下是怎么实现的。

4.1、获取锁

   首先,在 Zookeeper 当中创建一个持久节点 ParentLock。当第一个客户端想要获得锁时,需要在 ParentLock 这个节点下面创建一个 临时顺序节点 Lock1。
  

  之后,Client1 查找 ParentLock 下面所有的临时顺序节点并排序,判断自己所创建的节点 Lock1 是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。

  

  这时候,如果再有一个客户端 Client2 前来获取锁,则在 ParentLock 下再创建一个临时顺序节点Lock2。

  

   Client2 查找 ParentLock 下面所有的临时顺序节点并排序,判断自己所创建的节点 Lock2 是不是顺序最靠前的一个,结果发现节点 Lock2 并不是最小的。

  于是,Client2 向排序仅比它靠前的节点 Lock1 注册 Watcher,用于监听 Lock1 节点是否存在。这意味着 Client2 抢锁失败,进入了等待状态。

  

  这时候,如果又有一个客户端 Client3 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。

  

  Client3 查找 ParentLock 下面所有的临时顺序节点并排序,判断自己所创建的节点 Lock3 是不是顺序最靠前的一个,结果同样发现节点 Lock3 并不是最小的。

  于是,Client3 向排序仅比它靠前的节点 Lock2 注册 Watcher,用于监听 Lock2 节点是否存在。这意味着 Client3 同样抢锁失败,进入了等待状态。

   

  这样一来,Client1 得到了锁,Client2 监听了 Lock1,Client3 监听了 Lock2。

  这恰恰形成了一个等待队列,很像是 Java 当中 ReentrantLock 所依赖的 AQS(AbstractQueuedSynchronizer)。

4.2、释放锁

  释放锁分为两种情况:

  4.2.1、任务完成,客户端显示释放

    当任务完成时,Client1 会显示调用删除节点 Lock1 的指令。

    

  4.2.2、任务执行过程中,客户端崩溃

    获得锁的 Client1 在任务执行过程中,如果 Duang 的一声崩溃,则会断开与 Zookeeper 服务端的链接。根据临时节点的特性,相关联的节点 Lock1 会随之自动删除。

    

  不管上面哪种释放锁的方式,由于 Client2 一直监听着 Lock1 的存在状态,当 Lock1 节点被删除,Client2 会立刻收到通知。这时候 Client2 会再次查询 ParentLock 下面的所有节点,确认自己创建的节点 Lock2 是不是目前最小的节点。如果是最小,则 Client2 顺理成章获得了锁。

  

  同理,如果 Client2 也因为 任务完成 或者 节点崩溃 而删除了节点 Lock2,那么 Client3 就会接到通知。

  

  最终,Client3 成功得到了锁。

  

4.3、Curator 实现

  在 Apache 的开源框架 Apache Curator 中,包含了对 Zookeeper 分布式锁的实现。 github.com/apache/curator 它的使用方式也很简单,如下所示:

  

4.4、ZK 分布式锁总结

  zookeeper 天生设计定位就是分布式协调,强一致性,锁很健壮。如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。

  缺点: 在高请求高并发下,系统疯狂的加锁释放锁,最后 zk 承受不住这么大的压力可能会存在宕机的风险。

在这里简单的提一下,zk 锁性能比 redis 低的原因:

  zk 中的角色分为 leader,flower,每次写请求只能请求 leader,leader 会把写请求广播到所有 flower,如果 flower 都成功才会提交给 leader,其实这里相当于一个 2PC 的过程。在加锁的时候是一个写请求,当写请求很多时,zk 会有很大的压力,最后导致服务器响应很慢。

  redis 锁实现简单,理解逻辑简单,性能好,可以支撑高并发的获取、释放锁操作。

  缺点:

  • Redis 容易单点故障,集群部署,并不是强一致性的,锁的不够健壮;

  • key 的过期时间设置多少不明确,只能根据实际情况调整;

  • 需要自己不断去尝试获取锁,比较消耗性能。

 最后不管 redis 还是 zookeeper,它们都应满足分布式锁的特性:

  • 具备可重入特性(已经获得锁的线程在执行的过程中不需要再次获得锁)

  • 异常或者超时自动删除,避免死锁

  • 互斥(在分布式环境下同一时刻只能被单个线程获取)

  • 分布式环境下高性能、高可用、容错机制

原文地址:https://www.cnblogs.com/liang1101/p/12965149.html