关于java:分布式锁及其实现

4次阅读

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

对于 Java 中的锁大家必定都很相熟,在 Java 中 synchronized 关键字和 ReentrantLock 可重入锁在咱们的代码中是常常见的,个别咱们用其在多线程环境中管制对资源的并发拜访,然而随着分布式的疾速倒退,本地的加锁往往不能满足咱们的须要,在咱们的分布式环境中下面加锁的办法就会失去作用。为了在分布式环境中也能实现本地锁的成果,人们提出了分布式锁的概念。

分布式锁

分布式锁场景

个别须要应用分布式锁的场景如下:

  • 效率:应用分布式锁能够防止不同节点反复雷同的工作,比方防止反复执行定时工作等;
  • 正确性:应用分布式锁同样能够防止毁坏数据正确性,如果两个节点在同一条数据下面操作,可能会呈现并发问题。

分布式锁特点

一个欠缺的分布式锁须要满足以下特点:

  • 互斥性:互斥是所得根本个性,分布式锁须要按需要保障线程或节点级别的互斥。;
  • 可重入性:同一个节点或同一个线程获取锁,能够再次重入获取这个锁;
  • 锁超时:反对锁超时开释,避免某个节点不可用后,持有的锁无奈开释;
  • 高效性:加锁和解锁的效率高,能够反对高并发;
  • 高可用:须要有高可用机制预防锁服务不可用的状况,如减少降级;
  • 阻塞性:反对阻塞获取锁和非阻塞获取锁两种形式;
  • 公平性:反对偏心锁和非偏心锁两种类型的锁,偏心锁能够保障装置申请锁的程序获取锁,而非偏心锁不能够。

分布式锁的实现

分布式锁常见的实现有三种实现,下文咱们会一一介绍这三种锁的实现形式:

  • 基于数据库的分布式锁;
  • 基于 Redis 的分布式锁;
  • 基于 Zookeeper 的分布式锁。

基于数据库的分布式锁

基于数据库的分布式锁能够有不同的实现形式,本文会介绍作者在理论生产中应用的一种数据库非阻塞分布式锁的实现计划。

计划概览

咱们下面列举出了分布式锁须要满足的特点,应用数据库实现分布式锁也须要满足这些特点,上面咱们来一一介绍实现办法:

  • 互斥性:通过数据库 update 的原子性达到两次获取锁之间的互斥性;
  • 可重入性:在数据库中保留一个字段存储以后锁的持有者;
  • 锁超时:在数据库中存储锁的获取工夫点和超时时长;
  • 高效性:数据库自身能够反对比拟高的并发;
  • 高可用:能够减少主从数据库逻辑,晋升数据库的可用性;
  • 阻塞性:能够通过看门狗轮询的形式实现线程的阻塞;
  • 公平性:能够增加锁队列,不过不倡议,实现起来比较复杂。

表结构设计

数据库的表名为 lock,各个字段的定义如下所示:

字段名名称 字段类型 阐明
lock_key varchar 锁的惟一标识符号
lock_time timestample 加锁的工夫
lock_duration integer 锁的超时时长,单位能够业务自定义,通常为秒
lock_owner varchar 锁的持有者,能够是节点或线程的惟一标识,不同可重入粒度的锁有不同的含意
locked boolean 以后锁是否被占有

获取锁的 SQL 语句

获取锁的 SQL 语句分不同的状况,如果锁不存在,那么首先须要创立锁,并且创立锁的线程能够获取锁:

insert into lock(lock_key,lock_time,lock_duration,lock_owner,locked) values ('xxx',now(),1000,'ownerxxx',true)

如果锁曾经存在,那么就尝试更新锁的信息,如果更新胜利则示意获取锁胜利,更新失败则示意获取锁失败。

update lock set 
    locked = true, 
    lock_owner = 'ownerxxxx', 
    lock_time = now(), 
    lock_duration = 1000
where
    lock_key='xxx' and(
    lock_owner = 'ownerxxxx' or
    locked = false or
    date_add(lock_time, interval lock_duration second) > now())

开释锁的 SQL 语句

当用户应用完锁须要开释的时候,能够间接更新 locked 标识位为 false。

update lock set 
    locked = false, 
where
    lock_key='xxx' and
    lock_owner = 'ownerxxxx' and
    locked = true

看门狗

通过下面的步骤,咱们能够实现获取锁和开释锁,那么看门狗又是做什么的呢?

大家设想一下,如果用户获取锁到开释锁之间的工夫大于锁的超时工夫,是不是会有问题?是不是可能会呈现多个节点同时获取锁的状况?这个时候就须要看门狗了,看门狗能够通过定时工作一直刷新锁的获取事件,从而在用户获取锁到开释锁期间放弃始终持有锁。

基于 Redis 的分布式锁

Redis 的 Java 客户端 Redisson 实现了分布式锁,咱们能够通过相似 ReentrantLock 的加锁 - 开释锁的逻辑来实现分布式锁。

RLock disLock = redissonClient.getLock("DISLOCK");
disLock.lock();
try {// 业务逻辑} finally {
    // 无论如何, 最初都要解锁
    disLock.unlock();}

Redisson 分布式锁的底层原理

如下图为 Redisson 客户端加锁和开释锁的逻辑:

加锁机制

从上图中能够看进去,Redisson 客户端须要获取锁的时候,要发送一段 Lua 脚本到 Redis 集群执行,为什么要用 Lua 脚本呢?因为一段简单的业务逻辑,能够通过封装在 Lua 脚本中发送给 Redis,保障这段简单业务逻辑执行的原子性。

Lua 源码剖析:如下为 Redisson 加锁的 lua 源码,接下来咱们会对源码进行剖析。

源码入参 :Lua 脚本有三个输出参数:KEYS[1]、ARGV[1] 和 ARGV[2],含意如下:

  • KEYS[1]代表的是加锁的 Key,例如 RLock lock = redisson.getLock(“myLock”)中的“myLock”;
  • ARGV[1]代表的就是锁 Key 的默认生存工夫,默认 30 秒;
  • ARGV[2]代表的是加锁的客户端的 ID,相似于上面这样的:8743c9c0-0795-4907-87fd-6c719a6b4586:1。

Lua 脚本及加锁步骤如下代码块所示,能够看出其大抵原理为:

  • 锁不存在的时候,创立锁并设置过期工夫;
  • 锁存在的时候,如果是重入场景则刷新锁的过期事件;
  • 否则返回加锁失败和锁的过期工夫。
-- 判断锁是不是存在
if (redis.call('exists', KEYS[1]) == 0) then 
    -- 增加锁,并且设置客户端和初始锁重入次数
    redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    -- 设置锁的超时事件 
    redis.call('pexpire', KEYS[1], ARGV[1]);  
    -- 返回加锁胜利
    return nil;  
end;  
-- 判断以后锁的持有者是不是申请锁的请求者
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then  
    -- 以后锁被请求者持有,重入锁,减少锁重入次数
    redis.call('hincrby', KEYS[1], ARGV[2], 1);  
    -- 刷新锁的过期工夫
    redis.call('pexpire', KEYS[1], ARGV[1]);  
    -- 返回加锁胜利
    return nil;  
end;  
-- 返回以后锁的过期工夫
return redis.call('pttl', KEYS[1]);

看门狗逻辑

客户端 1 加锁的锁 Key 默认生存工夫才 30 秒,如果超过了 30 秒,客户端 1 还想始终持有这把锁,怎么办呢?只有客户端 1 加锁胜利,就会启动一个 watchdog 看门狗,这个后盾线程,会每隔 10 秒检查一下,如果客户端 1 还持有锁 Key,就会一直的缩短锁 Key 的生存工夫。

开释锁机制

如果执行 lock.unlock(),就能够开释分布式锁,此时的业务逻辑也是非常简单的。就是每次都对 myLock 数据结构中的那个加锁次数减 1。

如果发现加锁次数是 0 了,阐明这个客户端曾经不再持有锁了,此时就会用:“del myLock”命令,从 Redis 里删除这个 Key。

而另外的客户端 2 就能够尝试实现加锁了。这就是所谓的分布式锁的开源 Redisson 框架的实现机制。

个别咱们在生产零碎中,能够用 Redisson 框架提供的这个类库来基于 Redis 进行分布式锁的加锁与开释锁。

Redisson 分布式锁的缺点

Redis 分布式锁会有个缺点,就是在 Redis 哨兵模式下:

  1. 客户端 1 对某个 master 节点写入了 redisson 锁,此时会异步复制给对应的 slave 节点。然而这个过程中一旦产生 master 节点宕机,主备切换,slave 节点从变为了 master 节点。
  2. 客户端 2 来尝试加锁的时候,在新的 master 节点上也能加锁,此时就会导致多个客户端对同一个分布式锁实现了加锁。
  3. 零碎在业务语义上肯定会呈现问题,导致各种脏数据的产生。

这个缺点导致在哨兵模式或者主从模式下,如果 master 实例宕机的时候,可能导致多个客户端同时实现加锁。

基于 Zookeeper 的分布式锁

Zookeeper 实现的分布式锁实用于引入 Zookeeper 的服务,如下所示,有两个服务注册到 Zookeeper,并且都须要获取 Zookeeper 上的分布式锁,流程式什么样的呢?

步骤 1

假如客户端 A 抢先一步,对 ZK 发动了加分布式锁的申请,这个加锁申请是用到了 ZK 中的一个非凡的概念,叫做“长期程序节点”。简略来说,就是间接在 ”my_lock” 这个锁节点下,创立一个程序节点,这个程序节点有 ZK 外部自行保护的一个节点序号。

  • 比方第一个客户端来获取一个程序节点,ZK 外部会生成名称 xxx-000001。
  • 而后第二个客户端来获取一个程序节点,ZK 外部会生成名称 xxx-000002。

最初一个数字都是顺次递增的,从 1 开始逐次递增。ZK 会保护这个程序。所以客户端 A 先发动申请,就会生成进去一个程序节点,如下所示:

客户端 A 发动了加锁申请,会先加锁的 node 下生成一个长期程序节点。因为客户端 A 是第一个发动申请,所以节点名称的最初一个数字是 ”1″。客户端 A 创立完整程序节后,会查问锁上面所有的节点,依照开端数字升序排序,判断以后节点的是不是第一个节点,如果是第一个节点则加锁胜利。

步骤 2

客户端 A 都加完锁了,客户端 B 过去想要加锁了,此时也会在锁节点下创立一个长期程序节点,节点名称的最初一个数字是 ”2″。

客户端 B 会判断加锁逻辑,查问锁节点下的所有子节点,按序号顺序排列,此时第一个是客户端 A 创立的那个程序节点,序号为 ”01″ 的那个。所以加锁失败。加锁失败了当前,客户端 B 就会通过 ZK 的 API 对他的程序节点的上一个程序节点加一个监听器。ZK 人造就能够实现对某个节点的监听。

步骤 3

客户端 A 加锁之后,可能解决了一些代码逻辑,而后就会开释锁。Zookeeper 开释锁其实就是把客户端 A 创立的程序节点 zk_random_000001 删除。

删除客户端 A 的节点之后,Zookeeper 会负责告诉监听这个节点的监听器,也就是客户端 B 之前增加监听器。客户端 B 的监听器晓得了上一个程序节点被删除,也就是排在他之前的某个客户端开释了锁。此时,就会客户端 B 会从新尝试去获取锁,也就是获取锁节点下的子节点汇合,判断本身是不是第一个节点,从而获取锁。

三种锁的优缺点

基于数据库的分布式锁

  • 数据库并发性能较差;
  • 阻塞式锁实现比较复杂;
  • 偏心锁实现比较复杂。

基于 Redis 的分布式锁

  • 主从切换的状况下可能呈现多客户端获取锁的状况;
  • Lua 脚本在单机上具备原子性,主从同步时不具备原子性。

基于 Zookeeper 的分布式锁

  • 须要引入 Zookeeper 集群,比拟重量级;
  • 分布式锁的可重入粒度只能是节点级别;

参考文档

分布式锁

三种分布式锁比照

分布式锁的三种实现的比照

我是御狐神,欢送大家关注我的微信公众号:wzm2zsd

本文最先公布至微信公众号,版权所有,禁止转载!

正文完
 0