分布式锁

分布式实现全局锁的关键:用一个全局唯一的资源来满足资源竞争的顺序执行和原子性:1️⃣使用数据库实现。2️⃣使用缓存实现。

一、Java 原生锁的使用

在说分布式锁之前,先了解下 Java 的 synchronized,这种加锁方式可以针对某个方法或者某个代码块进行加锁,如:

public void doInfo (String uid) {
  synchronized(uid.intern()) {
  //TODO
  }
}

比如甲进来执行了 doInfo(),且还在 synchronized 代码块里处理相关业务,如果此时乙也进来了,那么需要在 synchronized 的代码块外边等待直到甲执行完代码块里的业务,这样就防止甲乙在同一时间对同一资源进行操作可能造成数据错误的情况。

上面只是针对单机的操作,也就是说针对部署单个节点里有效。如果是微服务部署方式,同一个应用可能会部署到多台服务器,而访问的资源还是同一个,甲在第一台服务器上执行,乙在第二台服务器上执行,此时 synchronized 是无效的。因为多节点部署,每个节点都是一个独立的 Server,它们的 JVM 是相互独立的。在内存方面是没办法做到节点之间的相互通信

二、分布式锁的概念

1️⃣线程锁:主要是用来给方法或者代码块加锁的。当某个方法或代码使用锁时,在同一时间只能有一个线程执行该方法或者该代码段。线程锁的实现其实是依靠线程之间共享的内存来实现的,因此线程锁只是针对同一个 JVM 中才有效。

2️⃣进程锁:主要是为了控制同一系统中某个资源同时被多个进程访问。而每个进程都是独立的,因此各个进程是无法互相访问彼此的资源的,也无法像线程那样通过 synchronized 来实现进程锁。

3️⃣分布式锁:控制分布式系统间同步请求共享资源的一种方式。分布式系统中,不同的系统或是同一个系统的不同主机之间共享同一资源,访问该资源的时候,需要互斥防止彼此干扰来保证一致性,此时便需要用到分布式锁。分布式锁应该具备以下特点:

  1. 排他性:在分布式高并发的条件下,同一时刻只能有一个线程获得锁。
  2. 避免死锁:即在客户端使用锁过程中出现异常也不应该出现死锁,应该及时释放锁确保后续流程正常运行。分布式非常有必要设置锁的有效时间,确保系统出现故障后,在一定时间内能够主动去释放锁,避免造成死锁的情况。
  3. 可靠性:只要大部分的中间件节点正常运行,客户端就可以加锁和解锁的操作。
  4. 一致性:加锁和解锁应当是同一个客户端,自己加锁自己解锁,不能去操作其它锁。
  5. 容错性:分布式锁服务一般要满足 AP,也就是说,只要分布式锁服务集群节点大部分存活,client 就可以进行加锁解锁操作。
  6. 重入:同一个线程可以重复拿到同一个资源的锁。重入锁非常有利于资源的高效利用。
  7. 性能:对于访问量大的共享资源,需要考虑减少锁等待的时间,避免导致大量线程阻塞。所以在锁的设计时,需要考虑两点:
    ①锁的颗粒度要尽量小。比如要通过锁来减库存,那这个锁的名称可以设置成是商品的 ID,而不是任取名称。这样这个锁只对当前商品有效。
    ②锁的范围尽量要小。只锁两行代码就可以解决问题的,坚决不锁十行代码。

三、DB

在数据库新建一张表用于控制并发控制,表结构可以如下所示:

key_id 作为分布式 key 用来并发控制,memo 可用来记录一些操作内容(比如 memo 可用来支持重入特性,标记下当前加锁的 client 和加锁次数)。将 key_id 设置为唯一索引,保证了针对同一个 key_id 只有一个加锁(数据插入)能成功。此时 lock 和 unlock 伪代码如下:

注意,伪代码中的 lock 操作是非阻塞锁,也就是 tryLock,如果想实现阻塞(或者阻塞超时)加锁,只需反复执行 lock 伪代码直到加锁成功为止即可。

基于 DB 的分布式锁其实有一个问题,那就是如果加锁成功后,client 端宕机或者由于网络原因导致没有解锁,那么其他 client 就无法对该 key_id 进行加锁并且无法释放了。为了能够让锁失效,需要在应用层加上定时任务,去删除过期还未解锁的记录,比如删除 2 分钟前未解锁的伪代码如下:

因为单实例 DB 的 TPS 一般为几百,所以基于 DB 的分布式性能上限一般也是 1k 以下,一般在并发量不大的场景下该分布式锁是满足需求的,不会出现性能问题。

不过 DB 作为分布式锁服务需要考虑单点问题,对于分布式系统来说是不允许出现单点的,一般通过数据库的同步复制,以及使用 vip 切换 master 就能解决这个问题。

以上 DB 分布式锁是通过 insert 来实现的,如果加锁的数据已经在数据库中存在,用select xxx where key_id = xxx for udpate方式来做也是可以的。

四、Zookeeper分布式锁

ZooKeeper 是一个高可用的分布式协调服务,由雅虎创建,是 Google Chubby 的开源实现。ZooKeeper 提供了一项基本的服务:分布式锁服务。Zookeeper 集群部署保证可用性。

Zookeeper 重要的 3 个特征是:
1️⃣zab 协议:通过 zab 协议保证数据一致性。
2️⃣node 存储模型:node 存储在内存中,提高了数据操作性能。Zookeeper node 模型支持临时节点特性,即 client 写入的数据是临时数据,当客户端宕机时临时数据会被删除,这样就不需要给锁增加超时释放机制了。
3️⃣watcher 机制。使用 watcher 机制,实现了通知机制(比如加锁成功的 client 释放锁时可以通知到其他 client)。

当针对同一个 path 并发多个创建请求时,只有一个 client 能创建成功,这个特性用来实现分布式锁。注意:如果 client 端没有宕机,由于网络原因导致 Zookeeper 服务与 client 心跳失败,那么 Zookeeper 也会把临时数据给删除掉的,这时如果 client 还在操作共享数据,是有一定风险的。

基于 Zookeeper 实现分布式锁,相对于基于 Redis 和 DB 的实现来说,使用上更容易,效率与稳定性较好。curator 封装了对 Zookeeper 的 API 操作,同时也封装了一些高级特性,如:Cache 事件监听、选举、分布式锁、分布式计数器、分布式 Barrier 等,使用 curator 进行分布式加锁示例如下:

五、Redis 锁

Redis 锁是通过以下命令对资源进行加锁:set key value NX PX expireTime

其中,setnx 命令只会在 key 不存在时给 key 进行赋值,px 用来设置 key 过期时间,value 一般是随机值,用来保证释放锁的安全性(释放时会判断是否是之前设置过的随机值,只有是才释放锁)。由于资源设置了过期时间,一定时间后锁会自动释放。

setnx 保证并发加锁时只有一个 client 能设置成功(Redis 内部是单线程,并且数据存在内存中,也就是说 Redis 内部执行命令是不会有多线程同步问题的),此时的 lock/unlock 伪代码如下:

1️⃣分布式锁服务中的一个问题

如果一个获取到锁的 client 因为某种原因导致没能及时释放锁,并且 Redis 因为超时释放了锁,另外一个 client 获取到了锁,此时情况如图:

2️⃣如何解决这个问题

一种方案是引入锁续约机制,也就是获取锁之后,释放锁之前,会定时进行锁续约,比如以锁超时时间的 1/3 为间隔周期进行锁续约。

六、Redisson

Redis 是最流行的 NoSQL 数据库解决方案之一,但 Redis 并没有对 Java 提供原生支持。相反,若想在 Java 程序中集成 Redis,必须使用 Redis 的第三方库。Redisson 就是用于在 Java 中操作 Redis 的库,开发者利用它可以在程序中轻松地使用 Redis。Redisson 在 java.util 中常用接口的基础上,提供了一系列具有分布式特性的工具类。Redis 分布式锁,一般就用 Redisson 框架,非常的简便易用。

核心代码如下:

RLock lock = redisson.getLock("myLock");
//加锁
lock.lock();
//释放锁
lock.unlock();

很清爽!此外,Redisson 还支持 Redis 单实例、Redis 哨兵、Redis cluster、Redis master-slave 等各种部署架构,都可以完美实现。

1️⃣引入依赖

<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson</artifactId>
  <version>3.11.5</version>
</dependency>

2️⃣代码编写

@Autowired
private Redisson redisson;
private void testRedisson (String msg) {
   String lockKey = "test_lock_key";
   //获取锁
   RLock redLock = redisson.getLock(lockKey);
   try {
       //锁5秒钟
       redLock.lock(5, TimeUnit.SECONDS);
       //TODO 处理业务逻辑
    } catch (Exception e) {
       e.printStackTrace();
    } finally {
        //最后释放锁
       if (null != redLock && redLock.isLocked()) {
         redLock.unlock();
       }
    }
}

七、Redisson 实现 Redis 分布式锁的底层原理


1️⃣加锁机制
如上图某个客户端要加锁。如果该客户端面对的是一个 Redis cluster 集群,它首先会根据 hash 节点选择一台机器。这里注意,仅仅只是选择一台机器!紧接着,就会发送一段lua 脚本到 Redis 上,脚本如图:

为何要用 lua 脚本?因为复杂的业务逻辑,可以通过封装在 lua 脚本中发送给 Redis,保证该逻辑执行的原子性

解析 lua 脚本:

  1. KEYS[1]代表加锁的那个 key,比如说:
RLock lock = redisson.getLock("myLock");

这里设置了加锁的那个锁 key 就是“myLock”。

  1. ARGV[1]代表的就是锁 key 的默认生存时间 30 秒。
  2. ARGV[2]代表的是加锁的客户端的 ID,类似如:6094a8d0-0125-4237-67cd-6c419a3b8975:1

第一段 if 判断语句,就是用“exists myLock”命令判断,如果要加锁的那个锁 key 不存在,就进行加锁。如何加锁呢?很简单,用下面的命令:
hset myLock 6094a8d0-0125-4237-67cd-6c419a3b8975:1 1

通过该命令设置一个 hash 数据结构,命令执行后,会出现一个类似下面的数据结构:

mylock:
{
     “6094a8d0-0125-4237-67cd-6c419a3b8975:1”:  1
}

上述就代表“6094a8d0-0125-4237-67cd-6c419a3b8975:1”这个客户端对“myLock”这个锁 key 完成了加锁。接着会执行“pexpire myLock 30000”命令,设置 myLock 这个锁 key 的生存时间是 30 秒。到此为止,加锁完成了。

小结:
①线程去获取锁,获取成功:执行 lua 脚本,保存数据到 Redis 数据库。
②线程去获取锁,获取失败:一直通过 while 循环尝试获取锁,获取成功后,执行 lua 脚本,保存数据到 Redis 数据库。

2️⃣锁互斥机制

在分布式高并发的条件下,同一时刻只能有一个线程获得锁,这是最基本的点。
此时,如果客户端乙来尝试加锁,执行了同样的一段 lua 脚本,会怎样?很简单,第一个 if 判断会执行“exists myLock”,发现 myLock 这个锁 key 已经存在了。接着第二个 if 判断,判断一下,myLock 锁 key 的 hash 数据结构中,是否包含客户端乙的 ID,明显不是,因为那里包含的是客户端甲的 ID。所以,客户端乙会获取到pttl myLock返回的一个数字,这个数字代表了myLock 锁 key 的剩余生存时间。比如还剩 15000 毫秒的生存时间。此时客户端乙会进入一个 while 循环,不停的尝试加锁。

3️⃣watch dog 自动延期机制

客户端甲加锁的锁 key 默认生存时间才 30 秒,如果超过了 30 秒,客户端甲还想一直持有该锁,如何?其实只要客户端甲加锁成功,就会启动一个 watch dog 看门狗,它是一个后台线程,会每隔 10 秒检查一下,如果客户端甲还持有锁 key,那么就会不断的延长锁 key 的生存时间。

4️⃣可重入加锁机制

如果客户端甲都已经持有了这把锁了,结果可重入的加锁会怎么样呢?如下代码:

分析一下上面那段 lua 脚本。

第一个 if 判断肯定不成立,“exists myLock”会显示锁 key 已经存在了。
第二个 if 判断会成立,因为 myLock 的 hash 数据结构中包含的那个 ID,就是客户端甲的 ID,也就是“6094a8d0-0125-4237-67cd-6c419a3b8975:1”。此时就会执行可重入加锁的逻辑,它会通过incrby myLock 6094a8d0-0125-4237-67cd-6c419a3b8975:1 1命令,对客户端甲的加锁次数,累加 1。此时 myLock 数据结构如下:

mylock:
{
     “6094a8d0-0125-4237-67cd-6c419a3b8975:1”:  2
}

Redisson 可以实现可重入加锁机制的原因

①Redis 存储锁的数据类型是 Hash 类型
②Hash 数据类型的 key 值包含了当前线程信息。

下面是 redis 存储的数据:

这里表面数据类型是 Hash 类型,Hash 类型相当于 Java 的 <key,<key1,value>> 类型,这里 key 是指 'redisson'。它的有效期还有 9 秒,再来看里面的 key1 值为078e44a3-5f95-4e24-b6aa-80684655a15a:45它的组成是:guid + 当前线程的 ID。后面的 value 就是和可重入加锁有关,如图:

上图表示可重入锁机制,它最大的优点就是相同线程不需要在等待锁,而是可以直接进行相应操作。

5️⃣释放锁机制

执行 lock.unlock(),就可以释放分布式锁。其实说白了,就是每次都对 myLock 数据结构中的那个加锁次数 -1。如果发现加锁次数是 0 了,说明该客户端已经不再持有锁,此时执行del myLock命令,从 Redis 里删除这个 key。然后,客户端乙就可以尝试加锁了。

这就是所谓的分布式锁的开源 Redisson 框架的实现机制。一般在生产系统中,可以用 Redisson 框架提供的这个类库来基于 Redis 进行分布式锁的加锁与释放锁。

6️⃣上述 Redis 分布式锁的缺点

客户端甲对某个 master 节点写入了 redisson 锁,此时会异步复制给对应的 slave节点。但是这个过程中一旦发生 master 节点宕机复制失败,主备切换,slave 节点从变为了 master 节点。这时客户端乙来尝试加锁的时候,在新的 master 节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。缺陷在哨兵模式或者主从模式下,如果 master 实例宕机的时候,可能导致多个客户端同时完成加锁。
可采用三主三从架构。三台服务器,启动 6 个实例,形成三主三从,其中存储相同数据的主从节点不能落在同一台机器上,目的是防止部署 redis 的虚拟机宕机从而造成主从节点全部失效的问题。

八、总结

分布式锁的设计与实现,都有各自的特点,针对潜在的问题有不同的解决方案,归纳如下:

1️⃣性能:Redis > Zookeeper > DB。

2️⃣避免死锁:DB 通过应用层设置定时任务来删除过期未释放的锁,Zookeeper 是通过临时节点来解决,而 Redis 通过设置超时时间来解决。

3️⃣可用性:DB 可通过数据库同步复制,vip 切换 master 来解决;Zookeeper 本身自己是通过 zab 协议集群部署来解决的;Redis 可通过集群或者 master-slave 方式来解决。注意,DB 和 Redis 的复制一般都是异步的,也就是说某些时刻分布式锁发生故障可能存在数据不一致问题,而 Zookeeper 本身通过 zab 协议保证集群内(至少 n/2+1 个)节点数据一致性。

4️⃣锁唤醒:DB 和 Redis 分布式锁一般不支持唤醒机制(也可以通过应用层自己做轮询检测锁是否空闲,空闲就唤醒内部加锁线程),Zookeeper 可通过本身的 watcher/notify 机制来做。

5️⃣使用分布式锁,安全性上和多线程(同一个进程内)加锁是没法比的,可能由于网络原因,分布式锁服务(因为超时或者认为 client 挂了)将加锁资源给删除了,如果 client 端继续操作共享资源,此时是有隐患的。因此,对于分布式锁,一个是要尽量提高分布式锁服务的可用性,另一个就是要部署同一内网,尽量降低网络问题发生几率。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268