Redisson
我们先来了解一下Redisson是个什么东西,他是一个客户端,通过网络和redis来沟通。那么他用的什么东西呢,如果你找一下他的依赖,那么就会发现netty,他通过netty来和redis沟通,这对于后边看门狗的续期,有很大的相关性。
锁的一些性质:
- 互斥性
- 可重入性
- 原子性
- 防止死锁
- 阻塞等待上的优化 / 通知他人
我们依次来看这里的性质,在Redisson分布式锁中都有说法,通过抓住锁的一些核心性质,来实现锁的操作部分。
互斥性
这里直接通过key 是否exist来判断就可以了,来看这里的lua脚本
"if ((redis.call('exists', KEYS[1]) == 0) " +
"or (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]);"
这里直接exists去判断,是否有这个key,这里h也是hash的意思,对吧,这里的hincrby就是去实现的重入嘛。 "redis.call('pexpire', KEYS[1], ARGV[1]); " +
这一句给当前锁重新加一个过期时间。
可重入性。
首先来看一看设计思路:
为什么普通的锁不可重入? 因为setnx 可以看成一种boolean的状态,他只有true or false , 但是我们想要的不仅仅是true or false , 我们想要的是state , 我们想要值。 这里AQS有一个state变量,就是干这个用的,为什么他state是 volatile int,而不是用的boolean?就是为了实现可重入的效果。 那么为什么不可以用String ? 他的key作为锁名和持有的名 , value作为这个锁的重入次数,而是用hash这种key - fieldKey - value的格式。
我觉得实现可重入这些都是次要的,对于可重入的实现,主要是将boolean改为int值就可以了。但是hash结构的这种key - fieldKey - value的模式可以为我们提供一些别的遍历。在一个key中,我们可以通过fieldKey存储更多的信息。
myLock: {
“8743c9c0…:1”: 1, // 主锁字段
“mode”: “fair”, // 锁模式
“timeout”: 30000, // 超时时间
“created_at”: 1630000000 // 创建时间戳
}
我觉得这个东西是最重要的,单个的string的key存的东西比较单一,取出来的时候操作会很复杂,所以我们主要选取hash,string可以实现重入,但是很麻烦。而hash双重key自动spilt,并且还有其他信息可以使用。有简单的数据结构,就不要去用复杂的东西来折磨自己了
原子性
使用lua脚本来保持原子性。为什么redis执行是原子性?因为redis使用单个主线程来处理任务。 redis会一股脑读完lua脚本中的内容,那么为什么redis单单用个单线程就可以那么快呢?其实核心就在于IO多路复用。这里详细讲一下当复习了。某个redis可能会连接多个client,当某个socket可读的时候,内核通过事件触发的方式来通知Redis,然后Redis立马对该事件进行处理。为了更形象的理解,我们来假设redis不用多路复用,那么如果有大量的请求连过来,他必须去对应的处理。比如fork一个子进程,或者开一条线程。如果不这么做,那么大量的client就会互相阻塞,因为路就这么大,你不开更多的路,那么只能像排队那样等前边的人弄完才可以继续走,或者用简单的NIO,比如之前的select,但是select要去轮训,且连接管理大小有限。poll只优化连接数,但是仍然是去轮训请求的。如果请求数很多,他们的开销,时间消耗都是不晓得。但是如果用IO多路复用呢?每个连接都有自己的channel,kernel会通过channel表来通知redis哪个channel有对应的数据,redis直接去拿然后操作就可以了,简单,高效。
防止死锁。
如果redis挂了,那么普通的锁永远都不会被释放,所以我们需要考虑expire ,但是如果expire的时间太短,线程执行的过程中被释放那不炸了吗,所以我们需要使用watchDog,给锁续时。看门狗的续期逻辑不依赖当前的线程,而是有他自己的一个线程,还记得前文我们说的netty吗?他里边就有个时间轮,Redisson通过时间轮的方式来管理锁续期。来看代码(虽然我也不想粘)
CompletionStage<Boolean> f = acquiredFuture.thenApply(acquired -> {
// lock acquired
if (acquired) {
if (leaseTime > 0) {
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
scheduleExpirationRenewal(threadId);
}
}
return acquired;
});
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
Timeout task = getServiceManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
CompletionStage<Boolean> future = renewExpirationAsync(threadId);
future.whenComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock {} expiration", getRawName(), e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
if (res) {
// reschedule itself
renewExpiration();
} else {
cancelExpirationRenewal(null, null);
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
这里new了一个定时任务,他是TaskTime类型的任务,每隔锁的释放时间的1 / 3 去更新一次,最开始为30,所以每隔10s去做一次。里边的任务会去继续调用他自己,实现定时任务的循环。最后把这个任务用ee来启动. HashedWheelTimeout
你去这里的newTimeout中去看一下,就会看到这个哈希时间轮了。 然后就是执行这个定时任务的线程了,他是redisson来管理的线程,你去debug看一下,就会看到他的线程名称对应为(我的) redisson-netty-4-3 这种名称很明显是新创建的内容。但是具体在哪创建的,我没找到。反正肯定是和主线程没什么关系。二者声明周期是不同的。主线程挂了后,那么这个是会一直续期,所以一定要用finally来修饰。
阻塞通知 : pub / sub
这里最后就是当锁释放后怎么通知其他的线程可以去拿锁了。这里也是发布订阅模式的应用。而不是直接用比如AQS那种用阻塞队列的模式来拿等待的锁。
根本原因是 “分布式” vs “单机” 的物理差异,导致无法像 AQS 那样维护一条集中式等待队列。
AQS 为什么能用 CLH 队列
• 所有线程在同一 JVM,共享同一块堆内存;
• 用volatile + CAS
就能原子地把线程节点插到链表;
•LockSupport.park/unpark
直接在用户态挂起/唤醒线程,零网络开销。Redis 场景下如果硬做“链表”会遇到什么
• 节点放哪?
– 放客户端本地:别的 JVM 看不到,无法全局排队。
– 放 Redis 里:需要把“客户端 ID + 线程 ID”序列化成 List/Stream 的元素,插入、弹出、超时清理全部要 Lua 保证原子,实现复杂度陡增。
• 唤醒谁?
– 队列只能让“下一个节点”的客户端去抢锁;如果那个客户端进程已经崩溃,节点就永远卡在那里,需要额外的监听与清理。
• 实时性
– 链表方案只能让客户端轮询“轮到我了吗?”;消息往返 + 空转重试让延迟不可控。
• 原子性
– “入队 + 抢锁”必须是原子事务,否则会出现两个客户端同时认为自己排在队头。
– 一次 Lua 脚本里既要写队列又要 SETNX,逻辑臃肿且难以维护。Pub/Sub 的优势正好弥补以上痛点
• 天然广播:一条PUBLISH
立即通知所有订阅端,零轮询、零链表维护。
• 无状态:客户端崩溃后频道自动消失,没有残留节点。
• 实现简单:Lua 脚本只需DEL
+PUBLISH
两步即可保证原子。
• 延迟低:消息由 Redis 服务器主动推送,毫秒级即可唤醒等待方。
结论
AQS 用链表是因为它能在同一进程内共享内存,而 Redisson 运行在跨进程、跨机器的分布式环境中,无法安全、实时、低成本地维护全局链表;Pub/Sub 提供的“广播+无状态”机制正好满足分布式锁释放通知的需求,因此成为最优选。