为什么需要分布式锁?
在单体项目中,我们可以使用syn锁为程序加锁来防止出现并发问题。那么java底层是怎样实现锁互斥的呢?其实是依靠jvm中的锁监视器,当有线程拿到syn锁时,锁监视器就会记录该行为,将该线程的id和锁对象都记录下来,此时如果有其他线程试图拿到相同锁对象的syn锁时,就会因为锁监视器中已经有记录而失败,这样就实现了锁互斥
但是在集群环境下,每台服务器都有自己独立的jvm,在这种情况下如果我们仍然使用syn锁为程序代码加锁就会出现问题,因为无论是锁对象还是锁监视器都是依赖于jvm的,在不同的jvm中,哪怕运行的代码完全一致,它们的锁对象和锁监视器也不会是同一个,这样就会导致有多少台服务器中就能有多少条线程拿到syn锁,进而出现线程安全问题
为了保证在集群环境下仍然只有一条线程能够拿到互斥锁,我们需要使用分布式锁来为代码加锁。
分布式锁是满足分布式系统或集群模式下多进程可见并且互斥的锁。简单点来说,分布式锁的核心思想就是让不同服务器中的线程都能够使用同一把锁。分布式锁应该具有以下特征:
可见性:不同服务器里的多个线程都能看到相同的结果,例如锁对象的获取和锁对象的释放,注意这里的可见性并不是指内存可见性
互斥:互斥是分布式锁的最基本的条件,必须保证一把锁在同一时刻只能被一条线程拿到,使程序串行执行
高可用:程序不易崩溃,时时刻刻都保证较高的可用性,即绝大部分情况下获取锁都是成功的
高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
安全性:在获取锁的时候应当考虑一些异常情况,比如服务器宕机导致未释放,死锁问题等
常见的分布式锁有三种
MySQL:MySQL 本身具备事务机制,在执行写操作的时候,MySQL 会为正在变动的数据分配行锁,进而保证在同一时刻只有一个事务操作一组数据,最终实现事务之间的锁互斥。我们可以基于上述原理来实现分布式锁。但是这种方式会受限于MySQL的性能。
Redis:利用 SETNX 互斥命令,当使用 SETNX 命令向 Redis 中存储数据时,只有该数据的 key 不存在时才能存储成功,如果已经存在,就会存储失败,我们可以通过这种方式表示锁的获取,释放锁时,只需要将 key 删除即可。Redis 支持主从模式、集群模式,可用性高,且在性能方面也远远高于 MySQL。
需要注意的是我们在Redis中使用 SETNX 命令存储数据时,一定要设置过期时间,这样即使Redis 服务宕机,锁最后也会得到释放,但是到底设置多长时间比较合适,需要我们好好考虑。如果过期时间设置的过长,那么锁的无效等待时间就会比较多,如果设置过短,有可能导致业务没有执行结束就将锁释放掉。
Zookeeper:Zookeeper 实现锁的原理是基于它内部的节点机制。Zookeeper 内部可以创建数据节点,而节点具有唯一性和有序性,另外,Zookeeper 还可以创建临时节点。所谓唯一性就是在创建节点时,节点不能重复;所谓有序性是指每创建一个节点,节点的id是自增的。那么就可以利用节点的有序性来实现互斥。
当有大量线程来获取互斥锁时,每个线程就会创建一个节点,而每个节点的 id 是单调递增的,如果我们约定 id 最小的那个获取锁成功,这样就可以实现互斥。当然,也可以利用唯一性,让所有线程去创建节点,但是节点名称相同,这样就会只能有一个线程创建成功。一般情况下,会使用有序性来实现互斥。想要释放锁,则只需要将节点删除即可,一旦将最小节点删除,那么剩余节点中 id 最小的那个节点就会获取锁成功。
由于Zookeeper 本身支持集群,所以其可用性很好。而Zookeeper 的集群强调节点之间的强一致性,而这种强一致性就会导致主从之间在进行数据同步时会消耗一定的时间,其性能相较于 Redis 而言会差一点。安全性方面,Zookeeper 一般创建的是临时节点,一旦服务出现故障,Zookeeper 就会自动断开连接,锁就会自动释放掉。
在本篇文章中,会介绍利用redis实现分布式锁
基于Redis实现分布式锁的基本思路如下:
利用redis中String类型的setnx方法,这个方法与普通的set方法相比有一个特性,就是只有当redis中key不存在时才能插入成功,如果key存在则插入失败,而我们可以用插入key来表示获取锁的过程,插入key成功表示锁获取成功,插入key失败则表示锁获取失败,这样也就实现了锁互斥,当我们需要释放锁时,只需要将该数据从redis中移除就好,当然为了防止redis宕机时出现死锁的现象,我们最好为该数据设置一条过期时间。
实现分布式锁时需要实现以下两个基本方法:
获取锁:
释放锁:
编写以下接口作为锁的基本接口
/**
* 分布式锁父接口
*/
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后锁自动释放
* @return 返回true表示锁获取成功,返回false表示锁获取失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
编写SimpleRedisLock实现ILock
利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性
public class SimpleRedisLock implements ILock {
/**
* 业务名称,由调用者创建对象时传入,目的是保证在不同的业务中有不同的key作为锁
*/
private String name;
private StringRedisTemplate stringRedisTemplate;
/**
* 锁前缀
*/
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 获取锁
* @param timeoutSec 锁持有的超时时间,过期后锁自动释放
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
//获取当前线程标识
long id = Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
KEY_PREFIX + name,
id + "",
timeoutSec,
TimeUnit.MINUTES
);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*/
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
使用SimpleRedisLock
//创建锁对象,这里第一个参数根据业务传入,保证在不同的业务中都能使用不同的锁,例如我这里需要保证order业务中的线程安全
SimpleRedisLock redisLock = new SimpleRedisLock("order", stringRedisTemplate);
//获取锁对象
if(!redisLock.tryLock(10l)){
System.out.println("执行获取锁失败时的业务逻辑")
}
try {
System.out.println("执行获取锁成功时的业务逻辑")
} finally {
//释放锁对象
redisLock.unlock();
}
以上的分布式锁在实际情况中可能出现以下现象:
假设线程 1 获取互斥锁且获取成功,拿到锁后,线程 1 开始执行业务,但是由于某种原因,线程 1 的业务发生了阻塞,这样就会导致线程 1 持有锁的周期就会变长,如果当其持有的锁到期了,线程 1 的业务仍未执行完毕,锁就会被自动释放。
既然锁已经被释放了,那么其他线程就会尝试获取锁,假如此时线程2抢到了锁,然后开始执行业务。就在此时,线程1结束了阻塞并完成了自己的业务,然后线程1就会执行释放锁操作,但是此时的锁是由线程2持有的,由于此时我们编写的释放锁的代码只是简单的删除数据,因此线程1也可以直接释放线程2的锁,但是线程2此时的业务是仍然会继续执行的
既然锁已经被释放了,那么其他线程就会尝试获取锁,假如此时线程3抢到了锁,然后开始执行业务。此时就会出现两个线程并行执行业务代码的情况,就很有可能出现线程安全问题
还记得我们之前编写的获取锁代码中的setnx保存的值是线程ID吗?我们可以利用这点来解决上述问题
每个线程在释放锁的时候,先判断一下锁的线程ID与线程ID是否相等,如果相等则说明当前的锁对象是由当前线程持有的,就可以进行锁的释放,如果不相等,说明当前锁对象已经由别的线程持有了,则不进行锁的释放,这样就可以解决上述问题
当然还有一点问题是我们不能直接基于线程ID来判断锁对象是否是当前线程所持有,因为线程ID是由jvm递增生成的,每有一条线程,线程ID就加一,这样就会导致不同的服务器上会出现线程ID相等的现象,在集群环境下容易出现问题。我们可以在不同的服务器的线程ID前面带上一个不相同的前缀,这个前缀最好是随机生成的
基于上述分析,改进SimpleRedisLock如下:
public class SimpleRedisLock implements ILock {
/**
* 业务名称,由调用者创建对象时传入,目的是保证在不同的业务中都有不同的key作为锁
*/
private String name;
private StringRedisTemplate stringRedisTemplate;
/**
* 锁前缀
*/
private static final String KEY_PREFIX = "lock:";
/**
* 随机生成的线程ID前缀,这里用final保证在同一个jvm上面前缀是一致的
*/
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 获取锁
* @param timeoutSec 锁持有的超时时间,过期后锁自动释放
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
//获取当前线程标识
String id = ID_PREFIX+Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
KEY_PREFIX + name,
id,
timeoutSec,
TimeUnit.MINUTES
);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*/
@Override
public void unlock() {
//获取当前线程标识
String id = ID_PREFIX+Thread.currentThread().getId();
//获取当前锁的线程标识,并与当前线程进行对比
if(id.equals(stringRedisTemplate.opsForValue().get(KEY_PREFIX + name)){
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
}
上述的代码已经保证了锁误删的情况基本不会发生,为什么要说基本呢?因为还有更极端的情况。
在更极端的情况下,假设线程 1 获取互斥锁且获取成功,拿到锁后,线程 1 开始执行业务,执行结束后,线程 1 的准备释放锁,并且经过判断也确认了当前锁是自己所持有的,整准备执行stringRedisTemplate.delete(KEY_PREFIX+name);
时,线程1发生了阻塞(这种情况是有可能发生的,例如jvm垃圾回收就会阻塞所有的代码),而在线程1阻塞的过程中,锁因为过期自动释放了,这时线程2就会抢到锁,然后执行自己的业务。而正当线程2执行业务逻辑时,线程1结束了阻塞并将锁释放,到了这一步,后面会发生的事就很明显了,由于线程2持有的锁被线程1释放了,但是其业务还没执行完,此时线程3抢到了锁,并于线程2并行执行,线程安全问题出现
之所以会出现上述现象,是因为判断和删除锁是两个动作,既然是两个动作,那么中间就可能会出现阻塞,进而导致出现问题
if(id.equals(stringRedisTemplate.opsForValue().get(KEY_PREFIX + name)){
stringRedisTemplate.delete(KEY_PREFIX+name);
}
那我们思考一下,有没有什么办法可以让这两个动作变成一个动作呢
Redis提供了Lua脚本功能,我们可以在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法可以参考网站:https://www.runoob.com/lua/lua-tutorial.html
这里重点介绍Redis提供的调用函数,语法如下:
redis.call('命令名称', 'key', '其它参数', ...)
例如,我们要执行set name jack,则脚本是这样:
# 执行 set name jack
redis.call('set', 'name', 'jack')
再例如,我们需要先执行set name Rose,再执行get name,则脚本如下:
# 先执行 set name jack
redis.call('set', 'name', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
例如,我们要执行redis.call('set', 'name', 'jack')
这个脚本,语法如下:
脚本中的key、value也可以通过参数来传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
接下来我们来回一下我们释放锁的逻辑:
如果用Lua脚本来表示则是这样的:
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
接下来我们就可以改造我们之前的代码,使用lua脚本来释放锁
在resources新建一个unlock.lua,将上面的lua脚本复制进去
改造SimpleRedisLock代码:
public class SimpleRedisLock implements ILock {
/**
* 业务名称,由调用者创建对象时传入,目的是保证在不同的业务中都有不同的key作为锁
*/
private String name;
private StringRedisTemplate stringRedisTemplate;
/**
* 锁前缀
*/
private static final String KEY_PREFIX = "lock:";
/**
* 随机生成的线程ID前缀,这里用final保证在同一个jvm上面前缀是一致的
*/
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
/**
* 脚本对象
*/
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
/**
* 使用静态代码块加载lua脚本,在类启动的时候就将脚本加载进内存
*/
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
//设置脚本路径
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
//设置脚本返回值类型。这里随意即可
UNLOCK_SCRIPT.setResultType(Long.class);
}
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 获取锁
* @param timeoutSec 锁持有的超时时间,过期后锁自动释放
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
//获取当前线程标识
String id = ID_PREFIX+Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
KEY_PREFIX + name,
id,
timeoutSec,
TimeUnit.MINUTES
);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*/
@Override
public void unlock() {
//获取当前线程表示
String id = ID_PREFIX+Thread.currentThread().getId();
//调用lua脚本执行锁释放操作
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name), //需要释放的锁的id
ID_PREFIX + Thread.currentThread().getId() //需要进行判断的线程标识
);
}
}
这样我们就保证了标志判断到锁释放这整个过程的原子性。
基于setnx实现的分布式锁已经能够满足我们实际开发中的绝大部分需求,但是如果业务中有一些“特殊”的需求,那仅靠setnx可能就有些不太够用了。目前基于setnx的实现的分布式锁主要有以下几点问题:
不可重入:锁的重入是指获得锁的线程可以再次进入到相同的锁的代码块中,即一个线程可以多次重复的去获取同一把锁。可重入锁的意义在于防止死锁,例如在HashTable中,所有的方法都是使用synchronized修饰的,当我们在HashTable的一个方法内去调用另一个方法时,由于前后会获取两次锁,而且锁对象是同一个(当前类对象),如果锁是不能重入的,那么就会出现死锁现象。所以我们经常使用的synchronized和Lock锁都是可重入的。
不可重试:目前的分布式锁在获取锁失败后会直接返回false,即获取锁这个过程只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,它应该在一段时间内能再次尝试获得锁。
超时释放:我们目前编写的代码只能防止锁被误删的问题,如果因为业务执行时间较长导致锁超时释放,那么最终还是会出现两个线程并行执行业务的现象
主从一致性:如果 Redis 提供了主从集群,由于主从同步时存在延迟,假如某个线程从主节点中获取到了锁,但是尚未同步给从节点,而恰巧主节点在这个时候宕机。此时就会有一个从节点被选举成为新的主节点,而由于主节点并未将锁同步给其他节点,那么此时就会有另外一个线程在新的主节点上获取一把新的锁,这样就出现了两个线程分别拿到两把锁的情况。当然,由于redis主从同步延迟极低,因此这是在极端情况下才会出现的安全问题。
如果要解决上述问题,那么就需要更加复杂的编码,那么在市面上有没有什么现成的技术可以使用呢?答案就是Redisson。
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
Redission提供了分布式锁的多种多样的功能
引入依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
配置Redisson客户端:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://192.168.211.100:6379")
.setPassword("123456");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
使用Redission的分布式锁:
@Resource
private RedissionClient redissonClient;
@Test
void testRedisson() throws Exception{
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
//如果没有设置任何参数,则默认是-1(不重试立即返回),30,秒
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
//判断获取锁成功
if(isLock){
try{
System.out.println("执行业务");
}finally{
//释放锁
lock.unlock();
}
}
}
如果我们按照之前setnx的方式获取锁,那么实际上获得的锁是不可重入的,例如让我们观察下图中左侧的代码
代码很简单,执行method1,会去尝试获取一把锁,如果失败就返回,如果成功则调用method2,而在method2中也会尝试去获取同一把锁,由于这两个方法是由同一个线程执行的,所以为了避免出现死锁,我们最好希望锁是可重入的,但是实际上,如果锁的实现原理是按照我们之前setnx的方式来实现的话,当线程一第一次获取锁之后,第二次就已经无法再获取锁了,因为基于setnx的机制,key在第一次被写入后,第二次就会被判断已经存在无法再进行写入了,这也就意味着锁是不可重入的。
而基于redisson实现的锁是可重入的,那么它底层是怎样做的呢
我们之前使用的锁的结构是string类型的,redisson则不同,它的锁的结构是Hash类型的,该Hash中有两个field,一个用于记录线程id,另一个用于记录重入的次数(实际上就是一个计数器),初始值为1。
当线程试图获取锁的时候,首先会去判断锁是否已经被占用,如果已经被占用,就会去判断是否是当前线程所占用的,如果不是,则获取锁失败,如果是,则记录重入次数的计数器加一,且对方法调用者仍然表示调用锁成功。在释放锁时,当前线程每释放一次锁,计数器就会减一。当计数器被减到零时,会将锁删除,这样也就实现了可重入锁的需求。
可以看到,无论是获取锁还是释放锁,其业务逻辑相较于之前的版本已经复杂了很多,没有办法再通过 Java 代码实现,只能采用 Lua 脚本来确保获取锁和释放锁的原子性,而Redisson底层的操作也是通过lua脚本实现的,我们可以大致模拟一下其实现:
获取锁脚本
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断锁是否存在
if(redis.call('exists', key) == 0) then
-- 不存在,获取锁
redis.call('hset', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
-- 锁已经存在,判断threadId是否是当前线程ID
if(redis.call('hexists', key, threadId) == 1) then
-- 存在,获取锁,重入次数+1
redis.call('hincrby', key, threadId, '1');
-- 重新设置锁的有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
释放锁脚本
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断当前锁是否还是被自己持有
if(redis.call('hexists', key, threadId) == 0) then
return nil; -- 如果已经不是自己,则直接返回
end
-- 是自己的锁,则重入次数-1
local count = redis.call('hincrby', key, threadId, -1);
-- 判断重入次数是否已经为0
if(count > 0) then
-- 大于0说明不能是释放锁,重置有效期然后返回
redis.call('expire', threadId, releaseTime);
return nil;
else
redis.call('del', key); -- 等于 0 说明可以释放锁,直接删除
return nil;
end
redisson底层获取锁与释放锁的脚本与上述大体上是一致的,我们将在下文中看到
之前我们实现的分布式锁在获取锁失败之后会立即返回,即获取锁是不可重试的,而redisson的分布式锁是可重试的,那么redisson到底是如何实现的呢?我们一起来看看
在redisson的trylock方法的第一种重载形式中有三个参数,其中的第一个参数waitTime就是我们自己设置的锁重试时长,当我们设置了这个参数后,如果线程获取锁失败,就会在我们指定的时间范围内重试,当然,如果我们并未指定时间,则默认获取失败不重试立即返回,而第二个参数leaseTime则是指定锁过期时间,第三个参数是时间的单位
而第三种重载形式则是只指定锁重试时长和时间单位
这里我们可以选择trylock的第三种重载形式,然后跟入源码
boolean isLock = lock.tryLock(1L, TimeUnit.MINUTES);
继续跟进之后到RedissonLock类中的代码是这样的,我们先列举出来,然后逐段分析
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId);
return false;
}
try {
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// waiting for message
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
unsubscribe(subscribeFuture, threadId);
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
首先我们来看这部分代码:
//将我们传入的等待时间转成毫秒
long time = unit.toMillis(waitTime);
//获取当前时间
long current = System.currentTimeMillis();
//获取当前线程id
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
其中的tryAcquire方法其实就是一个获取锁的代码,
我们可以继续跟入看看,发现tryAcquire最终调用了tryAcquireAsync方法
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
/*
* 判断leaseTime是否为-1,如果我们在调用trylock时未设置参数,那么默认值就是-1
* 如果leaseTime不为-1,则说明我们设置了leaseTime,则执行以下逻辑
*/
if (leaseTime != -1) {
return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
/*
* 如果我们并未设置leaseTime,即leaseTime等于-1,则执行以下逻辑
*/
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
waitTime,
/*
* 这里为leaseTime设置一个默认值,使用的是看门狗的超时时间,而看门狗的超时时间是3000毫秒
* 也就是说如果我们不设置锁超时时间,默认30秒超时
*/
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS,
threadId,
RedisCommands.EVAL_LONG
);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
在tryAcquireAsync中最终获取锁执行的是一个名为tryLockInnerAsync的方法,我们可以点进去看看:
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"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]);",
Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
这个方法就比较明显了,它执行了一段lua脚本,在redisson中lua脚本是写死在代码中的,而并不是像我们之前那样单独写在一个lua文件中,仔细分析这段代码,它其实和我们之前在讲解可重入锁时列举出来的获取锁的lua脚本时高度相似的,这里不再过多阐述
需要注意的是两点,第一点是该lua脚本中获取锁成功返回的是nil,获取锁失败返回的却是redis.call('pttl', KEYS[1]);
,那么pttl命令是干什么的呢?它其实与我们之前使用过的ttl命令是类似的,都是查看当前key剩余的有效期,不过ttl返回的是秒数,而pttl返回的是毫秒数
这下我们就理解了,如果获取锁失败,这段lua脚本返回的结果实际上是当前锁剩余的有效时间,那么返回这个有效时间有什么用呢?
这时我们再回到tryAcquireAsync方法,发现最终接收tryLockInnerAsync返回结果的是一个Future对象
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
waitTime,
/*
* 这里为leaseTime设置一个默认值,使用的是看门狗的超时时间,而看门狗的超时时间是3000毫秒
* 也就是说如果我们不设置锁超时时间,默认30秒超时
*/
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS,
threadId,
RedisCommands.EVAL_LONG
);
这是因为tryLockInnerAsync是一个异步调用的方法,方法调用之后结果有没有拿到暂时还不清楚,因此这里使用一个Future来接收,最终tryAcquireAsync方法返回的结果也是这个Future
return ttlRemainingFuture;
返回之后其实就回到我们最初的tryLock方法了,tryLock方法使用了一个变量ttl来接收剩余的有效时间
//将我们传入的等待时间转成毫秒
long time = unit.toMillis(waitTime);
//获取当前时间
long current = System.currentTimeMillis();
//获取当前线程id
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// 如果ttl等于null,说当前没有人获取锁,或者持有锁的是当前线程,希望重入锁,则返回true,表示获取锁成功
if (ttl == null) {
return true;
}
// 获取锁失败,由于我们在传参的时候设置了重试时间,因此获取锁失败后还会尝试再次获取
/*
* 这里用当前时间减去尝试获取锁之前的时间,得到的也就是tryAcquire()方法的执行时间,即获取锁消耗的时间
* 再用最大等待时间减去获取锁消耗的时间,得到的是剩余等待时间
*/
time -= System.currentTimeMillis() - current;
// 如果剩余等待时间已经小于等于零了,那么就表示获取锁真正失败了,直接返回false
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
再往下看,就是真正锁重试的业务逻辑了
// 首先再次拿到当前时间,避免误差
current = System.currentTimeMillis();
/*
* 获取锁失败之后不会立即尝试重新获取锁,因为持有锁的对象大概率还在持有锁,现在去尝试除了增加CPU负担以外没有任何意义
*
*/
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
我们发现这里异步调用了一个方法subscribe(),并且传入了当前线程id,那么这个方法是干什么用的呢?
其实当前线程在获取锁失败之后不会立即尝试重新获取锁,因为持有锁的对象大概率还在持有锁,现在去尝试除了增加CPU负担以外没有任何意义,所以当前线程应该等待当前持有锁的线程释放锁之后才开始尝试获取锁,那么当前线程怎么知道锁什么时候释放呢?其实就是通过这个subscribe方法
在redisson释放锁的lua脚本是这样写的
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
我们发现在这段lua脚本中,删除key时除了执行一个del命令以外,还执行了以下命令:
redis.call('publish', KEYS[2], ARGV[1]);
这个命令的作用其实就是发布一条释放锁的通知,而我们上面的subscribe实际上就是订阅这个通知,当锁被释放之后,其他线程就能接收到通知,从而开始进行获取锁的重试,当然收到通知的具体时间时不确定的,因此我们这里使用的是异步调用,返回值是一个Future
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
继续往下看:
// 首先再次拿到当前时间,避免误差
current = System.currentTimeMillis();
/*
* 获取锁失败之后不会立即尝试重新获取锁,因为持有锁的对象大概率还在持有锁,现在去尝试除了增加CPU负担以外没有任何意义
*
*/
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
/*
* 通过await方法尝试等待subscribeFuture的返回结果,那么等待多长时间呢?
* await方法传递了一个参数,实际上等待时间就是在这个参数范围内,而这里我们传入的是time,
* time这个参数是我们当前线程尝试获取锁的剩余等待时间
* 也就是说await方法返回的等待的时间是尝试获取锁的剩余等待时间,如果剩余等待时间结束,那么await方法返回的等待时间就是false
*/
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
// await返回结果是false时,也就是剩余等待时间已经结束时,尝试执行的代码
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
/*
* 这里的unsubscribe是与subscribe相对的,subscribe意味着订阅,则unsubscribe意味着取消订阅
* 由于当前线程获取锁的等待时间已经结束了,自然也就没有继续订阅的必要了
*/
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId);
//返回获取锁失败的结果
return false;
}
// 如果在剩余等待时间内接收到了锁释放的通知,则执行以下代码:
try {
/*
* 这里用当前时间减去等待通知之前记录的时间,得到的结果也就是等待发布释放锁通知所耗费的时间
* 再用获取锁的剩余等待时间减去上述时间,得到剩余等待时间
* 之所以这么做,主要是为了确保超时时间的精确程度
*/
time -= System.currentTimeMillis() - current;
/*
* 如果time小于零,就意味着执行上述业务逻辑的过程中,锁获取的等待时间已经结束了,直接返回false表示锁获取失败
*/
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
/*
* 开始进行获取锁的重试
*/
while (true) {
/*
* 从这里开始就和之前获取锁的过程比较类似了
*/
// 记录当前时间
long currentTime = System.currentTimeMillis();
// 尝试重新获取锁,并拿到当前锁剩余的有效时间
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// 如果ttl等于null,说当前没有人获取锁,或者持有锁的是当前线程,希望重入锁,则返回true,表示获取锁成功
if (ttl == null) {
return true;
}
// 看看当前线程获取锁的剩余等待时间有没有结束,如果已经结束则直接返回false,表示获取锁失败
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// 记录当前时间
currentTime = System.currentTimeMillis();
// 这里同样也不会立即尝试重新获取锁,而是等待当前持有锁的线程释放锁之后再重新尝试获取
if (ttl >= 0 && ttl < time) {
/*
* 这里调用的方法与之前使用的await作用很像,但是这种方式是通过Latch(信号量),释放锁的线程在释放锁的同时
* 也会释放一个信号量,该方法就会尝试获取该信号量。等待获取信号量同样也有一个最大时间,这里的最大时间
* 我们使用ttl,因为这里的ttl是小于当前线程获取锁的剩余等待时间time的,ttl时间结束意味着锁已经释放,就可以开始
* 尝试获取锁,没有必要再等了
*/
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
/*
* 如果ttl>time,那么等待获取信号量的最大时间就是time的时间,因为time时间结束时,当前线程获取锁的剩余等待时间也 * 就结束了,也就没有必要再等了
*/
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
// 再判断一下获取锁的剩余等待时间有没有结束,如果结束了就返回false表示获取锁失败
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// 如果剩余等待时间仍然充足则进入下一次循环
}
} finally {
unsubscribe(subscribeFuture, threadId);
}
上述设计的最大巧妙之处就是在于通过消息订阅或信号量的机制来等待当前持有锁的线程释放锁,而不是直接使用for循环进行盲等,后者的实现方式会加大CPU负担
至此,我们也就分析完了redisson是如何实现锁重试的
之前我们提到过,如果因为业务执行时间较长导致锁超时释放,最终就会出现两个线程并行执行业务的现象,导致线程安全问题。但是Redisson的分布式锁就不用担心这种问题,这是因为Redisson在实现分布式锁时使用了一种看门狗机制,它能保证当前持有锁的线程只要执行业务,就能不断刷新锁的过期时间,进而防止出现上述问题
那么Redisson底层到底是如何实现这种机制的呢
还记得当我们之前调用tryAcquireAsync是时,如果未设置leaseTime,则执行的代码吗?如果我们未设置leaseTime,锁默认的超时时间就是看门狗的超时时间
/*
* 如果我们并未设置leaseTime,即leaseTime等于-1,则执行以下逻辑
*/
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
waitTime,
/*
* 这里为leaseTime设置一个默认值,使用的是看门狗的超时时间,而看门狗的超时时间是3000毫秒
* 也就是说如果我们不设置锁超时时间,默认30秒超时
*/
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS,
threadId,
RedisCommands.EVAL_LONG
);
/*
* onComplete表示当拿到ttlRemainingFuture之后执行的代码
* 两个参数分别为当前锁的剩余有效期和异常信息
*/
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
// 如果e不等于null说明出现了异常,则什么都不做,直接返回
if (e != null) {
return;
}
// 如果剩余有效期等于null,说明获取锁成功
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
在当前上述代码中,获取锁成功之后,执行了一个名为scheduleExpirationRenewal()
的方法,意为"计划过期续约",那这个方法到底做了什么事呢?我们可以跟进去看看
private void scheduleExpirationRenewal(long threadId) {
// 这里new了一个entry,先别管entry是干嘛的,先往下看
ExpirationEntry entry = new ExpirationEntry();
/*
* 这里的map是一个使用static final修饰的ConcurrentHashMap,
* 它的key是一个字符串,value为我们刚刚new的entry
*/
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
renewExpiration();
}
}
这里使用了getEntryName()
来获取EXPIRATION_RENEWAL_MAP
的key,那么这个getEntryName()
又是干什么的呢?我们同样可以跟进去看看
到这里我们发现,EntryName是当前类(RedissonLock)的一个成员变量,这个成员变量是由两部分拼接而成,前半部分的id是当前连接的id,后半部分的name是我们在new RedissonLock时手动传入的name。看到这里相信不少读者已经意识到了,这个EntryName其实就是当前线程获取的锁的名称。
继续分析scheduleExpirationRenewal方法
private void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
/*
* (1)这里的EXPIRATION_RENEWAL_MAP是一个使用static final修饰的ConcurrentHashMap,
* 它的key是一个字符串,这里我们使用的是当前线程获取的锁的名称作为key
* value为我们刚刚new的entry,也就是说,一个锁对应着一个entry
* (2)由于EXPIRATION_RENEWAL_MAP是静态的,也就意味着当前类(RedissonLock)的所有实例均能看到这个map,
* 而我们在使用RedissonLock时,会创造很多它的实例来获取锁对象,一个实例对应着一把锁,
* 当一个线程试图通过锁对象来获取锁时,就会在EXPIRATION_RENEWAL_MAP中插入一条以锁对象名称为key,entry为value的记录,当一个线程释放锁时,就会移除EXPIRATION_RENEWAL_MAP中以锁对象名称为key,entry为value的记录
* (3)当一个线程第一次成功获取锁时,在EXPIRATION_RENEWAL_MAP中肯定是没有以锁名为key,entry为value的记录的,那么这里使用putIfAbsent(不存在就添加)时就会以锁名为key,在EXPIRATION_RENEWAL_MAP插入一条新的entry,并且返回null;
* (4)当一个线程第N次来创建锁时(即重入锁),EXPIRATION_RENEWAL_MAP就不会执行putIfAbsent方法,而是将之前创建出来的entry返回,这样做的好处是可以确保不管这个锁将来重入几次,拿到的都是同一个entry
*/
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
/*
* 如果entry不等于null,说明当前线程重入锁,将当前线程id加入到entry之中
*/
oldEntry.addThreadId(threadId);
} else {
/*
* 如果entry为空,说明当前线程是第一次创建锁,同样将当前线程id加入到entry中,不过会多做一个renewExpiration()的动作
*/
entry.addThreadId(threadId);
renewExpiration();
}
}
这个renewExpiration()又做了什么呢?我们可以跟进其源码看看
private void renewExpiration() {
//先从map中得到entry
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
/*
* newTimeout方法有三个参数,分别为:
* TimerTask task:表示需要执行的任务
* long delay:执行任务的延时,当delay到期之后task才会去执行
* TimeUnit unit:delay的时间单位
* 返回值为Timeout,是一个定时任务
*/
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//拿出entry
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
//得到当前线程id
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
/*
* renewExpirationAsync方法会调用一段lua脚本,它做的事情只有一个:刷新当前锁的有效期为锁最大过期时间,也就是
* 重置当前线程持有锁的有效期
*/
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
/*
* 在执行完之后会调用自己,也就是递归
*/
if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
// 将任务设置到当前entry中,也就是说entry中有两个东西,一个是当前持有锁的线程id,一个就是上述定时任务
ee.setTimeout(task);
}
上述任务的执行时间我们可以看到,是internalLockLeaseTime / 3,那么这个internalLockLeaseTime又是多少呢?其实这里的internalLockLeaseTime和我们之前提到的看门狗过期时间是同一个时间,也就是30秒,也就是说这里任务的执行时间就是10秒之后,而每当我们执行一次任务之后,任务内部又会再次调用renewExpiration(),也就是说这段任务每隔10秒就会执行一次,刷新当前线程持有锁的有效期,模拟一个锁永不过期的效果,这个任务也就被称为"看门狗"
来梳理一下思路,当有一条线程第一次获取锁成功时,会在锁对象对应的entry中添加一个定时任务,这个定时任务每过十秒就会刷新一下当前锁的释放时间。
那问题来了,这个锁什么时候会过期呢?那当然是在释放锁的时候。
我们再来看一下释放锁,也就是unlock方法在RedissonLock中的源码:
@Override
public void unlock() {
try {
get(unlockAsync(Thread.currentThread().getId()));
} catch (RedisException e) {
if (e.getCause() instanceof IllegalMonitorStateException) {
throw (IllegalMonitorStateException) e.getCause();
} else {
throw e;
}
}
}
我们可以看到,这里调用了一个unlockAsync的方法,并传入了当前线程id,我们可以点进去看看:
@Override
public RFuture<Void> unlockAsync(long threadId) {
RPromise<Void> result = new RedissonPromise<Void>();
RFuture<Boolean> future = unlockInnerAsync(threadId);
future.onComplete((opStatus, e) -> {
// 取消到期更新
cancelExpirationRenewal(threadId);
if (e != null) {
result.tryFailure(e);
return;
}
if (opStatus == null) {
IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
result.tryFailure(cause);
return;
}
result.trySuccess(null);
});
return result;
}
继续跟进源码之后,我们发现,取消到期更新是通过一个cancelExpirationRenewal(threadId)方法完成的,那这个方法又做了什么事情呢,我们继续跟进:
void cancelExpirationRenewal(Long threadId) {
//从map中去获取当前锁实例对应的entry
ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (task == null) {
return;
}
// 因为这里要进行的是释放锁的操作,所以先将当前的线程Id从entry中移除
if (threadId != null) {
task.removeThreadId(threadId);
}
if (threadId == null || task.hasNoThreads()) {
//取出timeout任务
Timeout timeout = task.getTimeout();
if (timeout != null) {
//将任务取消掉
timeout.cancel();
}
//再将entry从map中移除
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
}
}
源码看到这里,整个流程也就清晰了,需要注意的是,只有我们不设置锁的过期时间,即leaseTime等于-1时,底层才会执行看门狗的逻辑,通过不断刷新当前持有锁的线程的锁释放时间来保证当前线程顺利执行完业务而不用担心锁的超时释放,那么这里可能就有人会问了:不断刷新有效期的话,那么redis宕机了,不就会出现死锁了吗,这点其实完全不用担心,因为刷新有效期正是通过执行redis命令的lua脚本做到的,如果redis服务宕机了,那么刷新有效期自然也无法做到了,锁的超时时间一到,锁就会自动释放了
锁重试和WatchDog的整个流程图示如下:
我们之前自己写的分布式锁无法保证主从一致性,在redis集群环境下,主节点负责写数据,从节点负责读数据,主节点写数据之后会将数据复制到从节点中,而我们之前写的分布式锁正是将数据保存在主节点中的。如果在主节点将锁数据同步给从节点的过程中,主节点宕机了,导致锁无法同步给从节点,Redis 的哨兵模式检测到主节点宕机,就会从从机中选择出一个节点成为新的主节点,那么其他线程就有可能趁虚而入,从新的主节点中获取到锁,这样就出现多个线程拿到多把锁,在极端情况下,可能会出现安全问题。
那么Redisson是如何解决这个问题的呢?
在Redisson中有这样一套方案,叫做MultiLock,也就是联锁,将同一把锁保存在多个redis节点中,如果某个线程需要获取锁,那就必须从指定的所有节点中获取到锁才算成功。这种情况下,即使redis中某个存储锁的节点出现宕机,其他线程也无法再次获取到锁。
我们可以将保存锁的redis节点均指定为主节点,再分别针对这些主节点进行主从同步
那么我们应该如何在Java代码中实现MultiLock呢?
其实Redisson为我们提供了相应的API,假设我们现在有三个redis节点,希望针对这三个redis节点做一个联锁,那么我们就可以按照以下步骤进行:
1 修改RedissonConfig配置,在RedissonConfig中配置三个RedissonClient的Bean
@Configuration
public class RedissonConfig {
/**
* 配置第一个RedissonClient,config中配置第一个redis节点的信息
*/
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress("redis://虚拟机ip:6379").setPassword("密码");
return Redisson.create(config);
}
/**
* 配置第二个RedissonClient,config中配置第二个redis节点的信息
*/
@Bean
public RedissonClient redissonClient2(){
Config config = new Config();
config.useSingleServer().setAddress("redis://虚拟机ip:6380").setPassword("密码");
return Redisson.create(config);
}
/**
* 配置第三个RedissonClient,config中配置第三个redis节点的信息
*/
@Bean
public RedissonClient redissonClient3(){
Config config = new Config();
config.useSingleServer().setAddress("redis://虚拟机ip:6381").setPassword("密码");
return Redisson.create(config);
}
}
2 在获取锁时使用getMultiLock方法。示例代码如下:
@Slf4j
@SpringBootTest
class RedissonTest {
// 注入第一个redissonClient
@Resource
private RedissonClient redissonClient;
// 注入第二个redissonClient
@Resource
private RedissonClient redissonClient2;
// 注入第三个redissonClient
@Resource
private RedissonClient redissonClient3;
private RLock lock;
@BeforeEach
void setUp() {
//这里要分别获取三个RedissonClient的锁,记得锁名使用同一个
RLock lock1 = redissonClient.getLock("order");
RLock lock2 = redissonClient2.getLock("order");
RLock lock3 = redissonClient3.getLock("order");
//创建联锁,使用哪个redissonClient来调用getMultiLock都无所谓
lock = redissonClient.getMultiLock(lock1, lock2, lock3);
}
@Test
void method1() throws InterruptedException {
// 尝试获取锁
boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
if (!isLock) {
log.error("获取锁失败 ....");
return;
}
try {
log.info("获取锁成功 .... ");
log.info("开始执行业务 ... ");
} finally {
log.warn("准备释放锁 .... ");
lock.unlock();
}
}
}
这样也就完成了联锁的创建。通过getMultiLock()
创建的锁对象,在获取锁时必须将我们设置的每个锁都获取到才算获取锁成功(在上述代码中也就是指lock1,lock2,lock3),而在释放锁时,也会将每个节点上的锁都释放掉。
那么联锁的底层是怎样做到的呢?我们可以跟进源码看一下
首先查看getMultiLock()方法
@Override
public RLock getMultiLock(RLock... locks) {
return new RedissonMultiLock(locks);
}
在RedissonLock类的源码中,getMultiLock方法返回了一个RedissonMultiLock类的对象,并将我们我们设置的锁做为参数传给了构造器,那么这时我们就可以去RedissonMultiLock类中去看看对应的构造器到底做了什么事
// RedissonMultiLock类的成员变量
final List<RLock> locks = new ArrayList<>();
public RedissonMultiLock(RLock... locks) {
if (locks.length == 0) {
throw new IllegalArgumentException("Lock objects are not defined");
}
//将我们传入的locks变成一个数组,加入到成员变量locks中
this.locks.addAll(Arrays.asList(locks));
}
到这里我们就懂了,getMultiLock()底层返回了一个RedissonMultiLock的对象,并将我们设置的锁保存在该对象的locks成员变量中,也就是说我们以下代码拿到的lock实际上是一个保存了lock1,lock2和lock3信息的RedissonMultiLock,已经不再是我们之前所用的 ResissonLock了。
lock = redissonClient.getMultiLock(lock1, lock2, lock3);
那么接下来我们再看看RedissonMultiLock的tryLock方法是怎样写的:
@Override
public boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException {
return tryLock(waitTime, -1, unit);
}
在这里由于我们没有指定leaseTime,方法内会将leaseTime设置为-1然后调用对应的重载形式,继续跟进看看:
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long newLeaseTime = -1;
if (leaseTime != -1) {
if (waitTime == -1) {
newLeaseTime = unit.toMillis(leaseTime);
} else {
newLeaseTime = unit.toMillis(waitTime)*2;
}
}
long time = System.currentTimeMillis();
long remainTime = -1;
if (waitTime != -1) {
remainTime = unit.toMillis(waitTime);
}
long lockWaitTime = calcLockWaitTime(remainTime);
int failedLocksLimit = failedLocksLimit();
List<RLock> acquiredLocks = new ArrayList<>(locks.size());
for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
RLock lock = iterator.next();
boolean lockAcquired;
try {
if (waitTime == -1 && leaseTime == -1) {
lockAcquired = lock.tryLock();
} else {
long awaitTime = Math.min(lockWaitTime, remainTime);
lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
}
} catch (RedisResponseTimeoutException e) {
unlockInner(Arrays.asList(lock));
lockAcquired = false;
} catch (Exception e) {
lockAcquired = false;
}
if (lockAcquired) {
acquiredLocks.add(lock);
} else {
if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
break;
}
if (failedLocksLimit == 0) {
unlockInner(acquiredLocks);
if (waitTime == -1) {
return false;
}
failedLocksLimit = failedLocksLimit();
acquiredLocks.clear();
// reset iterator
while (iterator.hasPrevious()) {
iterator.previous();
}
} else {
failedLocksLimit--;
}
}
if (remainTime != -1) {
remainTime -= System.currentTimeMillis() - time;
time = System.currentTimeMillis();
if (remainTime <= 0) {
unlockInner(acquiredLocks);
return false;
}
}
}
if (leaseTime != -1) {
List<RFuture<Boolean>> futures = new ArrayList<>(acquiredLocks.size());
for (RLock rLock : acquiredLocks) {
RFuture<Boolean> future = ((RedissonLock) rLock).expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS);
futures.add(future);
}
for (RFuture<Boolean> rFuture : futures) {
rFuture.syncUninterruptibly();
}
}
return true;
}
我们先来分析这部分代码:
//这是代码在接下来的编写中真正会去考虑的过期时间
long newLeaseTime = -1;
//如果我们设置了锁的默认过期时间,则执行以下逻辑
if (leaseTime != -1) {
//判断是否设置了获取锁的等待时间
if (waitTime == -1) {
//如果我们没有设置锁的等待时间,那么就将锁的过期时间设置为我们给定的
newLeaseTime = unit.toMillis(leaseTime);
} else {
/*
* 如果我们设置了锁的等待时间,那就将锁的过期时间设置为锁的等待时间*2,而不会使用我们给定的释放时间
* 为什么要这样设计呢?因为锁的等待时间可能比较久,万一我们给定的过期时间小于等待时间的话就容易出现问题
*/
newLeaseTime = unit.toMillis(waitTime)*2;
}
}
//记录当前时间
long time = System.currentTimeMillis();
//remainTime表示锁的剩余等待时间
long remainTime = -1;
//如果我们给定了锁的等待时间,那么就会将锁的剩余等待时间给定为我们指定的时间
if (waitTime != -1) {
remainTime = unit.toMillis(waitTime);
}
/*
* 这里的lockWaitTime是锁等待时间,这里调用了一个calcLockWaitTime方法,这个方法实际上就是将我们传入的remainTime原封不动的
* 返回,这就是说这行代码的意义就是将锁的等待时间设置为锁的剩余等待时间
*/
long lockWaitTime = calcLockWaitTime(remainTime);
/*
* failedLocksLimit表示容许接受的失败的锁的上限,failedLocksLimit()返回了一个数字0,也就是说failedLocksLimit的值为零,
* 这个参数将会在下面的代码被中用到
*/
int failedLocksLimit = failedLocksLimit();
上面的代码做的操作都是一些参数的封装,我们再来继续分析下面的代码
// acquiredLocks表示获取成功的锁,它的大小为locks.size(),locks我们之前见过,它里面保存了所有redis节点上面的锁
List<RLock> acquiredLocks = new ArrayList<>(locks.size());
/*
* 开始尝试获取锁,循环变量为locks的迭代器,循环结束条件为iterator.hasNext();
*/
for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
//获取锁
RLock lock = iterator.next();
//该变量用于记录该锁有没有获取成功
boolean lockAcquired;
try {
if (waitTime == -1 && leaseTime == -1) {
/*
* 当我们没有设置waitTime和leaseTime时,执行的就是lock空参的tryLock()方法
* 注意这里的lock是RedissonLock的对象
*/
lockAcquired = lock.tryLock();
} else {
/*
* 当我们设置了waitTime和leaseTime时,lock获取锁的等待时间使用的是lockWaitTime和remainTime中的较小值
* 也就是锁等待时间和锁剩余等待时间的中的较小值,然后调用tryLock满参的重载形式
*/
long awaitTime = Math.min(lockWaitTime, remainTime);
lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
}
} catch (RedisResponseTimeoutException e) {
unlockInner(Arrays.asList(lock));
lockAcquired = false;
} catch (Exception e) {
lockAcquired = false;
}
//判断获取锁有没有成功
if (lockAcquired) {
//如果成功了,就将当前锁加入到获取成功的锁的集合中
acquiredLocks.add(lock);
} else {
/*
* 如果获取失败了,会先去判断需要获取的锁的总数减去已经成功获取的总数是否等于failedLocksLimit(),而这个
* failedLocksLimit我们之前介绍过,是容许接受的失败的锁的上限,也就是说锁的总数减去已经成功获取的总数即
* 还未获取的锁的数量一旦达到failedLocksLimit,当前循环直接结束。而这里failedLocksLimit的值是零,
* 所以除非获取到了所有的锁,否则不会执行该判断中的break逻辑,这段代码在这里的意义也就变成了:当我们获取到所有的锁之 * 后,直接结束当前循环,但是既然已经执行到了这里,就说明当前已经有锁获取失败了
*/
if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
break;
}
if (failedLocksLimit == 0) {
// 如果failedLocksLimit为零,则直接将当前acquiredLocks集合中所有锁释放掉,因为联锁必须要全部拿到才有意义
unlockInner(acquiredLocks);
// 如果锁等待时间为-1,也就是不重试,则直接返回获取锁失败的信息
if (waitTime == -1) {
return false;
}
//如果设置了等待时间,就进行如下操作:
//将failedLocksLimit重置为0
failedLocksLimit = failedLocksLimit();
//清空获取成功的锁的集合
acquiredLocks.clear();
//将迭代器的指针重置,准备尝试重新遍历
while (iterator.hasPrevious()) {
iterator.previous();
}
} else {
failedLocksLimit--;
}
}
// 判断剩余等待时间是不是-1
if (remainTime != -1) {
// 如果不为-1,则将剩余等待时间减去执行上述操作锁消耗的时间,得到一个新的剩余等待时间
remainTime -= System.currentTimeMillis() - time;
//重新记录当前时间,为下一次循环做准备
time = System.currentTimeMillis();
/*
* 如果剩余等待时间已经小于等于零,则返回false表示获取锁失败
*/
if (remainTime <= 0) {
// 在返回false之前,还会将acquiredLocks已经存在的锁都释放掉,因为联锁必须要全部获取成功才有意义
unlockInner(acquiredLocks);
return false;
}
}
//进入下一次for循环,直到把所有的锁都拿到为止
}
继续分析以下代码,需要说明的是,如果能执行到以下代码,说明当前所有的锁都已经获取成功了
//判断当前锁是否设置了过期时间
if (leaseTime != -1) {
List<RFuture<Boolean>> futures = new ArrayList<>(acquiredLocks.size());
/*
* 遍历已经拿到的每一把锁,然后为这些锁重新设置一下有效期,因为集合中的锁在被获取到时就已经开始倒计时了,而有些锁先获取到,
* 有些锁后获取到,这样就会出现有些锁先释放有些所后释放的问题
* 而为什么leaseTime = -1时就不需要管呢?这是因为当leaseTime = -1时是会触发看门狗机制的
*/
for (RLock rLock : acquiredLocks) {
RFuture<Boolean> future = ((RedissonLock) rLock).expireAsync(
unit.toMillis(leaseTime),
TimeUnit.MILLISECONDS
);
futures.add(future);
}
for (RFuture<Boolean> rFuture : futures) {
rFuture.syncUninterruptibly();
}
}
//获取锁成功,返回
return true;
这样我们也就分析完了MultiLock的实现原理了