Redisson源码分析
# Redisson分布式锁源码分析
# 一、分布式锁——为什么、什么场景、面试常问点
# 为什么要分布式锁
在传统单机、多线程环境中,我们常用 synchronized、ReentrantLock 等来保证同一 JVM 内线程之间互斥。但在微服务或者分布式环境下:
- 多个服务实例(不同进程/机器)同时访问共享资源(数据库记录、缓存 key、外部接口等)
- 若无协调机制,可能出现竞态条件、超卖、重复 执行、数据不一致等问题。 (Medium (opens new window))
- 于是就出现“分布式锁”——一种跨进程/跨机器的互斥机制:确保某一资源在任意时刻只有一个客户端/线程在操作。
# 面试可能问的点
- 什么是分布式锁?什么时候用?
- 分布式锁与数据库悲观锁/乐观锁、单机锁区别?
- 实现分布式锁有哪些方式(如 Zookeeper、etcd、Redis、数据库、消息队列)?
- Redis 实现分布式锁时需要考虑哪些问题?(原子性、超时、节点失败、释放锁、续租等)
- 有哪些经典算法?例如 Redlock 算法。 (SoByte (opens new window))
- 在实际生产中,使用哪种客户端库(如 Redisson)?其原理如何?
- 在源码层面:加锁、解锁可重入、超时机制、续租机制、失败恢复机制、线程安全、错误使用该怎么办?
- 锁的粒度、性能、死锁可能、续租失败、业务误用 etc.
# 二、Redis 实现分布式锁的基本思路
为了让后续理解 Redisson 源码有基础,先从更基础的 Redis 分布式锁思路讲起。
# 基本思路
使用 Redis 作为“锁存储中心”(键-值存储)。
客户端尝试“加锁”时:往 Redis 写入一个 lock key(资源标识),并设置一个 唯一标识(例如 UUID + 线程 ID)作为 value,同时设置一个 TTL(租期),保证即使客户端 crash 也不会导致锁一直不能释放。
加锁操作必须是原子性的:典型用法
SET key value NX PX ttl。客户端释放锁时,只有拥有该锁的客户端才能删除这个 key(防止释放别人的锁)——常见做法是:
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end1
2
3
4
5TTL 到了锁自动释放,从而防止死锁。
如果要更强的容错(多 Redis 节点),可以采用 Redlock 算法:在 N 个独立 Redis 实例上尝试加锁,然后获得 > N/2 实例的锁才算成功。 (Medium (opens new window))
# 需要考虑的问题
- 原子性:加锁 + 设置 TTL 要么都成功,要么失败。Lua 脚本或 Redis 自身
SET NX PX支持。 - 客户端持有锁后超时被自动释放,这可能导致持锁客户端还在执行但锁被释放,造成其他客户端进入关键区——潜在风险。
- 解锁必须保证“是锁的持有者才能解锁”。
- 重入锁:同一个客户端/线程多次加锁如何实现?
- 自动续约(Watch-dog):如果业务执行很久怎么办?不能仅靠 TTL。
- 多节点应用/Redis 多实例模式的可用性、分区容错。
# 三、Redisson 的角色 + 支持特性(为什么生产中常用)
# Redisson 是什么
- Redisson 是一个基于 Redis 的 Java 客户端(和扩展库),提供了不仅仅是基本的 Redis 操作,还封装了许多分布式对象 & 服务(如 RLock、RReadWriteLock、RSemaphore、RCountDownLatch、RMap 等) 。
- 在生产系统中,中小型互联网公司常用 Redisson 来实现分布式锁、限流、分布式集合/队列等。
- 它支持多种 Redis 模式:单实例、哨兵(Sentinel)、主从、Redis Cluster。你的参考中也指出:Redisson 支持 redis 单实例、哨兵、master-slave、cluster。
# 为什么常用
- 较为成熟、社区活跃、使用方便。
- 对锁机制封装良好(支持同步和异步 API、可重入锁、自动续租、超时机制)——你示例
lock.tryLock(100,10,TimeUnit.SECONDS)就是典型。 - 在面试题中,讲 “基于 Redis 的分布式锁 + Redisson 源码” 是比较常见且有技术深度的点。
- Redisson 内部对 Redis 操作、Lua 脚本、线程唯一标识、续租机制、锁释放机制实现较好,可以作为源码分析的好范例。
# 四、Redisson 中分布式锁的源码分析
下面重点从源码层面分析 Redisson 的分布式锁实现机制。为了便于理解,我会分成几个模块:加锁(获取锁)、可重入机制、续租(watch dog)机制、解锁、判断锁持有情况、客户端/线程标识设计。每个模块,我先描述设计目的,再讲源码关键点/Lua 脚本解释。
# 4.1 客户端/线程唯一标识设计
在分布式锁中,必须区分“哪个客户端(甚至哪个线程)”持有锁。Redisson 的实现如下:
在
RedissonLock构造函数中:this.id = commandExecutor.getConnectionManager().getId(); this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(); this.entryName = id + ":" + name;1
2
3你的参考已给出。
getLockName(threadId)=id + ":" + threadId。也就是说,锁 key 对应的 Hash 结构里面,field 是 “客户端 UUID:线程Id”。这样,客户端在多个线程中使用锁时,每条线程也有唯一标识;不同客户端也不冲突。
这个设计使得:
- 支持可重入:同一个客户端线程再次加锁,可以检测
hexists(KEY, thisThreadField)。 - 解锁必须是原持有线程才能解锁(否则 IllegalMonitorStateException)。你参考里也提到了 stackoverflow 上的问题。 (Stack Overflow (opens new window))
理解这一点是深入源码的基础。
# 4.2 加锁(获取锁)
# 概述
当调用 lock()、tryLock() 等 API 时,底层调用的是 tryLockInnerAsync(...) 方法(对应你的参考中代码段)。其主要流程:
- 构造 Lua 脚本并通过
evalWriteAsync()执行(保证原子性)。 - 脚本逻辑:如果 key 不存在,则 set hset + pexpire;如果 hash 中已有当前 thread’s field,则为可重入,hincrby + pexpire;否则返回当前 key 的剩余 TTL(用作返回值用于做等待/重试逻辑) 。
- 如果用户指定 leaseTime(不是 −1 表示默认方式),则用该 lease;否则使用 default
internalLockLeaseTime(例如 30 000 毫秒)并触发续租(watchdog)机制。
# 核心 Lua 脚本
如下(正如你参考中所展示):
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', 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]);
2
3
4
5
6
7
8
9
10
11
解读:
KEYS[1]= lockName (即你传入的 name)ARGV[1]= leaseTime (ms)ARGV[2]= lock field =clientId:threadId- 第一分支:如果 key 不存在,则表示锁尚未被任何客户端持有 → 创建 hash,field=当前线程,value=1(表示锁计数(可重入计数)) + 设置 TTL。返回
nil表示成功。 - 第二分支:如果该 key 已存在,而且当前线程(field) 已经在 hash 中(即就是重入) → 将 value++,并 pexpire TTL。返回
nil表示成功。 - 否则:锁已经被别的线程/客户端持有,返回
pttl(key)表示剩余过期时间。客户端可以据此决定等待重试。
# 为什么用 hash 而不是简单的 string
- 使用 hash 的好处在于:可重入计数(value 存持有次数) + 支持多个客户端 field ?(不过实际逻辑里并允许多个客户端持有)
- 若用 String,重入时无法区分线程是否是自己、无法计数。
- Redisson 这种设计结合线程 ID 使得「同一线程重入」成为可能。
# 默认 leaseTime 和续租机制
- 如果用户调用
lock()(无 leaseTime 参数)或指定 leaseTime = −1,则 Redisson 使用默认internalLockLeaseTime(通常 30 秒)并在获取锁成功后启动“续租线程”(watch-dog),以防业务执行时间超出 TTL。你的参考里也提到了。 - 如果用户调用
tryLock(waitTime, leaseTime, unit)并指定 leaseTime > 0,则不会启动续租机制,而是直接按 leaseTime 到期释放。
# 获取锁失败/等待逻辑
- 如果脚本返回一个长期的 TTL (即别人持锁),客户端会判断等待时间、重试逻辑(比如 spin/wait 内容)——源码中
lock(long leaseTime, TimeUnit unit, boolean interruptibly)里有等待逻辑。你可以在资料中找到。 (devpress.csdn.net (opens new window)) - 在阻塞获取环境情况下(比如
lock()),Redisson 会循环尝试获取锁,在失败时订阅 channel 等待通知(见下一节)。
# 4.3 可重入机制
如上脚本所示,第二分支 hexists(KEY, ARGV[2]) == 1就是判断当前线程已经是锁的持有者(field 存在)→ 那就执行 hincrby(...,1) 计数++,然后重设 TTL。这样就实现了可重入。
对应面试问点:为什么要可重入?例如在同一线程内部调用 lock() 后,再调用其他锁保护的方法、递归调用等场景。
此外,释放时也会根据计数值决定是否真正释放(见解锁部分)。
# 4.4 Watch-dog 机制(自动续租)
# 为什么需要续租?
如果锁 TTL 设置为固定,如 30 秒,但业务执行可能超过 30 秒,那么就有两种潜在风险:
- 锁过期释放后,另一个客户端获得锁,之前的持有线程还在执行,引起并发错误。
- 若 TTL 太长,则持锁线程 crash/机器宕机后,锁长时间不能释放 → 资源被锁定。
Redisson 采用“看门狗”机制:如果用户没有显式指定 leaseTime(默认机制),Redisson 会在后台每隔 internalLockLeaseTime/3 时间执行一次续租(即延长 TTL)直到释放。你的参考也提到了。
# 源码关键流程
在 tryAcquireAsync(...) 中:
if (leaseTime == -1) {
// 使用默认 TTL,并执行 tryLockInnerAsync(...), 然后
ttlRemainingFuture.addListener(new FutureListener<Long>() {
@Override
public void operationComplete(Future<Long> future) throws Exception {
if (!future.isSuccess()) {
return;
}
Long ttlRemaining = future.getNow();
// lock acquired if ttlRemaining == null
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
scheduleExpirationRenewal(threadId) 方法会:
检查是否已有续租任务(
expirationRenewalMap用于标记)利用
commandExecutor.getConnectionManager().newTimeout(...)创建定时任务,周期约为internalLockLeaseTime/3。在任务执行时调用
renewExpirationAsync(threadId),其 Lua 脚本逻辑如下:if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;1
2
3
4
5即:如果 hash 中当前线程 field 存在,则延长 TTL 为 ARGV[1]=internalLockLeaseTime (ms),返回 true。否则返回 false。
如果续租失败(或当前线程 field 不存在),则取消任务。
这样就让锁在业务执行期间一直保持,不会因为 TTL 到期而被自动释放。值得注意的是,这依赖于客户端所在 JVM/线程存活且续租任务正常执行。如果客户端挂了或网络中断,则续租无法执行,锁会在原 TTL 到期后自动释放,避免死锁。
# 面试可问点
- “Watch-dog”机制如何实现?
- 当客户端宕机/网络断开/线程被阻塞超过续租周期,会出现怎样的问题?
- 为什么设定为
internalLockLeaseTime/3作为续租周期?(保守续租多于 TTL) - 是否会导致锁的无限延续?(是的,但前提客户端还在)
- 是否有续租失败导致锁释放但业务还在做的问题?(有可能,要在业务中做好超时保护)
# 4.5 解锁机制
# 概述
当持有线程完成业务后,需要调用 unlock() 释放锁。Redisson 的解锁逻辑也较为严谨,主要包括以下几个方面:
- 只有锁的持有线程才能解锁,否则抛出
IllegalMonitorStateException。 - 对于重入锁,如果计数 > 1,则只是将计数减 1,并重设 TTL;如果计数 == 1,则删除 key,并通知等待者。
- 发布解锁通知(利用 Redis 的 Pub/Sub 机制)以便等待锁的线程可以被唤醒。
- 取消续租任务。
# 核心 Lua 脚本
如下:
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
对应参数说明:
KEYS[1]= lockNameKEYS[2]= channelName(用于发布解锁事件)ARGV[1]= unlockMessageARGV[2]= internalLockLeaseTimeARGV[3]= lock field =clientId:threadId
解读:
- 如果 key 不存在(可能超时被删) → 发布解锁通知(防止别人一直等待) → 返回 1。
- 如果 hash 中不存在当前线程的 field(不是当前线程持有) → 返回
nil(上层会抛异常:非法释放锁)。 - 否则,执行
hincrby(field, -1),把计数-1。- 如果计数 > 0(表示还有重入层数未释放) → 重新设置 TTL → 返回 0(表示锁未完全释放,只减少一次层数)。
- 否则(counter ≤ 0)→ 删除 key → 发布解锁通知 → 返回 1(完全释放)。
- 返回
nil时上层会触发 IllegalMonitorStateException。
# 解锁后续逻辑
- 上层
unlockAsync(threadId)会拿到上述脚本的返回结果,并在回调中调用cancelExpirationRenewal(threadId)取消续租任务。 - 若发布了解锁通知,等待该锁的线程会通过订阅 channel 被唤醒,从而减少自旋/轮询开销。
# 面试可问点
- 为什么要先判断 owner 才允许解锁?
- 为什么要发布通知?(减少等待开销)
- 重入锁的释放为什么做计数减法?
- 如果持有线程宕机/未释放锁怎么办?(TTL 自动释放 + watch-dog 机制)
- 是否存在不能释放锁或别人错误释放锁的风险?(要看“线程唯一标识”设计)
# 4.6 判断锁持有情况/其他操作
Redisson 还提供判断当前线程是否持有锁、是否被锁住、尝试获取锁等操作。
例如:
@Override
public boolean isHeldByCurrentThread() {
return isHeldByThread(Thread.currentThread().getId());
}
@Override
public boolean isHeldByThread(long threadId) {
final RFuture<Boolean> future = commandExecutor
.writeAsync(getName(), LongCodec.INSTANCE, RedisCommands.HEXISTS, getName(), getLockName(threadId));
return get(future);
}
2
3
4
5
6
7
8
9
10
11
即:查 hash 内是否存在 clientId:threadId 这种 field。
这样的判断在面试中也可能被问到,比如“如何判断当前线程是否持有锁?”、“为什么不能用 isLocked() 来判断自己持有锁?”(比如你的参考中提到一个 GitHub issue:使用 isLocked() 错误释放锁会出问题)。 (GitHub (opens new window))
# 4.7 小结表格:Redisson 锁各模块对应机制
| 模块 | 关键机制 | 源码/脚本点 |
|---|---|---|
| 客户端标识 | clientId = UUID + threadId | id = ... getId() + getLockName(threadId) |
| 加锁 | Lua 脚本:不存在→创建;已持有→重入;否则返回 TTL | tryLockInnerAsync(...) |
| 可重入 | hash field value++ + 重设 TTL | Lua 第二分支 |
| 默认 lease + 续租 | 若用户无 leaseTime 则启动 watch-dog 机制 | scheduleExpirationRenewal(threadId) |
| 解锁 | Lua 脚本:判断 owner,计数>1则减一重设 TTL,否则删 key + 发布通知 | unlockInnerAsync(long threadId) |
| 判断持有 | HEXISTS hash field | isHeldByThread(threadId) |
# 五、设计权衡 & 注意事项
在面试中,除了讲清楚机制,还经常会问“在实际系统中怎么办”“有哪些坑”“为什么要这么设计”这类问题。下面整理一些常见的设计权衡和使用时的注意事项。
# 5.1 锁超时、续租、死锁风险
- 如果 TTL 太短而业务执行时间较长,可能导致锁自动释放但持锁线程还在执行 → 竞态。
- 如果 TTL 太长且没有续租机制或客户端 crash,则可能造成锁“卡住”资源。
- Redisson 的 watch-dog 机制是折中方案:给一个合理默认 TTL(30 秒),并且在业务执行中自动续租。前提是客户端存活且续租任务能跑。
- 但如果客户端所在 JVM 卡死/线程饥饿/网络中断,续租失败,锁最终还是会被释放(通过 TTL)——这是设计保证。
- 因此,业务层也应做好 “锁获取失败/超时” 的处理逻辑。
# 5.2 多实例 Redis 模式、容错、可用性
- 如果只是单 Redis 实例,虽然实现容易,但存在单点故障风险。
- Redisson 支持:单实例、Sentinel、主从、Cluster。你面临生产环境时要考虑 Redis 节点的高可用性。
- 如果使用多个独立 Redis 实例来采用 Redlock 算法,需要满足“多数节点”获得锁才成功。注意这是一个更复杂、更强容错但也更有挑战的模型。 (SoByte (opens new window))
- 在 Redis Cluster 模式下,由于 key 哈希槽分布,不同节点可能存储不同 key,Redisson 内部会根据 key 决定执行在哪个节点。你面试时也可能被问 “Redis Cluster 下锁机制是怎样的?”
- 网络分区、节点故障、延迟不一致、复制滞后——这些都可能影响锁安全。Redisson 虽然封装了很多机制,但并不能完全避免所有风险。
# 5.3 锁粒度、性能、误用风险
- 锁不要粒度太粗:锁保护的业务逻辑越大/执行越慢,持锁时间越长,性能瓶颈越大。
- 避免锁里执行大量阻塞/IO 操作;尽量在锁里做少量快速操作。
- 重入锁虽然方便,但也可能隐藏问题(比如不小心重复加锁而忘了解锁)。
- 解锁必须在
finally块中执行,否则可能锁无法释放。Redisson 的 API 也推荐如此。 - 使用
tryLock(timeout, leaseTime)时,要设置合理的等待时间及租期。 - 使用
isHeldByCurrentThread()判断再释放比isLocked()更准确(在多人环境中)——你的参考中已提到。
# 5.4 锁的安全性 vs 可用性权衡
- 安全性(仅一个客户端持锁、释放正确) vs 可用性(即使节点失败也能恢复)之间存在权衡。
- 单实例 Redis 模式 + TTL 是可用但安全性弱(节点故障可能导致锁“丢失”或“被误持”)。
- Redlock 算法增强安全性但更复杂、延迟更高。
- 业务场景不同:如果不是极关键资源,有时简单方案足够;若强一致要求高,则可能选择 Zookeeper、etcd 之类。
# 5.5 版本兼容、线程/客户端释放差异、误用场景
- 注意不同版本 Redisson 的行为可能略有差异。
- 不要跨线程/跨客户端释放锁(会抛 IllegalMonitorStateException)——如 StackOverflow 问题所示。 (Stack Overflow (opens new window))
- 如果使用异步任务/线程池,要确认释放锁的线程是持锁线程或使用 Redisson 提供的线程Id 参数方法。
- 在高并发环境,要监控锁失败、超时、重试、等待队列情况。
# 六、面试重点问答+常见误区
# 问答集
Q1:为什么用 Redis 实现分布式锁?优点有哪些?
A:Redis 是内存数据库,响应快、支持 TTL、支持原子操作(如 Lua 脚本、SET NX PX)、部署方便,适合作为分布式锁的协调中心。 (Developer Playground | Giri's Place (opens new window))
Q2:Redis 分布式锁需要考虑哪些场景?
A:主要包括原子性、TTL 超时、锁释放安全(持有者才能释放)、客户端宕机/崩溃、网络分区、多 Redis 实例容错、锁撤销/续租机制。
Q3:Redisson 的可重入锁是怎么实现的?
A:Redisson 用 hash 类型存储锁状态:key = lockName,field = clientId:threadId,value = 重入计数。Lua 脚本中如果 hexists(KEY, field)==1 则认为当前线程已持锁,做 hincrby(field,1) 并重设 TTL。你的参考中也有这部分。
Q4:什么是 Watch-dog 机制?为什么要有?
A:当业务执行可能超过锁初始 TTL 时,如果没有续租,锁会被自动释放,但持锁线程可能还在执行,造成并发问题。Watch-dog 机制就是 Redisson 在获取锁成功后、若未指定 leaseTime,则会定时续租(延长 TTL)直到释放。这样减少因业务超时导致锁提前释放的问题。
Q5:解锁时,为什么要判断是否是当前线程持有?
A:如果任意线程都可以释放锁,那么就可能 A 加锁后 B 释放锁,A 还在执行,C 又获得锁 → 导致并发错误。Redisson 将 “客户端 ID + 线程 ID” 作为唯一标识,仅持有线程才能解锁。否则抛 IllegalMonitorStateException。
Q6:面试常见误区?
- 误区:用
DEL key简单删除锁。问题是:可能删除了别人的锁,因为没有判断持有者。 - 误区:TTL 很长就万无一失。其实客户端 crash 或网络分区仍可能导致锁无法释放或误释放。
- 误区:只用 Redis Cluster 模式就等同于 Redlock。实际上,Redis Cluster 是为了分片和可用性,不等同于 Redlock 提出的 “多个独立 Redis 实例 + 多数原则”模型。 (Developer Playground | Giri's Place (opens new window))
- 误区:release 锁可以跨线程、跨客户端。正确是只能由持锁线程释放。