一、Java原生锁的使用
在说分布式锁之前,先了解下 Java 的 synchronized,这种加锁方式可以针对某个方法或者某个代码块进行加锁,如:
public void doInfo (String uid) {
synchronized(uid.intern()) {
//TODO
}
}
比如A用户进来执行了doInfo(),且还在 synchronized 代码块里处理相关业务,如果此时B用户也进来了,那么需要在 synchronized 的代码块外边等待直到A执行完代码块里的业务,这样就防止A和B在同一时间对同一资源进行操作可能造成数据错误的情况。
上面只是针对单机的操作,也就是说针对部署单个应用里有效,如果是微服务部署方式,同一个应用可能会部署到多台服务器,而访问的资源还是同一个,那么用户A在第一台服务上执行,B在第二台服务器上执行,那么 synchronized 此时是无效的。
二、分布式锁的概念
1️⃣线程锁:主要是用来给方法或者代码块加锁的。当某个方法或代码使用锁时,在同一时间只能有一个线程执行该方法或者该代码段。线程锁的实现其实是依靠线程之间共享的内存来实现的,因此线程锁只是针对同一个JVM中才有效。
2️⃣进程锁:主要是为了控制同一系统中某个资源同时被多个进程访问。而每个进程都是独立的,因此各个进程是无法互相访问彼此的资源的,也无法像线程那样通过 synchronized 来实现进程锁。
3️⃣分布式锁:控制分布式系统间同步请求共享资源的一种方式。在分布式系统中,如果不同的系统或是同一个系统的不同主机之间共享同一资源,那么访问这个资源的时候,需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。
另外分布式锁应该具备以下特点:
①互斥性:在分布式高并发的条件下,同一时刻只能有一个线程获得锁。
②防止死锁:即在客户端使用锁过程中出现异常也不应该出现死锁,应该及时释放锁确保后续流程正常运行。分布式非常有必要设置锁的有效时间,确保系统出现故障后,在一定时间内能够主动去释放锁,避免造成死锁的情况。
③可靠性:只要大部分的中间件节点正常运行,客户端就可以加锁和解锁的操作。
④一致性:加锁和解锁应当是同一个客户端,自己加锁自己解锁,不能去操作其它锁。
⑤重入:ReentrantLock是可重入锁,它的特点:同一个线程可以重复拿到同一个资源的锁。重入锁非常有利于资源的高效利用。
⑥性能:对于访问量大的共享资源,需要考虑减少锁等待的时间,避免导致大量线程阻塞。所以在锁的设计时,需要考虑两点:
- 锁的颗粒度要尽量小。比如要通过锁来减库存,那这个锁的名称可以设置成是商品的ID,而不是任取名称。这样这个锁只对当前商品有效。
- 锁的范围尽量要小。比如只要锁2行代码就可以解决问题的,那就不要去锁10行代码。
针对以上Redisson都能很好的满足,下面就来分析下它。
三、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️⃣加锁机制

为啥要用lua脚本呢?因为一大坨复杂的业务逻辑,可以通过封装在lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性。
那么,这段lua脚本是什么意思呢?
①KEYS[1]
代表加锁的那个key,比如说:
RLock lock = redisson.getLock("myLock");
这里设置了加锁的那个锁key就是“myLock”。
②ARGV[1]
代表的就是锁key的默认生存时间,默认30秒。
③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️⃣锁互斥机制
在分布式高并发的条件下,同一时刻只能有一个线程获得锁,这是最基本的一点。
此时,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会怎样?很简单,第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。此时客户端2会进入一个while循环,不停的尝试加锁。
3️⃣watch dog自动延期机制
客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?其实只要客户端1加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下
,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
4️⃣可重入加锁机制
那如果客户端1都已经持有了这把锁了,结果可重入的加锁会怎么样呢?比如下面这种代码:
这时来分析一下上面那段lua脚本。
第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了。
第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“6094a8d0-0125-4237-67cd-6c419a3b8975:1”。此时就会执行可重入加锁的逻辑,它会用:incrby myLock 6094a8d0-0125-4237-67cd-6c419a3b8975:1 1
。通过这个命令,对客户端1的加锁次数,累加1。此时myLock数据结构变为下面这样:
mylock:
{
“6094a8d0-0125-4237-67cd-6c419a3b8975:1”: 2
}
那个myLock的hash数据结构中的那个客户端ID,就对应着加锁的次数。
图解可重入加锁:
Redisson可以实现可重入加锁机制的原因:
1、Redis存储锁的数据类型是 Hash类型
2、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。然后,另外的客户端2就可以尝试完成加锁了。
这就是所谓的分布式锁的开源Redisson框架的实现机制。一般在生产系统中,可以用Redisson框架提供的这个类库来基于redis进行分布式锁的加锁与释放锁。
6️⃣上述Redis分布式锁的缺点
客户端1 对某个master节点写入了redisson锁,此时会异步复制给对应的 slave节点。但是这个过程中一旦发生 master节点宕机复制失败,主备切换,slave节点从变为了 master节点。这时客户端2 来尝试加锁的时候,在新的master节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。缺陷在哨兵模式或者主从模式下,如果 master 实例宕机的时候,可能导致多个客户端同时完成加锁。
网友评论