前言:
在上篇博客中,我们探讨了单机模式下如何通过悲观锁(synchronized)实现"一人一单"功能。然而,在分布式系统或集群环境下,单纯依赖JVM级别的锁机制会出现线程并发安全问题,因为这些锁无法跨JVM生效。
(详情请参考我的上一篇博客:Redis实战-单机项目下实现优惠劵秒杀【万字长文】)
那么,在分布式环境下如何正确实现"一人一单"功能呢?本文将深入探讨分布式锁的实现方案,重点介绍基于Redis和Lua脚本的分布式锁实现,并分析Redission框架的源码实现。
今日所学:
- 分布式锁原理及其实现
- redis分布式可能造成的误删问题
- 引入lua脚本解决多条命令的原子性问题
- Redisson源码解析
1.分布式锁原理及其实现
1.1 什么是分布式锁
分布式锁是一种在分布式系统中用于协调多个节点或进程对共享资源进行互斥访问的机制。它的核心目标是确保在分布式环境下,同一时间只有一个节点能够执行关键操作(如修改共享数据、访问数据库等),避免并发导致的数据不一致问题。
一句话总结:
分布式锁是在分布式系统中协调多个节点对共享资源访问的一种同步机制。
同俗一点解释就是,synchronized
只能管住自己JVM 里的线程,而Redis分布式锁相当于一个"全局管理员",能管住所有JVM的线程,让它们在集群里排队用资源。就好比单机游戏(只能在本机内存档)和网游游戏的区别(所有电脑都可以读取存档)

分布式锁的特性有:
- 互斥性:同一时刻只能有一个节点持有锁。
- 可重入性:同一个节点多次请求锁时能够成功(避免死锁)。
- 超时释放:锁需设置超时时间,防止节点崩溃后锁无法释放。
- 高可用性:锁服务需具备容错能力,避免单点故障。
- 高性能:获取和释放锁的操作应高效。

1.2 常见的分布式锁
常见的分布式锁有三种
Mysql:mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见
Redis:redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
Zookeeper:zookeeper也是企业级开发中较好的一个实现分布式锁的方案,由于黑马的那套视频并不讲解zookeeper的原理和分布式锁的实现,所以不过多阐述

这里我们使用redis作为分布式锁。
1.3 redis分布式锁的实现
1.3.1 核心思路:
分布式锁应该说但凡是锁都要实现的两个基本方法:
1.获取锁:
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
- 释放锁:
- 手动释放
- 超时释放:获取锁时添加一个超时时间
那么我们改如何去实现呢,redis中有什么指令或者说方法可以满足这两点呢?
我们先说获取锁,核心思路是我们利用redis 的setNx 方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的哥们,等待一定时间后重试即可

想通了这个,释放锁就非常简单了,要么等业务执行完自动删除,要么就是服务宕机或者业务执行时间过长到达锁的超时时间,锁自动删除。
最后问题就变成了如何设置一个指令(只能一条指令以保证原子性),既能实现setnx保证锁的互斥性,同时增加过期时间,防止死锁。
1.3.2 代码实现:
1.获取锁(util包下SimpleRedisLock类)
逻辑思路很简单:
1.先获取线程标识
2.获取锁(等同于redis中的SET lock_key unique_value NX PX 30000,创建一个redis字段)nx等同于setnx代表互斥,ex设置过期时间,一条指令保证了原子性。
3.根据返回的boolean确定是否获取锁成功(成功返回1,失败返回0),这边使用Boolean.TRUE是为了防止Boolean包装类有返回null的情况
private static final String KEY_PREFIX="lock:"
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = Thread.currentThread().getId()
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
2.释放锁(utils下SimpleRedisLock类):
要释放锁时给相应的redis锁删除就行
public void unlock() {
//通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
最后在service层下的VoucherOrderServiceImpl类下修改相应的代码:
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//创建锁对象(新增代码)
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁对象
boolean isLock = lock.tryLock(1200);
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
2.redis分布式锁可能造成的误删问题
2.1 问题分析
我们假设持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明

2.2 解决思路
解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,假设还是上边的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁
2.3 需求分析
修改之前的分布式锁实现,满足:
在获取锁时存入线程标示(可以用UUID表示,在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
-
如果一致则释放锁
-
如果不一致则不释放锁
核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除
2.4 代码实现
在util包下SimpleRedisLock类中,对代码进行修改
具体修改逻辑:
1.生成一个静态常量UUID随机数
2.在加锁操作中,将生成的随机数与进程编号拼接,得到一个唯一的进程ID(防止多进程下编号相重)
2.在释放锁那,从锁中获取value值,并跟线程标识进行比较,如果相同。则是自己的锁,可以删除,如果不同,则不能删除
加锁
```java
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
```
释放锁
```java
public void unlock() {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
3. 引入lua脚本解决多条命令的原子性问题
3.1 极端情况说明
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,**而且已经走到了条件判断的过程中,也就是确定这把锁就是自己的了。**但是此时线程1出现了阻塞,锁超时,线程2获得锁,执行业务,就在此时,线程1停止阻塞,执行删除操作(因为已经走过了判断过程),线程2的锁被删除。线程3获得锁和线程2并发执行。而导致这的原因是因为判断和执行删除是两个操作,没能实现操作的原子性。
3.2 lua脚本解决原子性问题思路
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html,这里重点介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作了。
3.3 lua语言redis调用
这里重点讲下如何在lua语言中调用redis,语法如下:
```lua
redis.call('命令名称', 'key', '其它参数', ...)
```
例如,我们要在lua语言中执行set name jack,脚本是这样的
redis,call('set', 'name', 'jack')
在比如,我们要先执行set name Rose, 再执行 get name,则脚本如下:
先执行set name Rose
redis.call('set', 'name', 'Rose')
再执行 get name
local name = redis.call('get', 'name')
返回
return name
注意这里lua是一门弱语言(跟某py一样),所以没有什么变量类型只说,全局变量就加个local
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
EVAL script numkeys [keys] arg[arg...]
比如说要调用set name jack 这个脚本
eval 'return redis.call('set', 'name', 'jack')' 0
这个的0代表这传入了0个KEYS参数,'name'和'jack'作为普通字符传入(就相当于传入常量,不设变量)
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数
EVAL"return redis.call('set', KEYS[1], ARGV[1])" 1 name Rose
其中1代表着传入的KEYS的参数的数量,比如这条语句因为传入的参数是1,所以KEYS[1]数组长度为1,name作为key,其他的都作为ARGV参数(当然,这里只传入了一个Rose,所以ARGV[1]长度也为1)
3.4 代码实现
接下来我们来回顾一下我们释放锁的逻辑:
释放锁的业务流程是这样的
- 获取锁中的线程标示
- 判断是否与指定的标示(当前线程标示)一致
- 如果一致则释放锁(删除)
- 如果不一致则什么都不做
如果用Lua脚本来表示则是这样的:
1.获取Java中传入的key和线程标识
- 通过key从redis锁中获取线程标识value
3.比较,如果相同,则删除,不然返回0表示false
-- 锁的key local key = KEYS[1] -- 当前线程标识 local threadId = ARGV[1] -- 获取锁中的线程标识 get key local id = redis.call('get', key) -- 比较线程标示与锁中的标识是否一致 if(id == ARGV[1]) then -- 释放锁 del key return redis.call('del', KEYS[1]) end return 0
lua脚本写好了,那么怎么在Java中调用呢
我们的RedisTemplate中,可以利用execute方法去执行lua脚本,参数对应关系就如下图

具体代码如下(大致看懂什么意思就行):
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
这里可以看到,经过我们的改造,原本几行代码被我们改为1行,使用lua脚本,我们就能够实现 拿锁和锁删锁的原子性动作了。
3.5 总结
基于Redis的分布式锁实现思路:
- 利用set nx ex获取锁,并设置过期时间,保存线程标示
- 释放锁时先判断线程标示是否与自己一致,一致则删除锁
特性:
- 利用set nx满足互斥性
- 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
- 利用Redis集群保证高可用和高并发特性
做到这,我们先是setnx, ex(防止死锁)实现了一个简单的分布式锁,然后为了解决误删问题,我们又引入了线程标识作为评判标准,但是只是在Java代码中加入if()判断的话,仍可能出现线程安全问题(不是原子性的),因此我们又引入了lua脚本来解决这个问题。
但是问题来了,引入ex设置过期时间是解决了死锁问题,但是也造成了另一个问题:时间不好把握,如果业务执行时间过长,还有执行完所就给释放了怎么办,这就是所谓的锁不住问题,为了解决这个问题,我们需要一种机制,当过期时间到了但是业务没有执行完,我们可以刷新下它的过期时间,相当于给它续费。
4. Redission框架介绍
4.1 setnx锁存在的问题
我们说下使用setnx使用分布式锁存在的几个问题:
1.重入问题
重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的
2.不可重试
是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
- 超时释放
我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患。
- 主从一致性
如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。

那么如何解决这些问题呢?下面我们就来介绍一个框架Redission
4.2 Redission介绍
什么是Redission呢?
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
一句话总结:
Redisson 是一个基于 Redis 的高级 Java 分布式服务框架,提供分布式锁、数据结构、远程服务等企业级功能,大幅简化分布式系统开发。
Redission提供了分布式锁的多种多样的功能。

4.3 Redission快速入门
- 在pom.xml引入相应依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
2.在config包下配置相应的Redission客户端
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.150.101:6379")
.setPassword("123321");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
3.进行测试(test包下新建一个RedissionTest类)
1.创建锁,指定锁的名称
2.尝试获取锁,传入最大等待时间(用于重试),锁的自动释放时间,时间单位
- 判断是否获取锁成功
4。业务执行完释放锁
@Resource
private RedissionClient redissonClient;
@Test
void testRedisson() throws Exception{
//创建锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
//判断获取锁成功
if(isLock){
try{
System.out.println("执行业务");
}finally{
//释放锁
lock.unlock();
}
}
}
- 测试成功,更改service包下VoucherOrderServiceImpl类相应的逻辑
主要更改有两点:
1.依赖注入,注入相应的redissionClient类
2.给自定义的simpleRedisLock类创建锁对象改成使用redissionClient自带的内置方法创建锁对象
@Resource private RedissonClient redissonClient; /** * 抢购优惠卷 * @param voucherId * @return */ @Override public Result seckillVoucher(Long voucherId) { // 1.查询优惠券 SeckillVoucher voucher = seckillVoucherService.getById(voucherId); // 2. 判断秒杀是否开始 if(voucher.getBeginTime().isAfter(LocalDateTime.now())){ // 尚未开始 return Result.fail("秒杀尚未开始"); } // 3.判断秒杀是否结束 if(voucher.getEndTime().isBefore(LocalDateTime.now())){ return Result.fail("秒杀已经结束"); } // 4. 判断库存是否充足 if(voucher.getStock() < 1){ // 库存不足 return Result.fail("库存不足"); } Long userId = UserHolder.getUser().getId(); // 创建锁对象 //SimpleRedisLock simpleRedisLock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId); RLock simpleRedisLock = redissonClient.getLock("lock:order:" + userId); // 获取锁 boolean isLock = simpleRedisLock.tryLock(); // 判断是否获取锁成功 if(!isLock){ // 获取锁失败, 返回错误或者重试 return Result.fail("一个人只能下一单"); } try { IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); } catch (IllegalStateException e) { e.printStackTrace(); } finally { simpleRedisLock.unlock(); } return null; }
5. Redission源码解析
首先,我们回顾下setnx存在的几个问题,并逐一对这几个问题进行解决。

5.1 hash结构解决不可重入问题
通过设置一个hash结构,让filed储存线程标识,value储存重入次数。
利用唯一Key保证互斥性 (不同线程竞争时只有一个能成功创建锁,exists判断),同时通过Field记录线程ID+Value计数实现可重入(同一线程多次获取锁时计数器+1)。底层使用Lua脚本保证「判断存在→设置锁→设置过期时间」的原子性操作,既防止并发冲突,又支持锁的重入,最终通过「计数器归零删除Key」来安全释放锁。
-- 公共变量 local key = KEYS[1] 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是否是自己的 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;
-- 解锁
-- 判断当前锁是否还是被自己锁持有 if (redis.call('HINCRBY', key, threadId) == 0) then return nil; -- 如果不是自己的,直接返回 end -- 是自己的锁,则重入次数-1 local count = redis.call('HINCRBY', key, threadId, -1); -- 判断是否冲入次数已经为零 if (count > 0) then -- 大于0说明不能释放锁 redis.call('expire', key, releaseTime); return nil; else redis.call('del', key); return nil; end
接下来我们打开Redission源码(RLock下实现类RedissionLock tryLockInnerAsync方法)


在tryLockInnerAsync方法下可以看到如出一辙的加锁模式:
1.判断锁是否存在,不存在创建锁
2.存在并且是同一线程则value值加一,并刷新过期时间
3.如果锁存在并且不是为当前线程所持有(没有进行if语句),则返回当前锁的剩余时间,也就是锁到什么时候过期,以此后续确定是否重试。

5.2 信号量和Pubsub机制解决不可重试问题
不可重试问题,主要指的是锁竞争失败后无法自动重试。在Redission框架中,这主要依靠异步等待+订阅发布机制解决
这里先介绍下什么是异步等待:
异步等待是一种非阻塞的资源竞争处理机制,其核心特点是:当资源不可立即获取时,不会阻塞当前线程,而是通过事件监听或回调机制,在资源可用时自动恢复执行。这种模式在分布式系统和高并发场景中至关重要。
比如在解决不可重试问题时,使用异步等待,如果不成功,不会陷入阻塞,而是取执行查看订阅信息频道,以此进行超时判断或者重试

1.回到Redission的源码,找到tryAcquireAsync方法
leaseTime代表着等待超时时间,如果设置了等待超时时间则用自己的超时时间,不然启用看门狗机制自动续约(这个后面会讲),我们现在主要就重试问题进行分析。

在重入问题中,我们讲过如果获取到了锁,就返回null,没有获取到锁,返回当前线程的剩余时间

这个方法中返回的结果记录为ttlRemainingFuture

如果为ttl为null的话记录等待超时时间或启用看门狗机制,否则继续返回剩余时间。

下面是完整代码:
private RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) { RFuture<Long> ttlRemainingFuture; if (leaseTime > 0) { ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG); } else { ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG); } CompletionStage<Long> s = handleNoSync(threadId, ttlRemainingFuture); ttlRemainingFuture = new CompletableFutureWrapper<>(s); CompletionStage<Long> f = ttlRemainingFuture.thenApply(ttlRemaining -> { // lock acquired if (ttlRemaining == null) { if (leaseTime > 0) { internalLockLeaseTime = unit.toMillis(leaseTime); } else { scheduleExpirationRenewal(threadId); } } return ttlRemaining; }); return new CompletableFutureWrapper<>(f); }
2.ttl值通过get()同步传递到tryAcquire方法,trylock方法通过调用tryAcquire执行重试机制。

这边注意不是lock方法是trylock方法。
接下来我们来到trylock方法,传入等待重试时间(waittime),等待超时时间(锁持有时间leaseTime),还有时间单位。
ttl(当前锁的剩余时间,也就是锁到什么时候过期)时间由tryAcquire方法获取,底层逻辑就是获取锁的那段lua脚本

如果ttl为null的话,表示成功获取到锁,直接返回

否则看是否超时,执行尝试获取锁的线程后的时间(system.current)减去执行前的时间(current/waitime),如果小于零,表示超过等待重试时间**,不再重试**,直接返回

如果没有拿到锁并且还剩下时间,则向redis发送订阅消息,监听该锁的释放事件,并等待订阅完成(让线程进入阻塞等待状态)
做完后在检测waitTime剩余时间,如果小于零,不在重试,直接返回

否则进入while循环,做4件事
1.尝试获取锁

2.查看waitTime剩余时间,判断是否超时

3.确定最长阻塞等待时间,这里的if对应两种情况
- ttl(锁的剩余时间)在waitTime剩余时间之内,阻塞等待最长时间设置为ttl
- 如果不在,则阻塞等待最长时间设置为waitTime剩余时间(超出时间取消订阅)
然后执行阻塞等待,期间可能被两种事件唤醒:
- 超时通知(到达时间超过ttl或者time的时间限制)
- 锁释放通知(持有锁线程通过Pubsub(订阅信息通道)发送信息)

4.判断剩余时间

最后while循环结束不管没有没拿到锁,都要去取消订阅

最后流程图:

总结下:
Redission利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
5.3 Watchdog机制解决超时释放问题
什么是WatchDog机制?
Redisson的看门狗机制(Watchdog)是一种用于自动续期分布式锁的机制,主要解决客户端在持有锁期间因业务执行时间过长导致锁超时释放的问题。该机制通过后台线程定期检查并延长锁的持有时间,确保锁的安全性。
一句话总结,Watchdog机制通过一种定时续约逻辑来解决分布式锁过期的问题。
来到Redission源码,一样是在RedissionLock类中,回到tryAcquireOnceAsync方法,之前我们在讲解决不可重试问题时讲过,锁的等待超时(也就是过期时间leaseTime)可以设可以不设,如果设置的话,就使用自己设置的过期时间,如果没有设置过期时间,就走看门狗(watchdog)机制。

我们看到如果没有设置leaseTime,源码传达的时nternalLockLeaseTime,它的初始化是这样的:

进入getLockWatchdogTimeout()方法
发现return 了一个lockWatchdogTimeout,这个一个long类型的变量,值为30 * 1000

由此我们知道,采用看门狗机制它的初始锁的超时释放时间设置的是30s,那么他是如何做到自动续时的呢,我们往后看。

可以看到,红框部分同样有个判断,如果设置了leaseTime,则将其转换成毫秒单元。如果没有,则走scheduleExpirationRenewal()方法
进入scheduleExpirationRenewal()方法
当线程成功获取锁后,会调用此方法,执行以下逻辑:
1.尝试为当前锁新建一个续期条目,将当前线程ID加入其中
2.判断该锁是否已经有续期条目(确保同一个锁的多个线程共享同一个续期任务,防止重复续期)
3.如果已经存在,只需要将当前线程ID添加到该锁现有条目中
4.如果是新条目,执行续期任务(renewExpiration())
5.最后,如果线程被中断,取消续期

换言之,对于这个方法,他主要做两件事:
- 对已有续期条目的锁,给新的线程ID添加进去(续期任务已开启,满足相应条件自动续期)
- 对没有续期条目的锁,创建新的续期条目,开启续期任务
那么续期任务是具体怎么执行的呢?我们打开rennewExpiration()方法
1.获取当前的续期条目,如果没有,直接返回(代表着锁被释放或者未初始化)

2.创建一个定时任务
其中internalLockLeaseTime默认为30s,也就是每10s默认执行一次定时任务

3.进入到定时任务的具体逻辑中,二次检查是否存在相应的续约条目和线程

4.调用renewExpirationAsync方法,更新锁的过期时间,执行续约,返回一个boolean值

下面是renewExpirationAsync具体逻辑:
可以看到执行lua脚本实现过期时间的更新。

5.先是进行异常判断,然后根据传递的boolean值(lua脚本的执行结果)判断是否进行递归(继续每10s进行续约)还是取消续约任务。

具体执行流程:

5.4 multiLock解决主从一致性问题
原有锁存在问题:
redis集群主节点获得锁后可能立即宕机,没有及时给数据同步从节点
解决方法:
设立多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功

这样做,此时就算假设此时有一个主节点宕机,其他线程趁虚而入获得那个节点的锁,只要没有获得其他所有主节点的锁,也是获取失败的。
最后:
今天的分享就到这里。如果我的内容对你有帮助,请点赞 ,评论 ,收藏。创作不易,大家的支持就是我坚持下去的动力!(๑`・ᴗ・´๑)