前言
在现代分布式系统中,高并发环境下的数据一致性和资源竞争是必须面对的重大挑战。随着微服务架构的普及,服务之间的并发访问变得更加频繁且复杂,如何有效地管理这些并发访问成了系统设计成功的关键因素之一。Redisson
作为 Redis
客户端中一个强大的工具,提供了简单易用且功能丰富的分布式锁实现,帮助开发者在复杂的分布式系统中协调资源共享和任务调度。
本篇文章将深入探讨 Redisson
中的互斥锁RedissonLock
,包括其基本原理、使用场景、优势
,通过代码示例展示如何在实际应用中正确实现和使用 Redisson 锁。
为什么要使用Redisson分布式锁
当我们在使用一项技术的时候,我们首先应该明白的就是,为什么要使用这项技术?
我们都知道,Redis中有一个命令,叫做SETNX
,这个命令的作用是,只有当 key 不存在的时候才会设置成功,并返回true或false。我们通常会利用这个特性来实现分布式锁,实现只允许一个线程访问共享资源。然而,SETNX
的功能相对单一,无法满足更复杂的使用场景。
当我们使用Redisson获取到一个分布式锁后,在Redis中是这样的:
可重入
假设有方法A和方法B,它们都需要获取同一把锁才能访问共享资源。在缺乏锁重入功能的情况下,单个线程也可能因在方法A中已获得锁后调用方法B而导致死锁。尽管多数情况下不需要可重入,但设计支持锁重入能够有效避免此类单线程死锁问题。
Redis中的确有一些自增的命令可以用来实现锁的可重入,如INCR
、INCRBY
、HINCRBY
等,但是这些命令自身并不能事先判断锁是否已经被当前线程持有,但是我们可以通过lua脚本
来保证整个过程的原子性。上图中hash结构的value就是锁的重入次数。
锁误删
我们在使用SETNX
实现分布式锁的时候,同时也会设置这个锁的过期时间,以此来防止死锁的问题。假设在一个方法中,我们获取到了锁对象,并给锁对象设置了15秒的过期时间,然后现在有很多线程都想要执行这个方法。
if (tryLock("lock")) {
try {
expire("lock", 15, TimeUnit.SECONDS);
// 执行业务逻辑
} finally {
unlock("lock");
}
首先线程A先获取到了锁对象,开始执行方法,但是由于网络原因或GC等其他原因,线程A执行这个方法耗时16秒。而因为我们设置了锁的过期时间,实际上在第15秒的时候锁就已经自动释放了,线程B也成功获取到了锁对象,开始执行方法。假设线程B执行这个方法需要耗时5秒,执行到第1秒的时候恰好是线程A的第16秒,线程A就会执行unlock方法,将线程B获取到的锁释放掉,这样就会导致新的线程安全问题。
其实锁误删的本质就是:如何判断当前这个锁对象是不是当前线程持有的? 可重入也同样存在这个问题。那么对于多节点部署的后端服务,Redisson是怎么解决这个问题的?
从这张图可以看到,Redisson实现的分布式锁中,hash结构的key是一个uuid + 数字
的组合,这个uuid是我们服务的节点的标识,数字是这个节点中获取到锁的线程的id。当一个RedissonClient对象被创建出来时,就会创建这个uuid来标识当前服务的节点,所以通过这样uuid + 线程id
就可以唯一标识一个节点中的某个线程,在获取锁、可重入和释放锁
之前都会判断这个值是否和当前线程得到的唯一标识相同,Redisson本身也是这样做的:
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), 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(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
通过RedissonLock
中的tryLockInnerAsync方法
我们可以看到,加锁的步骤是这样的:
- 判断锁对象是否存在
- 锁对象不存在则加锁,并设置过期时间
- 锁对象存在,且是自己的锁,直接重入,并设置过期时间
- 如果锁被其他线程持有,返回锁的过期时间
锁续命
为了解决锁误删这个问题,我们可以将锁的过期时间设置长一些,防止业务逻辑还没有执行完,锁就过期了。
但这其实并不能彻底解决这个问题,因为有些时候我们并不能精确的知道哪些地方可能会耗时多少时间,可能下游服务出问题或其他原因等待了很久,而超过了我们设置的锁过期时间,导致严重的生产事故。
在使用RLock
的tryLock
或lock
方法时,如果没有指定锁的持有时间(即过期时间),Redisson就会在获取锁成功后开启一个看门狗为我们的锁进行续命。
看门狗其实是一个定时任务,当我们没有指定锁的过期时间时,默认会设置30秒的过期时间,防止当前节点宕了造成死锁,接着看门狗每隔10秒查看一次锁有没有存在,如果存在就重新设置过期时间为30秒,如此进行续命。
tryLock和lock方法的区别
获取锁对象有两个方法:tryLock
和lock
,这两个方法同样是获取锁,并且底层获取锁的逻辑也都是相同的,区别在哪里呢?
tryLock
方法返回一个布尔类型的值,而lock
方法没有返回值。lock
方法没有返回值,这就意味着lock
方法会一直等到获取锁成功;而tryLock方法允许等待一段时间,没有获取到锁就返回false,也可以不等待,立刻返回结果。
补充:在分布式环境中,由于无法依赖Java自带的wait
和notify
方法进行线程间通信,Redisson采用了pub/sub
机制实现等待和唤醒功能。具体来说,当线程尝试获取锁但未成功时,它会订阅一个名为redisson_lock__channel
的channel。与此同时,当持有锁的线程执行unlock
方法时,会向这个channel发布一条释放锁的消息,从而唤醒所有正在等待的线程。
总结
Redisson通过一系列高级特性与机制,帮助开发者在复杂的分布式系统中实现高效、安全的同步控制。通过支持锁可重入、避免锁误删、自动续期和提供灵活的锁获取方式,Redisson简化了开发者在高并发环境中管理资源的复杂性,为构建可靠的分布式应用提供了强有力的工具。