关于java:实现分布式锁的各种姿势

10次阅读

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

各位 Javaer 都对锁应该都是不生疏的,无论工作还是面试的时候,都是很常见的。不过对于大部分的小型的我的项目,也就是单机利用,根本都是应用 Java 的 juc 即可应答,然而随着利用规模的扩充,在分布式系统中,依附着诸如 syncronized,lock 这些就无奈应答了。那么本文就是来给大家唠嗑唠嗑在分布式系统中常见的几种实现分布式锁的形式。

数据库形式实现分布式锁

首先从大家最最相熟的数据库来说,这里应用的是 MySQL 数据库,其余数据库可能有所区别但大抵的思维是差不多的。这里我就默认大家对事务的 ACID 以及隔离机制都是理解的,就不多说废话了。

在单机利用下咱们应用锁比方 sync,就是锁住某个资源保障同一时刻只能有一个线程去操作它,那么在分布式系统中,同样的,在同一时刻要保障只能被一台机器上的一个线程进行解决。

表记录形式

最简略的形式来实现这样的一个锁,咱们能够设计上面这个表

create table lock_table
(
    id int auto_increment comment '主键',
    value varchar(64) null comment '要锁住的资源',
    constraint lock_table_pk
        primary key (id)
);

create unique index lock_table_value_uindex
    on lock_table (value);

能够看到,这里的 value 字段是 unique 的,那么咱们须要获取锁的时候,就往这个表外面插入一条数据,插入胜利则能够认定为获取到了锁,如果此时有另外的事务也去插入同样的数据,则会插入失败。如果须要开释锁,则 delete 这条数据即可。

不言而喻,这样的设计存在几个问题:

  1. 不可重入,同一个事务在没有开释锁之前无奈再次取得该锁
  2. 没有生效工夫,一旦解锁失败则会始终保留着这把锁
  3. 非阻塞的,没有取得锁的现场一旦失败不会期待,要想再次取得必须从新登程获取锁的操作
  4. 强依赖数据库的可用性,数据库一旦挂掉会导致业务不可用

不过这些问题都是有方法解决的

  1. 加上一个 count 字段和一个记录申请 ID 的字段,同一个申请再次进来只是减少 count,而不是插入
  2. 加上生效工夫字段,额定通过定时工作进行数据革除
  3. 加上 while 循环等伎俩一直触发获取锁
  4. 数据库设置备用库,防止只有单个节点,保障高可用

上述这个办法尽管可行而且简略,但如果一直有很多事务竞争锁,那么就会一直的呈现获取锁失败而后异样的状况,它自身的性能显然是不怎么样的,其实失常状况下咱们都不会应用这种形式。

乐观锁形式

对于数据库中咱们对于锁的分类有一种分类形式就是乐观锁和乐观锁,所谓乐观锁,顾名思义,就是总是假如最好的状况,每次去拿数据的时候都认为他人不会批改,所以不会上锁,然而在更新的时候会判断一下在此期间他人有没有去更新这个数据,能够应用版本号机制和 CAS 算法实现。在这里,就能够在表上再加一个自带 version,每次更新的时候都带上这个版本号。比方上面这张表:

create table lock_table
(
    id int auto_increment comment '主键',
    value varchar(64) null comment '要锁住的资源',
  version int default 0 null comment '版本号'
    constraint lock_table_pk
        primary key (id)
);

个别状况下,如果没有版本号,咱们批改的语句该当是

update lock_table set value = #{newValue} where id = #{id};

然而,在并发状况下,当咱们 set 新的 value 时,可能老的 value 曾经被批改了。MySQL 默认的事务隔离机制是 RR,也就是可反复读,一个事务屡次读取到的数据都是一样的,那么其余事务对这条事务的批改并不会影响到他读到的数据 abc。这就导致了更新时候的值变动可能不再是 abc->newValue 而可能是: unknownValue->newValue,咱们也能够把这个景象称为失落批改,这也是并发事务可能带来的问题之一。

加上版本号后,再来更新 value 值的 sql 就会变成

update lock_table set value = #{newValue}, version = #{version} + 1 where id = #{id} and version = #{version};

如果遇到被其余事务批改的状况时,因为咱们拿到的是老的版本号,更新的时候必然找不到对应的数据,因而会更新失败。

乐观锁在检测数据抵触时不像表记录形式依赖数据库自身的锁机制,因而不会影响性能。但它须要对表新增额定字段,减少了数据库设计的冗余。而且当并发量高的时候,version 的值会频繁变动,也会导致大量申请失败,影响零碎的可用性。所以乐观锁个别用在读多写少且并发量不是很高的场景下。

乐观锁

对于乐观锁,意思也很好了解,就是总是假如最坏的状况,每次去拿数据的时候都认为他人会批改,所以每次在拿数据的时候都会上锁,这样他人想拿这个数据就会阻塞直到它拿到锁。要应用乐观锁须要敞开 MySQL 的默认主动提交模式 autocommit,也就是 set autocommit=0。乐观锁的实现有两种,一种是共享锁,它指的是对于多个不同的事务,对同一个资源共享同一个锁。通过在执行语句前面加上 lock in share mode 就代表对某些资源加上共享锁了。在读操作之前要申请获取共享锁,如果加锁胜利,其它事务能够持续加共享锁,然而不能加排它锁。另一种就是排他锁了,就是指对于多个不同的事务,对同一个资源只能有一把锁。与共享锁类型,在须要执行的语句前面加上 for update 就能够了。对于上述场景,咱们查问这条数据的时候的语句就是

select id, value from lock_table where id = #{id} for update;

这个时候其余事务如果对这条数据进行批改则会阻塞,直到以后事务提交完结,这样能够保证数据的安全性。所以乐观锁的毛病就是每次申请都会额定产生加锁的开销且未获取到锁的申请将会阻塞期待锁的获取,在高并发环境下,容易造成大量申请阻塞,影响零碎可用性。

此外如果这条查问未指定主键(或者索引),或者主键不明确(如 id>0),且能查到数据。那么就会触发表锁。不过尽管咱们指定主键是应用行锁,可 MySQL 有时候会优化判断应用索引比全表扫描更慢,则不应用索引,这种状况个别呈现在表数据比拟小的时候。

应用 Redis 实现分布式锁

接下来咱们开始谈谈 redis 实现分布式锁,这个比上述应用数据库的形式要更常见的多,毕竟数据库还是很软弱的,把高并发的压力放到数据库是很容易使数据库解体的。应用 redis 实现分布式锁最常见的当然是 setnx 指令,这个指令就是 set if not exists 的意思,如下所示,如果 key 不存在则 set(返回 1),否则就失败(返回 0)。

127.0.0.1:6379> setnx name aa
(integer) 1
127.0.0.1:6379> get name
"aa"
127.0.0.1:6379> setnx name bb
(integer) 0

因为锁个别都须要设置生效工夫,能够应用 expire 来设置生效工夫。但很显著,如果是先 setnx 再 expire,因为这两个操作是离开的,不具备原子性,在设置生效工夫的时候若呈现一些异常情况导致指令没执行或执行失败,那么锁就始终无奈开释。解决这个问题有两种办法,一个是应用 Lua 脚本,使得同时蕴含 setnx 和 expire 两个指令,Lua 指令能够保障这两个操作是原子的,所以能保障两者要么同时胜利或者同时失败。还有一种办法就是应用另一个命令

set key value [EX seconds][PX milliseconds][NX|XX]
  • EX seconds: 设定过期工夫,单位为秒
  • PX milliseconds: 设定过期工夫,单位为毫秒
  • NX: 仅当 key 不存在时设置值
  • XX: 仅当 key 存在时设置值

第二种办法是绝对更常见也是比拟举荐的,但它仍然不是相对平安的。举个例子,比方线程 A 获取到了锁,锁的 key 叫做 lock_key,生效工夫是 10s。A 拿着锁去干活,到了 10s 的时候锁要开释了,但 A 干活太慢了还没干完,因为生效工夫到了,B 线程能胜利获取锁。到了 11s 的时候,A 的活干完了,要开释锁了,但这个时候的锁实际上是 B 的,它就把 B 的锁给开释了。这就造成在第 10-11s 的时候实际上是两个线程同时持有了锁并在执行本人的业务代码,同时呈现了锁谬误开释的问题。伪码如下:

// 加锁
redisService.set("lock_key", requestId, 10);
// 业务代码
dosomething();
// 开释锁
redisService.release("lock_key")

上述问题中谬误开释锁问题的关键点显然在于开释锁的时候是间接依据 key 删除了,但没有判断这个 key 还是不是它本人的。那么如果再删除的时候再判断一下它的 value 还是不是本人的,就能够避免误开释的状况了。同样这里也能够应用 Lua 脚本来管制这个逻辑判断,这是因为咱们要先判断是否这个 key 的 value 是否是原来赋予的,而后再删除,这是两个步骤,要保障原子性,因而应用 Lua 来解决。脚本怎么写大家能够自行查一下,我自己说实话目前还没钻研过 Lua,也不从他人的文章外面拷贝了。有趣味的本人查问下。

至于上述的另一个问题就是在第 10-11s 之间呈现了两个线程同时持有锁的状况,这就不合乎咱们一开始的需要了,既然设计了锁必然是心愿同一时间只能有一个线程持有,那么如果咱们持有锁的有效期内还没解决完业务的话,是须要把锁的工夫缩短到业务解决完再开释比拟适合的。这个时候咱们就要用到 redis 罕用的一个工具 -Redisson 了。它能够应用一个叫做 看门狗 机制的形式来提早锁的开释。要想应用它只须要设置 lockWatchdogTimeout 这个属性即可,默认是 3000ms。这个参数只用在锁没有设置 leaseTimeout 这个参数的状况下,所以留神咱们不能应用 lock()办法,而是应用 tryLock()办法, 如下所示,它的底层实现中 leaseTime 传的是 - 1 就明确了。

public RFuture<Boolean> tryLockAsync(long threadId) {
      // 其中第二个参数就是 leaseTime
        return this.tryAcquireOnceAsync(-1L, -1L, (TimeUnit)null, threadId)
}

上面是 Redisson 文档里对这个参数的解释,留神最终这个锁必定是会被开释的,所以它并不能相对的保障不会同时有两个线程拿到锁,默认是 30s,如果到 60s 线程还没有把锁放开,它会被动将这个锁生效。但理论生产中如果一个业务持有锁持有了一分钟还没解决完,得思考下是加长生效工夫还是代码自身或环境不稳固的问题了。

lockWatchdogTimeout

Default value: 30000

Lock watchdog timeout in milliseconds. This parameter is only used if lock has been acquired without leaseTimeout parameter definition. Lock will be expired after lockWatchdogTimeout if watchdog didn’t extend it to the next lockWatchdogTimeout time interval. This prevents against infinity locked locks due to Redisson client crush or any other reason when lock can’t be released in proper way.

除了下面这个误删除的问题,其实还有个问题。咱们平时应用 redis 的时候,往往不是只有一台机器,都是集群的。比方哨兵模式下,master 节点上拿到了某个锁,但忽然 master 节点宕机了,此时会进行故障转移,某个 slave 会降级为新的 master,那这个 slave 也会拿到同样的锁,这就导致了有两个客户端同时持有了对立把锁。所以对于 redis 分布式锁,redis 的官网上有对于 redis 实现分布式锁的文章介绍。外围算法叫做 RedLock 算法。它首先假如有 5 个独立的 Redis master 节点,它们散布在不同的机器上或虚拟机上。而后获取客户端获取锁的步骤如下:

  1. 获取以后毫秒工夫戳
  2. 从这 5 个实例中顺次尝试获取锁,应用雷同的 key 和随机的 value。在这一步骤中,当咱们在每个实例里申请锁时,每个客户端都要设置一个比锁的开释工夫要小的超时工夫。比方锁的主动开释工夫是 10s,那么超时工夫能够设置为 5~50 毫秒。这个能够阻止客户从剩下的曾经阻塞的实例外面一直的尝试获取锁。如果一个实例不可用,咱们该当尝试尽快去连贯下一个实例。
  3. 客户端计算获取锁破费的工夫,即计算以后工夫和第一步失去的工夫的差值。当且仅当客户端能在大部分实例(N/2 +1,这里是 N = 5 所以指的是 3),并且总的获取锁工夫时小于锁的无效工夫,这个锁才认为是胜利获取了。
  4. 如果胜利获取到锁了,它的理论无效工夫就被认为是初始的无效工夫减去第 3 步计算出的破费工夫
  5. 如果客户端因为某些起因获取锁失败了。(比方没有 N /2+ 1 的实例获取到锁或最终无效工夫是负值),那么此时就会尝试将所有实例上的锁进行开释(即便某些实例并没有锁)

Redisson 也是有红锁的实现算法 RedissonRedLock,但它对性能的影响很大,如非必要,个别不采取这种伎俩,可能会通过批改业务的设计或采纳其余技术计划来解决这种极其

应用 ZooKeeper 实现分布式锁

接下来要介绍的是最初一个实现分布式锁的形式,ZooKeeper。这个大家必定也是很相熟的了,但对于很多开发而言,这个都是用来配合 dubbo 当作注册核心应用的。然而它的性能远不止于此。它实际上是一个典型的分布式数据一致性解决方案,分布式应用程序能够基于它实现诸如数据公布 / 订阅、负载平衡、命名服务、分布式协调 / 告诉、集群治理、master 选举、分布式锁和分布式队列等性能。这里我先讲两个前面介绍实现分布式锁会提及的根底概念。当然 ZooKeeper 还有很多其余的概念和知识点,这不是本文的重点也就不说了。

根底概念

数据节点 znode

一个是数据节点 znode。其实在 Zookeeper 中,节点是有两类的。一类是形成集群的机器,咱们称之为机器节点;另一类就是所谓的数据节点 znode,它指的是数据模型中的数据单元。ZooKeeper 将所有数据存储在内存中,数据模型是一棵树,由斜杠进行宰割的门路,就是一个 znode。每个 znode 上都会保留本人的数据内容,同时还会保留一系列属性信息。

同时 znode 还能够分为两类,一类是长久节点,指的是一旦这个 znode 被创立了,除非被动进行 znode 的移除操作,否则这个 znode 将始终保留在 ZooKeeper 上。另一类是长期节点,顾名思义,它的生命周期和客户端会话绑定,一旦客户端会话生效,那么这个客户端创立的所有长期节点都会被移除。

另外,ZooKeeper 容许用户为每个节点增加一个非凡的属性:SEQUENTIAL。一旦节点被标记上这个属性,那么这个节点被创立的时候,ZooKeeper 会主动在其节点名前面追加一个整型数字,这个整型数字是一个由父节点保护的自增数字。也就意味着无论是长久节点还是长期节点,都能够设置成有序的,即长久程序节点和长期程序节点。

版本 version

对于每个 znode,ZooKeeper 都会为其保护一个叫做 Stat 的数据结构,Stat 中记录了这个 ZNode 的三个数据版本,别离是 version(以后 znode 的版本)、cversion(以后 znode 子节点的版本)、和 aversion(以后 znode 的 ACL(Access Control Lists, Zookeeper 的权限控制策略)版本)。

事件监听器 Watcher

另一个概念是 Watcher,事件监听器。ZooKeeper 容许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件告诉到感兴趣的客户端下来,该机制是 ZooKeeper 实现分布式协调服务的重要个性。

锁的实现形式

介绍完根底概念咱们就开始来应用 ZooKeeper 实现分布式锁。ZooKeeper 是通过数据节点来示意一个锁,接下来就开始谈谈 ZooKeeper 实现锁的几种形式,也依照乐观锁和乐观锁的分类来实现。

乐观锁

乐观锁的次要实践就是应用 CAS 算法,因而同样的,应用 ZooKeeper 也是要利用到版本号的概念。

<img src=”https://leafw-blog-pic.oss-cn-hangzhou.aliyuncs.com/1599189335841.png” alt=” 乐观锁流程图 ” style=”zoom:50%;” />

每次更新数据的时候都会带上版本号,如果版本号和以后不统一则会更新失败,须要再次去尝试去更新,这个局部的逻辑是比较简单的就不过多形容。

乐观锁

排他锁

排他锁的概念下面介绍数据库实现分布式锁的时候就有阐明,它也能够称为写锁和独占锁,是一种根本的锁类型。如果事务 T1 对数据对象 O1 加上了排他锁,那么在整个加锁期间,只容许事务 T1 对 O1 进行读取和更新操作,其余任何事务都不能在对这个数据对象进行任何类型的操作,晓得 T1 开释了排他锁。如图为排他锁的示意图:

在须要获取排他锁时候,所有的客户端都会试图调用 create()接口,在 /exclusive_lock 节点下创立长期子节点 /exclusive_lock/lock。ZooKeeper 会保障在所有客户端中,最终只有一个客户端可能创立胜利,那么就能够认为该客户端获取了锁。同时,所有没有获取到锁的客户端就须要到 /exclusive_lock 节点上注册一个子节点变更的 Watcher 监听,以便实时监听到 lock 节点的变更状况。

开释锁有两种状况,一种是以后获取锁的客户端机器产生宕机,那么 ZooKeeper 上的这个长期节点就会被移除。另一种状况是失常执行完业务逻辑后,客户端就会被动将本人创立的长期节点删除。无论什么状况下移除了 lock 节点,ZooKeeper 都会告诉所有在 /exclusive_lock 节点上注册了子节点变更 Watcher 监听的客户端。这些客户端在承受到告诉后,再次从新发动分布式锁获取,即反复“获取锁”过程。

总的来说,排他锁的整个流程能够用下图来示意:

共享锁

接下来再讲讲共享锁,又成为读锁。如果事务 T1 对数据对象 O1 加上了共享锁,那么以后事务只能对 O1 进行读取操作,其余事务也只能对这个数据对象加共享锁,直到该数据对象上的所有共享锁都被开释。和排他锁一样,同样是通过 ZooKeeper 上的数据节点示意一个锁,是一个相似与 ”/shared_lock/[Hostname]- 申请类型 - 序号 ” 的长期程序节点,那么这个节点就代表了一个共享锁。

在须要获取共享锁时,所有客户端都会到 /shared_lock 这个节点上面创立一个长期程序节点,如果是读申请,那么就创立例如 /shared_lock/192.168.0.1-R-000000001 的节点,如果是写申请,那么就创立例如 shared_lock/192.168.0.1-W-000000001 的节点。共享锁在同一时刻能够有多个事务对同一个数据进行读操作,但不可同时进行写。

对于这部分的实现逻辑我依照《从 Paxos 到 ZooKeeper 分布式一致性原理与实际》这本书(zookeeper 这部分的大部分内容都来自于这本书)的形容是如下所示

  1. 创立完节点后,获取 /shared_lock 节点下的所有子节点,并对该节点注册子节点变更的 Watcher 监听
  2. 确定本人的节点序号在所有子节点中的程序
  3. 对于读申请:

    • 如果没有比本人序号小的子节点,或是所有比本人序号小的子节点都是读申请,那么表明本人曾经胜利获取到了共享锁,同时开始执行读取逻辑
    • 如果比本人序号小的子节点中有写申请,那么就须要进入期待。
  4. 对于写申请

    • 如果本人不是序号最小的子节点,那么就须要进入期待
  5. 接管到 Watcher 告诉后,反复步骤 1
  6. 删除锁的逻辑和排他锁一样,这里就不说了

上面是共享锁的流程图,但在我尝试写这部分代码的时候发现有个问题,就是我如果一开始就挂上了子节点列表的变更监听,那么就算某个线程抢到了锁,那么它开释的时候也会触发本人挂上的监听,导致它自身的业务又执行了一次。不晓得是不是我代码实现的问题还是自身这个逻辑不适合,我把注册监听的代码放在了获取锁失败的中央程序运行就没有问题,这个心愿有趣味的童鞋能够本人钻研钻研。

<img src=”https://leafw-blog-pic.oss-cn-hangzhou.aliyuncs.com/f247e24f-cb71-423f-85bf-32ad8b7eb795-924724.jpg” alt=” 书上共享锁的流程图 ” style=”zoom:67%;” />

因而我本人依照本人的想法写了个新的逻辑,流程图如下,但这个逻辑实际上不会产生前面我要说的羊群效应,只是它会呈现大量的争抢锁失败而后一直去争取的情况。

<img src=”https://leafw-blog-pic.oss-cn-hangzhou.aliyuncs.com/%E5%85%B1%E4%BA%AB%E9%94%81-%E6%9C%89%E7%BE%8A%E7%BE%A4.jpg” alt=” 本人设计的共享锁实现 ” style=”zoom:50%;” />

羊群效应

通过上述的逻辑形容,能够发现在整个分布式锁的竞争过程中,大量的“Watcher 告诉”和“子节点列表获取”两个操作反复运行,并且绝大多数的运行后果都是判断出本人并非是序号最小的节点,从而持续期待下一次告诉。如果同一时间有多个节点对应的客户端实现事务或是事务中断引起节点隐没,ZooKeeper 服务器就会在短时间外向其余客户端发送大量的事件告诉,这就是所谓的羊群效应。然而客户端真正的关注点在于判断本人是否是所有子节点中序号最小的。因而每个节点对应的客户端只须要关注比本人序号小的那个相干节点的变更状况就能够了。那么基于这个思维,咱们能够对算法进行改良,同样的,我也是先列出书上的步骤:

  1. 客户端调用 create()办法创立一个相似于 ”/shared_lock/[Hostname]- 申请类型 - 序号 ” 的长期程序节点
  2. 客户端调用 getChildren()接口来获取所有曾经创立的子节点列表,留神,这里不注册任何 Watcher
  3. 如果无奈获取共享锁,那么就调用 exist()来比照本人小的那个节点注册 Watcher。留神这里“比本人小的节点”只是个抽象的说法,具体对于读申请和写申请不一样

    • 读申请:向比本人序号小的最初一个写申请节点注册 Watcher 监听
    • 写申请:向比本人序号小的最初一个节点注册 Watcher 监听
  4. 期待 Watcher 告诉,持续进入步骤 2

流程图如下:

<img src=”https://leafw-blog-pic.oss-cn-hangzhou.aliyuncs.com/d71a3477-c117-44a8-8a32-5dc8ebac4335-924724.jpg” alt=” 书上改良后的共享锁流程图 ” />

那么上述流程中我的纳闷是若当初有两个线程 AB 同时进来了,假如都是写申请,A 序号最小,占用锁,B 须要比照本人小的节点注册监听。但若在监听还没注册上的时候 A 曾经实现了业务操作,B 可能就挂不上监听。那么我感觉,此时应该尝试再次去获取锁。所以以下是我设计的流程图。

<img src=”https://leafw-blog-pic.oss-cn-hangzhou.aliyuncs.com/1599451354015.png” alt=” 本人优化后的共享锁流程图 ” style=”zoom:50%;” />

综上,zookeeper 的共享锁的实现计划尽管我本人的了解和书上有所差别,但思维上我感觉还是靠近的,在平时工作中,咱们如果真的须要应用的话,应该是应用 curator 框架比拟多,这个前面我有空会在钻研下这个框架的应用办法和一些底层逻辑可能会(那就是不会)再写点文章啥的。

对于 ZooKeeper 实现分布式锁这块,绝对于其余的两种实现形式要略微简单一点,但它绝对于 Redis 而言要更牢靠,Redis 的 RedLock 算法也不是百分百保障牢靠,但 ZooKeeper 因为它是依据节点的创立删除来实现锁,宕机时能主动删除节点,所以不会存在像 Redis 那种因为宕机导致的问题。但大部分状况下咱们还是并不需要那么严格的需要,所以其实 Redis 分布式锁是我目前工作中见的最多的。

最初自己 zookeeper 实现分布式锁的代码因为太长了所以放在 github 上了,包含实现局部和测试代码,如果有什么问题心愿能够评论指出来(真心的,因为下面共享锁的局部纠结了我半个月了,很心愿有个大牛能帮我看看是我本人的问题还是只是单纯书上没写的太谨严)。

正文完
 0