基于Redis的setnx实现分布式锁的缺点
在上篇文章Redis实现分布式锁 - 掘金 (juejin.cn)中,说到基于Redis的setnx实现分布式锁,使用lua脚本解决原子性问题,但是setnx实现的分布式锁仍然存在下面的问题
-
重入问题
- 锁的可重入性是指获得锁的线程在持有锁的期间,可以再次获得锁
- 可重入锁的意义在于防止死锁
- 如果锁不可重入,在一个线程内,方法一和方法二都需要获取锁,并且在方法一内调用了方法二,那么此时将会发生死锁
- synchronized和Lock锁都是可重入的
-
不可重试
- 基于Redis的setnx实现分布式锁只尝试获取锁一次,如果获取失败则返回失败信息
- 我们认为合理的情况是:当线程在获得锁失败后,他应该能在一定时间内再次尝试获得锁
-
超时释放
- 误删锁风险:尽管采用Lua脚本可以确保只有持有锁的客户端才能删除锁,但是如果在执行删除操作之前客户端崩溃或者网络异常,就可能导致该客户端持有的锁永远不会被释放,其他客户端无法获取锁。
- 锁过期风险:加锁时设置的过期时间是一个相对粗略的估计,如果业务场景发生了变化,某些操作耗时增加,可能会导致锁的过期时间不足以覆盖所有操作。此时,如果出现卡顿等情况,仍然会导致死锁问题。
-
主从一致性
- 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
为解决上述问题,我们今天来学习基于Redisson的分布式锁服务
什么是Redisson
Redisson是基于Redis的分布式Java对象和服务包,提供了一系列的分布式服务,如分布式对象、分布式锁、分布式集合、分布式Map、分布式队列等,可以轻松地实现高并发、高可用的分布式系统。本篇文章只讨论Redisson的分布式锁服务。
Redisson解决上述问题的主要机制如下:
- 精细化的锁管理:Redisson提供了多种类型的锁,如可重入锁、公平锁、红锁等,针对不同的业务场景选择不同的锁类型,从而达到更好的锁管理效果。
- 防止误删锁:Redisson在删除锁时使用了Lua脚本,确保只有持有该锁的客户端才能删除锁,避免了误删锁的问题。
- 自动续期:Redisson支持自动续期,在加锁时可以设置锁的过期时间,并在锁即将过期时自动进行续期,从而避免因操作耗时增加而导致的锁过期问题。
- 监听锁状态:Redisson提供了监听锁状态的机制,可以在锁释放时才发出通知,从而避免了锁竞争问题和误删锁问题。
后续我们将会详细讨论。
maven项目下Redisson的快速使用
-
pom依赖
xml<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.13.6</version> </dependency>
- 配置Redisson客户端
arduino
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
// 单点模式
Config config = new Config();
config.useSingleServer().setAddress("redis://ip:端口")
.setPassword("密码");
// 集群模式
/*config.useClusterServers()
.addNodeAddress("redis://ip:端口")
.addNodeAddress("redis://ip:端口")
.addNodeAddress("redis://ip:端口")
*/
// 创建RedissonClient对象
return Redisson.create(config);
}
}
Redission分布式锁的简单demo
csharp
@Resource
private RedissionClient redissonClient;
@Test
void testRedisson() throws Exception{
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("lockname");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
//判断获取锁成功
if(isLock){
try{
System.out.println("执行业务");
}finally{
//释放锁
lock.unlock();
}
}
}
Redission的可重入性原理
前面我们提到,synchronized和Lock锁都是可重入的
-
在Lock锁中,它通常使用一个volatile修饰的state变量来表示锁的状态。当没有线程持有该锁时,state的值为0;当一个线程获得该锁时,state的值递增,表示持有该锁的次数。如果同一个线程再次获得该锁,state的值会再次递增,而在释放锁时,state的值相应地递减。只有当state的值减为0时,表示当前没有线程持有这把锁。
-
对于synchronized关键字,在底层的实现中也使用了一个计数器,通常称为monitor的计数器或者entryCount。当一个线程进入synchronized代码块时,entryCount的值递增,表示持有该锁的次数。同样,当同一个线程再次进入synchronized代码块时,entryCount的值会再次递增,并在退出synchronized代码块时递减。只有当entryCount的值减为0时,表示当前没有线程持有这把锁。
无论是Lock锁还是synchronized关键字,都通过计数器来记录锁的重入
首先我们来认识一下,Redission中实现可重入锁的结构------采用Redis的hash结构来实现可重入锁
其中KEY标识锁是否存在,key标识当前这把锁被哪个线程持有
可以发现,Redission内部仍然是通过lua脚本实现锁的
ini
// 判断锁是否存在
"if (redis.call('exists', KEYS[1]) == 0) then " + // 锁不存在
// 往redis里面写入 hash数据,key为带有线程标识的小key,value为1(第一次写入)
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
// 设置锁的有效期
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
// 返回null
"return nil; " +
"end; " +
// 锁存在,且锁是自己的(key已经存在
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
// 将当前这个锁的value进行+1
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
//重新设置过期时间
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
// 锁抢锁失败,返回pttl
"return redis.call('pttl', KEYS[1]);"
- KEYS[1] : 锁名称
- ARGV[1]: 锁失效时间
- ARGV[2]: id + ":" + threadId;,即锁的小key
- pttl:该键的剩余过期时间
Redission的分布式锁原理
Redisson 的 tryLock() 方法的源码分析
-
waitTime:等待时间,即在这个时间范围内尝试获取锁。如果超过这个时间仍未获取到锁,则会返回false。
-
leaseTime:租约时间,即锁的有效期。在成功获取到锁之后,锁会在leaseTime之后自动释放。
- 为-1则超时释放时间默认值为30s,并开启看门狗机制(watchDog
-
unit:时间单位,用于指定waitTime和leaseTime的单位。
接下来我们从Redission源码分析实现逻辑
scss
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
// 把超时等待时间转换为毫秒
long time = unit.toMillis(waitTime);
// 获取当前时间(毫秒)
long current = System.currentTimeMillis();
// 获取线程 ID
long threadId = Thread.currentThread().getId();
// tryAcquire 尝试获取锁(获取锁成功返回 null, 获取锁失败返回剩余ttl)
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) { // 成功获取锁
return true;
}
// time 是超时释放时间(单位:毫秒)
// time = time - (System.currentTimeMillis() - current)
time -= System.currentTimeMillis() - current; // 刷新超时释放时间
if (time <= 0) { // 超时释放时间到了
acquireFailed(waitTime, unit, threadId);
return false; // 获取锁失败
}
// 获取 当前时间毫秒值
current = System.currentTimeMillis();
// 等待释放锁的通知(订阅释放锁的信号)
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
// 等到超时释放时间结束还没有收到释放锁的通知的话, 返回 false
// 获取锁失败
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));
}
首先,通过Redisson的分布式锁机制,在Redis中使用setnx命令尝试获取锁。
-
如果获取锁成功,则返回true(此后会将当前线程信息放入ThreadLocal变量中,并设置锁的有效期为leaseTime)
-
如果获取锁失败,判断剩余等待时间是否大于0,大于0则订阅等待释放锁的信号
- 如果获取到释放锁的通知,则在min(该键的剩余过期时间,剩余等待时间) 内反复尝试再次获取锁,如果成功获取到锁,则同样返回true
- 否则等待信号,如果超时释放时间结束还没有收到释放锁的通知的话, 返回 false
为什么在min(该键的剩余过期时间,剩余等待时间) 内反复尝试再次获取锁
-
最大限度地利用剩余过期时间
- Redis中的键可以设置过期时间,在过期时间到达之后,锁会自动释放。
- 选择较小的时间作为尝试获取锁的时间范围,可以最大限度地利用键的剩余过期时间。这样做的好处是,在剩余过期时间内继续尝试获取锁,可以减少获取锁的开销和延迟,提高程序的响应速度。
-
防止长时间等待
- 如果只考虑剩余等待时间而忽略键的剩余过期时间,可能会导致长时间等待的情况。
- 如果剩余等待时间比键的剩余过期时间长很多,就会出现在等待期间锁自动释放的情况,而等待的线程无法及时获取到锁。通过将剩余过期时间和剩余等待时间进行比较,可以避免长时间的无效等待。
WatchDog机制
Redisson的WatchDog机制是用于维护分布式锁的有效性和自动延长锁的过期时间的一种机制
通过定时任务来检查和维护锁的有效性,并自动延长锁的过期时间,确保持有锁的线程不会因为锁的过期而丢失锁,防止如果业务代码没执行完,锁却过期了所带来的线程不安全问题。
Redisson WatchDog机制的源码分析
-
WatchDog的启动时机
-
WatchDog是在
lock()
方法中获取锁成功后才异步启动的。 -
在调用lock方法时,会最终调用到tryAcquireAsync。调用链为:lock()->tryAcquire->tryAcquireAsync,
-
在
tryAcquireAsync
方法中,先执行获取锁的逻辑,如果获取锁成功,则调用scheduleExpirationRenewal()
方法来异步启动 Watchdog。 -
值得注意的是,对于
leaseTime
参数,它用于指定锁的过期时间。- 如果
leaseTime
的值为 -1,表示锁的过期时间由 Redisson 自动生成,此时 Watchdog 会自动开启并负责定时更新过期时间。 - 如果
leaseTime
的值大于 0,则锁的过期时间由用户指定,Watchdog 不会启动。
- 如果
scss public void lock() { // 获取锁的逻辑... if (leaseTime != -1) { // 不续期,只是把internalLockLeaseTime重置为leaseTime。 internalLockLeaseTime = unit.toMillis(leaseTime); } else { // leaseTime == -1,scheduleExpirationRenewal开启看门狗续期, scheduleExpirationRenewal(threadId); }
-
WatchDog的实现逻辑
- 在
scheduleExpirationRenewal()
方法中,会创建一个周期性的任务,该任务会定期检查是否需要续期锁的过期时间 renewExpiration
方法会在定时任务的执行逻辑中被异步调用,用于更新锁的过期时间。- 具体的续期操作是scheduleExpirationRenewal通过异步调用
renewExpirationAsync
方法来完成的。
arduino
private ScheduledFuture<?> scheduleExpirationRenewal(long leaseTime, long timeoutDate) {
if (leaseTime < 0) {
return null;
}
// 创建一个 ExpirationRenewalTask,用于定时检查并续期锁的过期时间
ExpirationRenewalTask task = new ExpirationRenewalTask(getName());
long delay = leaseTime / 3;
// 计算首次执行任务的时间
long scheduleTimeout = timeoutDate - System.currentTimeMillis() - delay;
// 调度定时任务,并将任务添加到 scheduledFutures 集合中
ScheduledFuture<?> future = commandExecutor.getConnectionManager().getGroup().scheduleWithFixedDelay(task, scheduleTimeout, delay, TimeUnit.MILLISECONDS);
scheduledFutures.add(future);
return future;
}
renewExpiration()
:更新锁的过期时间
java
private void renewExpiration() {
// 从 EXPIRATION_RENEWAL_MAP 中获取与当前锁关联的 ExpirationEntry 对象
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
// 创建一个新的 Timeout 对象,用于定时执行更新过期时间的任务
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
// 获取与当前锁关联的 ExpirationEntry 对象
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
// 获取第一个线程的 ID
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
// 异步执行更新过期时间的操作,并在操作完成后的回调中判断是否需要继续更新
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
// 更新过期时间失败,记录错误日志
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
if (res) {
// 需要继续更新过期时间,重新调度更新任务
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
// 将新的 Timeout 对象设置为 ExpirationEntry 的 timeout
ee.setTimeout(task);
}
-
renewExpirationAsync
:- 如果锁存在:续期,返回1
- 锁不存在(任务已经执行完成,手动释放了锁):返回0
typescript
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getRawName()),
internalLockLeaseTime, getLockName(threadId));
}
通过上述分析我们可以看出,watchDog会 在当前节点存活时每10s给分布式锁的key续期 30s,当watchDog启动,如果代码中没有释放锁操作时,会不断的给锁续期,所以我们要使用finally确保锁被释放。
主从问题-MutiLock锁
为了提高redis的可用性,我们会搭建集群或者主从,本篇文章以主从为例
主从中信息的丢失
在主从中,我们在主机上写命令, 主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。
为了解决这个问题,redission提出来了MutiLock锁
在MutiLock锁中,每个节点的地位都是一样的
- MutiLock锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功
- 假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。
MutiLock的简单使用
- 配置 Redis 客户端,搭建三个 Redis 节点
arduino
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient(){
// 配置类
Config config = new Config();
// 设置 redis 地址
config.useSingleServer().setAddress("redis://ip:port");
// 返回
return Redisson.create(config);
}
@Bean
public RedissonClient redissonClient2(){
// 配置类
Config config = new Config();
// 设置 redis 地址
config.useSingleServer().setAddress("redis://ip:port");
// 返回
return Redisson.create(config);
}
@Bean
public RedissonClient redissonClient3(){
// 配置类
Config config = new Config();
// 设置 redis 地址
config.useSingleServer().setAddress("redis://ip:port");
// 返回
return Redisson.create(config);
}
}
- 使用 SpringBoot 提供的测试类进行测试
java
@Slf4j
@SpringBootTest
class HmDianPingApplicationTests {
// 注入三个 Client
@Resource
private RedissonClient redissonClient;
@Resource
private RedissonClient redissonClient2;
@Resource
private RedissonClient redissonClient3;
private RLock lock;
// 创建连锁
@BeforeEach
void setup() {
RLock lock1 = redissonClient.getLock("order");
RLock lock2 = redissonClient2.getLock("order");
RLock lock3 = redissonClient3.getLock("order");
// 创建连锁 ------ 用 redissonClient2、redissonClient3 也可以
lock = redissonClient.getMultiLock(lock1, lock2, lock3);
}
// 测试连锁
@Test
public void HmDianPingApplicationTests() throws InterruptedException {
boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
if(!isLock){
System.out.println("获取锁失败");
} else{
System.out.println("获取锁成功");
lock.unlock();
}
}
}
需要注意的是,MutiLock并不能解决主从延迟带来的问题,它不处理主从同步的细节。如果主节点和从节点之间存在较大的延迟,可能会导致一些数据不一致的情况。