在分布式系统中,当不同进程或线程一起访问共享资源时,会造成资源争抢,如果不加以控制的话,就会引发程序错乱。而分布式锁它采用了一种互斥机制来防止线程或进程间相互干扰,从而保证了数据的一致性。
**Redission是一个基于Redis实现的Java分布式对象存储和缓存框架。它提供了丰富的分布式数据结构和服务。**例如:分布式锁、分布式队列、分布式Rate Limiter等。
Redission是基于Redis的,因此它继承了Redis的高性能和低延迟的特性。同时,它采用了Netty的NIO框架,能够并发地处理大量的请求,使得应用程序的响应速度得到了极大的提升。
Redission提供了丰富的API和方法,同时还提供了文档和示例,让开发者易于上手和使用。此外,它支持自动配置和灵活的配置方式,使得开发者可以根据自己的需求进行配置和调整。
Redission的分布式架构使得它支持水平扩展,可以将数据和请求分散到更多的节点上进行处理。这也使得它具备了更好的容错能力和可靠性。
Redission支持不同的数据存储类型,例如:String、List、Set、Map、BloomFilter、HyperLogLog等,它可以将这些数据存储在Redis中,以实现数据缓存的功能,从而提高应用程序的性能和响应速度。
Redission提供了可重入锁、公平锁等常用的分布式锁,还支持异步执行、锁的自动续期、锁的等待等特性。
Redission提供了分布式队列的实现,我们可以在不同的节点之间快速、可靠地实现任务的传递和处理。
Redis 分布式锁常用命令如下所示:
- SETNX key val:仅当key不存在时,设置一个 key 为 value 的字符串,返回1;若 key 存在,设置失败,返回 0;
- Expire key timeout:为 key 设置一个超时时间,以 second 秒为单位,超过这个时间锁会自动释放,避免死锁;
- DEL key:删除 key。
上述 SETNX 命令相当于抢占锁操作,EXPIRE 是为避免出现意外用来设置锁的过期时间,也就是说到了指定的过期时间,该客户端必须让出锁,让其他客户端去持有。
但还有一种情况,如果在 SETNX 和 EXPIRE 之间服务器进程突然挂掉,也就是还未设置过期时间,这样就会导致 EXPIRE 执行不了,因此还是会造成“死锁”的问题。为了避免这个问题,Redis 作者在 2.6.12 版本后,对 SET 命令参数做了扩展,使它可以同时执行 SETNX 和 EXPIRE 命令,从而解决了死锁的问题。
直接使用 SET 命令实现,语法格式如下:
SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
- EX second:设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
- PX millisecond:设置键的过期时间为毫秒。SET key value PX millisecond 效果等同于 PSETEX key millisecondvalue 。
- NX:只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
- XX:只在键已经存在时,才对键进行设置操作。
使用redisson实现分布式锁的操作步骤,三部曲
注意:
以上实现步骤考虑到了使用分布式锁需要考虑的互斥性、防死锁、加锁和解锁必须为同一个进程等问题,但是锁的续期无法实现。所以通常情况都是采用 Redisson 实现 Redis 的分布式锁,借助 Redisson 的 WatchDog 机制 能够很好的解决锁续期的问题。
Watch Dog 机制其实就是一个后台定时任务线程,获取锁成功之后,会将持有锁的线程放入到一个
RedissonLock.EXPIRATION_RENEWAL_MAP
里面,然后每隔 10 秒(internalLockLeaseTime / 3)
检查一下,如果客户端 1 还持有锁 key(判断客户端是否还持有 key,其实就是遍历EXPIRATION_RENEWAL_MAP
里面线程 id 然后根据线程 id 去 Redis 中查,如果存在就会延长 key 的时间),那么就会不断的延长锁 key 的生存时间。
加锁其实是通过一段 lua 脚本实现的。这里 KEYS[1]
代表的是你加锁的 key,比如你自己设置了加锁的那个锁 key 就是 “myLock”。
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]);"
ARGV[1]
代表的是锁 key 的默认生存时间,默认 30 秒。ARGV[2]
代表的是加锁的客户端的 ID,类似于下面这样:285475da-9152-4c83-822a-67ee2f116a79:52。至于最后面的一个 1 是为了后面可重入做的计数统计.
第一段 if 判断语句,就是用 exists myLock
命令判断一下,如果你要加锁的那个锁 key 不存在的话,你就进行加锁。如何加锁呢?使用 hincrby
命令设置一个 hash 结构。接着会执行 pexpire myLock 30000
命令,设置 myLock 这个锁 key 的生存时间是 30 秒。到此为止,加锁完成。
第二个 if 判断,判断一下,myLock 锁 key 的 hash 数据结构中,是否包含客户端 2 的 ID,这里明显不是,因为那里包含的是客户端 1 的 ID。所以,客户端 2 会执行:
return redis.call('pttl', KEYS[1]);
返回的一个数字,这个数字代表了 myLock 这个锁 key 的剩余生存时间。
主要用于锁续费服务。只要客户端 1 一旦加锁成功,就会启动一个 Watch Dog。也就是说leaseTime
必须是 -1 才会开启 Watch Dog 机制,也就是如果你想开启 Watch Dog 机制必须使用默认的加锁时间为 30s。
当锁key已经存在,第二个 if 判断会成立,因为 myLock 的 hash 数据结构中包含的那个 ID 即客户端 1 的 ID,此时就会执行可重入加锁的逻辑,
redisson_lock__channel
publish 一条 UNLOCK_MESSAGE
信息)。RedissonLock.EXPIRATION_RENEWAL_MAP
里面的线程 id 删除,并且 cancel 掉 Netty 的那个定时任务线程。 重点主要是依赖lua脚本的原子性,实现加锁和释放锁的功能。
**Thread.currentThread().interrupt()。**当发生异常,则通知线程进行中断并释放锁。
lockInterruptibly(-1, null); 此环节会先获取当前线程的线程ID,之后会尝试获取锁的剩余时间。
如果当前锁的剩余时间为null,说明没有线程持有该锁,直接返回让当前线程加锁成功。如果当前线程的剩余时间不为null,就会一直尝试获取锁。
Redisson 提供了一个续期机制, 只要客户端 1 一旦加锁成功,就会启动一个 Watch Dog。**也就是说leaseTime
必须是 -1 才会开启 Watch Dog 机制,也就是如果你想开启 Watch Dog 机制必须使用默认的加锁时间为 30s。**如果你自己自定义时间,超过这个时间,锁就会自定释放,并不会延长。
**tryAcquireAsync(leaseTime, unit, threadId)(自旋获取锁的方法)。**其内部专门创建异步任务用于尝试获取锁。其任务内部会注册监听事件,当剩余时间为null,就会再次去获取锁,并给锁延长过期时间。
tryLockInnerAsync 其内部实现是lua脚本。
scheduleExpirationRenewal(final long threadId)。又是lua脚本 判断是否存在,存在就调用pexpire
@Override
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();
// 1.尝试获取锁
Long ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
// 申请锁的耗时如果大于等于最大等待时间,则申请锁失败.
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
current = System.currentTimeMillis();
/**
* 2.订阅锁释放事件,并通过 await 方法阻塞等待锁释放,有效的解决了无效的锁申请浪费资源的问题:
* 基于信息量,当锁被其它资源占用时,当前线程通过 Redis 的 channel 订阅锁的释放事件,一旦锁释放会发消息通知待等待的线程进行竞争.
*
* 当 this.await 返回 false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败.
* 当 this.await 返回 true,进入循环尝试获取锁.
*/
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
// await 方法内部是用 CountDownLatch 来实现阻塞,获取 subscribe 异步执行的结果(应用了 Netty 的 Future)
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(threadId);
return false;
}
try {
// 计算获取锁的总耗时,如果大于等于最大等待时间,则获取锁失败.
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
/**
* 3.收到锁释放的信号后,在最大等待时间之内,循环一次接着一次的尝试获取锁
* 获取锁成功,则立马返回 true,
* 若在最大等待时间之内还没获取到锁,则认为获取锁失败,返回 false 结束循环
*/
while (true) {
long currentTime = System.currentTimeMillis();
// 再次尝试获取锁
ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
// 超过最大等待时间则返回 false 结束循环,获取锁失败
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
/**
* 6.阻塞等待锁(通过信号量(共享锁)阻塞,等待解锁消息):
*/
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
//如果剩余时间(ttl)小于wait time ,就在 ttl 时间内,从Entry的信号量获取一个许可(除非被中断或者一直没有可用的许可)。
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
//则就在wait time 时间范围内等待可以通过信号量
getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
// 更新剩余的等待时间(最大等待时间-已经消耗的阻塞时间)
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
}
} finally {
// 7.无论是否获得锁,都要取消订阅解锁消息
unsubscribe(subscribeFuture, threadId);
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
release()
方法会被调用,此时被信号量阻塞的等待队列中的一个线程就可以继续尝试获取锁了。特别注意:
以上过程存在一个细节,这里有必要说明一下,也是分布式锁的一个关键点:当锁正在被占用时,等待获取锁的进程并不是通过一个
while(true)
死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题。
加锁、解锁环节放在事务注解修饰的方法内部
lock上锁-》开启事务-》查询释放满足条件(业务方面)-》执行业务处理-》unlock解锁-》提交事务
将加锁解锁的代码 放在事务注解修饰的方法的外层就可以了
主从复制架构,在主节点异步同步从节点过程中,主节点宕机,那么分布式锁的数据就会丢失,即使后续从节点顶上,也是未加锁状态,也是可以被其他线程抢到的。
1、在并发场景不严重的情况下,可以选择Zookeeper方案
2、在释放锁环节,抛出异常那里,添加数据回溯处理。若是释放锁失败,说明当前锁处于无锁状态或者被别的线程拿到锁(主从同步宕机),那么当前数据的操作就处于无效状态,则需要进行回撤处理。
SET mylock userId NX PX milliseconds
+ lua 实现的效果更好些,虽然基本原理都一样,但是它帮我们屏蔽了内部的执行细节。1、不能使用自定义过期时间,不然会影响Redisson无法正常续费问题。
整体来说,使用Redisson可以很简单的集成到项目中,并且使用方便,性能非常好,可以有效解决争取共享资源有序问题。