概述
前文《缓存设计与 Spring Cache 深度整合》拆解了 Redis 在缓存场景下的设计模式与 Spring 整合。但在高并发系统中,缓存解决的是"读"的性能问题,而"写"的并发控制------比如秒杀库存扣减、分布式任务调度------需要分布式锁 来保证数据一致性。Redis 从基础的 SETNX 演进到 Redisson 的工业级分布式锁框架,再到 RedLock 的学术争议,形成了一套完整的分布式协调方案。本文将从基础实现到 Redisson 源码,从红锁原理到 fencing token 替代方案,系统拆解 Redis 分布式锁的内核。
摘要 :分布式锁是分布式系统中最基础的协调原语,也是最容易出错的技术之一。一个看似简单的锁,需要同时满足互斥性 、防死锁 、可重入 和锁续期 四项要求。Redisson 通过 Hash 可重入计数 和 Watchdog 自动续期 ,将 Redis 的 SET NX PX 包装成了 JUC Lock 接口的完整实现。而 RedLock 尝试通过多实例独立锁解决单点故障,却因 GC 停顿等现实问题陷入学术争议。本文将从一行 SET key value NX PX 30000 开始,逐层深入 Redisson 的 Watchdog 机制和红锁的正确性边界,并给出无锁化的替代方案。
核心要点:
- 分布式锁基础实现 :
SET NX PX+ Lua 脚本安全解锁。 - Redisson RLock:可重入(Hash + 计数)、Watchdog 自动续期、Pub/Sub 通知等待线程。
- RedLock 红锁 :多实例顺序加锁、过半成功判定、
RedissonRedLock实现。 - 红锁争议:Martin Kleppmann 的 GC 停顿分析、fencing token 缺失问题。
- 替代方案:etcd/Consul 强一致性锁 + fencing token、ZooKeeper 临时顺序节点锁。
- 无锁化方案 :
INCR/DECR原子操作、Lua 脚本、RPOPLPUSH安全队列。
文章组织架构图
总览说明:全文 7 个模块从分布式锁的基础实现出发,逐步深入 Redisson 的工业级设计、红锁的原理与争议、替代方案和无锁化实践,最后以面试题收尾。
逐模块说明:模块 1 建立分布式锁的基础认知;模块 2-3 是全文核心,深入 Redisson 的源码级实现与红锁协议;模块 4 引入学术争议体现严谨性;模块 5-6 给出正确的选型与替代方案;模块 7 面试巩固。
关键结论 :Redis 分布式锁从 SETNX 到 Redisson 的 Watchdog,再到 RedLock 的争议,反映了分布式系统在正确性与性能之间的永恒权衡。理解 Redisson 的可重入机制、Watchdog 续期逻辑和红锁的 fencing token 缺失问题,是生产环境中正确使用分布式锁的前提。
1. 分布式锁的核心要求与基础实现
1.1 四项核心要求
一个合格的分布式锁必须同时满足以下四个条件:
- 互斥性 (Mutual Exclusion):任意时刻,只能有一个客户端持有锁。这是锁存在的最基本意义。
- 防死锁 (Deadlock Prevention) :即使持有锁的客户端崩溃、网络分区或宕机,锁必须能被自动释放,使其他客户端能够获取锁。通常通过给锁加一个 租约/过期时间 实现。
- 可重入 (Reentrancy) :同一个客户端/线程在持有锁的情况下,可以再次成功获取同一把锁,而不会自己阻塞自己。这要求锁的持有者能被标识,并且锁内部维护一个 重入计数器。
- 锁续期 (Lease Extension/Lock Renewal) :如果客户端持有锁的操作执行时间可能长于锁的初始过期时间,需要一种机制能在后台自动延长锁的生命周期 ,防止操作未完成锁就提前释放。这通常被称为 Watchdog 机制。
注意 :基础的单点 Redis 锁(
SET NX PX)仅能满足前两点。要实现完整的工业级分布式锁,必须解决可重入和锁续期问题,这正是 Redisson 的核心贡献。
1.2 基础实现:SET NX PX 原子加锁
在 Redis 2.6.12 之前,通常使用 SETNX 和 EXPIRE 两个命令组合实现锁。但这存在严重的原子性问题:如果客户端执行 SETNX 成功后在执行 EXPIRE 之前崩溃,将导致死锁 。Redis 2.6.12 版本提供了扩展的 SET 命令,可将加锁与设置过期时间合为一个原子操作:
bash
SET key value NX PX 30000
key:锁的标识,通常为资源 ID 或业务标识。value:客户端唯一标识,建议使用UUID+ 当前线程 ID(如550e8400-e29b-41d4-a716-446655440000:thread-1)。用于安全解锁时校验锁的持有者。NX:仅当 Key 不存在时才设置,保证互斥性。PX 30000:设置 30000 毫秒的过期时间,防止死锁。
Java (Lettuce) 加锁示例:
java
import io.lettuce.core.api.sync.RedisCommands;
public class RedisLock {
// 客户端唯一标识,格式:UUID:线程ID
private static final String LOCK_VALUE = UUID.randomUUID() + ":" + Thread.currentThread().getId();
/**
* 尝试加锁
* @param commands Redis 同步命令接口
* @param lockKey 锁的 Key
* @param expireTimeMs 过期时间(毫秒)
* @return 是否成功加锁
*/
public boolean tryLock(RedisCommands<String, String> commands, String lockKey, long expireTimeMs) {
// SET key value NX PX expireTimeMs
String result = commands.set(lockKey, LOCK_VALUE,
io.lettuce.core.SetArgs.Builder.nx().px(expireTimeMs));
return "OK".equals(result);
}
}
设计意图 :使用 UUID:ThreadId 作为 value 确保了锁持有者的全局唯一性。即便不同 JVM 的线程 ID 可能相同,前缀 UUID 也保证了不会误删对方的锁。
1.3 Lua 脚本安全解锁
解锁时不能简单地使用 DEL key,否则会误删其他客户端的锁(例如客户端 A 操作超时,锁自动释放后被客户端 B 获取,此时 A 尝试解锁就会删除 B 的锁)。因此,解锁必须原子性 地校验 value 是否为自己设置的,校验通过方可删除。这正是 Lua 脚本大显身手之处------Redis 单线程执行 Lua 脚本保证了 get-and-compare-and-del 的原子性(详见系列第 2 篇《线程模型与事件循环》)。
安全解锁 Lua 脚本 (unlock.lua):
lua
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
Java 调用 Lua 解锁:
java
public boolean unlock(RedisCommands<String, String> commands, String lockKey) {
// 加载 Lua 脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
// 执行 eval,KEYS: [lockKey], ARGV: [LOCK_VALUE]
Long result = commands.eval(script, io.lettuce.core.ScriptOutputType.INTEGER,
new String[]{lockKey}, LOCK_VALUE);
return result == 1L;
}
生产要点 :如果业务执行时间可能超过锁过期时间,上述基础锁存在隐患------锁提前释放,A 正在写数据时 B 又获得了锁。因此必须引入 锁续期(Watchdog) 或使用足够长的过期时间。但过长的过期时间又会加剧死锁风险。这便是 Redisson 需要解决的核心矛盾。
1.4 加锁与解锁序列图
以下序列图描述了一个典型的基于 SET NX PX + Lua 解锁的交互流程:
四层说明:
- 参与角色 :客户端 A 与 B 竞争同一把锁
lock:order,Redis Server 作为协调者。 - 时序流程 :A 通过
SET NX PX原子性占锁;B 尝试失败;A 完成业务后通过 Lua 原子解锁;B 方才成功获取锁。 - 关键决策点:Lua 脚本中 value 比较是防止误删的关键。如果 A 的锁因超时被自动删除,其 value 将不匹配,解锁操作返回 0,避免误删 B 的锁。
- 生产影响:此流程保证了互斥性与基本防死锁。但未解决可重入和锁续期。一旦 A 业务执行时间超过 30s,B 将提前获取锁,破坏互斥性。
2. Redisson RLock 的可重入与 Watchdog 机制
Redisson 是一个基于 Redis 的 Java 驻内存数据网格和分布式锁框架,它完全实现了 JUC 的 Lock 接口,并赋予了分布式协调的能力。其核心锁实现 RedissonLock 通过一系列精巧的 Lua 脚本,弥补了基础锁的所有不足。
2.1 RLock.lock() 调用链总览
在 Redisson 中,RLock 最核心的方法是 lock()。其内部流程大致如下:
lock()方法调用tryAcquire(-1, null, threadId),即尝试获取锁,无限等待。tryAcquire调用tryAcquireAsync(waitTime, leaseTime, unit, threadId),返回RFuture<Boolean>。- 异步方法
tryAcquireAsync中,若leaseTime != -1(即用户指定了锁租约时间),则不启动 Watchdog;否则,在加锁成功后,会调用scheduleExpirationRenewal(threadId)启动看门狗。 - 实际执行加锁的是
tryLockInnerAsync()方法,它向 Redis 发送一段精心设计的 Lua 脚本。
2.2 Hash 可重入计数:加锁 Lua 脚本深度解析
Redisson 锁并非使用简单的 String,而是使用 Redis Hash 数据结构。Hash 的 key 是锁的名称,例如 lock:order;Hash 内部 field 是客户端标识符 UUID:ThreadId;其对应的 value 是重入次数。
加锁 Lua 脚本 (核心逻辑):
lua
-- KEYS[1]: 锁的Key (如 lock:order)
-- ARGV[1]: 锁的过期时间 (默认 30000ms)
-- ARGV[2]: 客户端标识 (UUID:ThreadId)
-- 若锁不存在 (exists == 0)
if (redis.call('exists', KEYS[1]) == 0) then
-- 设置 Hash field 为 1,并设置过期时间
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 若锁存在,且是当前线程持有 (hexists == 1)
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 重入计数 +1,并重置过期时间
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 锁被他人持有,返回锁的剩余有效时间 (TTL)
return redis.call('pttl', KEYS[1]);
设计解读:
- 互斥性 :通过
exists判断锁是否空闲;hexists判断是否自己持有。别人持有时返回 TTL,客户端据此进入自旋等待。 - 可重入性 :
hincrby使同一客户端可多次获取锁,field 对应的 value 递增。这正是 Hash 结构相比 String 的优势。 - 防死锁 :每次加锁(包括重入)都
pexpire重置过期时间,避免因客户端崩溃导致 Hash 永存。
为什么是 Hash 而不是 String?
String 只能存储一个值,若想同时表示"持有者"和"重入次数",需要复杂的编码(如 JSON),既增加解析开销又无法保证原子更新。Hash 天然支持字段更新,且 hincrby 是原子操作,完美契合重入计数需求。
2.3 Watchdog(看门狗)自动续期机制
如果用户在 lock() 时未指定 leaseTime(或使用 lock(-1, null)),Redisson 将启动 Watchdog。其核心参数:
internalLockLeaseTime:默认 30000ms ,即锁的默认过期时间。可通过Config.setLockWatchdogTimeout(long timeout)全局调整。- 续期间隔 :
internalLockLeaseTime / 3= 10秒。每 10 秒 Watchdog 会向 Redis 发送续期命令。
Watchdog 的实现依赖于 Netty 的 TimerTask 或 JDK 的 ScheduledExecutorService。核心类是 RedissonLock 中的内部类 ExpirationEntry,其记录了当前线程的重入计数和对应的 Timeout 任务句柄。当锁被初次获取时,会调用 scheduleExpirationRenewal(threadId) 方法:
java
private void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId); // 重入线程计数
} else {
entry.addThreadId(threadId);
renewExpiration(); // 启动续期
}
}
renewExpiration() 方法内部创建定时任务:
java
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) return;
Timeout task = commandExecutor.getConnectionManager().newTimeout(timeout -> {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) return;
Long threadId = ent.getFirstThreadId();
if (threadId == null) return;
// 向 Redis 发送续期 Lua 脚本
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);
ee.setTimeout(task);
}
续期 Lua 脚本:
lua
-- KEYS[1]: 锁Key
-- ARGV[1]: 过期时间 30000ms
-- ARGV[2]: 客户端标识
-- 当前锁仍由该客户端持有
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 重置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;
启动条件 :仅当 leaseTime == -1 时,在 tryAcquireAsync 加锁成功后调用 scheduleExpirationRenewal(threadId)。若用户显式指定了 leaseTime(如 lock(10, TimeUnit.SECONDS)),则不会启动 Watchdog,锁将在到达租约时间后自动释放,适用于执行时间严格可控的短任务。
注意:Watchdog 的续期依赖客户端活性。如果客户端进程长时间 Full GC 挂起(超过 30s),Watchdog 无法续期,锁将过期释放。这是 Redisson 无法避免的,也是后续红锁争议的根源之一。
2.4 unlock():可重入释放与 Pub/Sub 通知
解锁同样通过 Lua 脚本保证原子性:
lua
-- KEYS[1]: 锁Key
-- KEYS[2]: Redisson 的 Channel 名称 (用于通知)
-- ARGV[1]: 解锁消息 (0)
-- ARGV[2]: 过期时间 (30000ms)
-- ARGV[3]: 客户端标识
-- 锁不存在
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
-- 重入计数减1
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;
当锁被完全释放时,Redis 会向 KEYS[2] 指定的频道发布一条消息。Redisson 的 LockPubSub 机制会收到通知,从而唤醒那些调用了 lock() 而在自旋等待(实质是 semaphore.acquire())的线程,使其重新竞争锁。这比无脑轮询高效得多。
2.5 lockInterruptibly 与 tryLock 差异
lockInterruptibly():在等待锁的过程中可响应线程中断InterruptedException。其内部基于semaphore,通过countDownLatch.await()的可中断版本实现。tryLock(waitTime, leaseTime, unit):支持最大等待时间waitTime,超时返回false。若指定leaseTime,则无 Watchdog;否则同样会触发 Watchdog。
| 方法 | 等待策略 | 可中断 | 自动续期(Watchdog) |
|---|---|---|---|
lock() |
无限等待 | 否 | 是 (leaseTime=-1) |
lockInterruptibly() |
无限等待 | 是 | 是 |
tryLock(0, leaseTime, unit) |
仅尝试一次 | 否 | 取决于 leaseTime |
tryLock(wait, leaseTime, unit) |
等待 wait 时长 | 否 | 取决于 leaseTime |
内部实现细节 :当 tryAcquire 返回 false 时,当前线程会订阅该锁的 Pub/Sub 频道,然后进入一个 while(true) 循环,不断调用 tryAcquire 并 semaphore.tryAcquire(waitTime) 直到获取锁或超时。一旦 Pub/Sub 收到释放锁的消息,semaphore.release() 将线程唤醒,重新抢锁。这种"发布-订阅 + 信号量"组合极大地减少了无效轮询。
3. RedLock(红锁)原理与 RedissonRedLock 实现
单点 Redis 锁存在单点故障:一旦实例宕机,所有锁定信息将丢失。即使使用哨兵/Cluster(第 5 篇详述),主从异步复制可能导致锁信息在故障转移时丢失,破坏互斥性。RedLock (红锁) 算法试图通过在多个完全独立的 Redis 实例上取得"多数派"共识来规避这个问题。
3.1 红锁多实例顺序加锁算法
RedLock 假设部署 5 个独立的 Redis 实例(通常为不同物理机,不使用主从复制),客户端执行以下步骤:
- 获取当前时间戳 (T1)。
- 依次向 N 个实例申请锁,使用相同的 Key 和随机 Value。对每个实例的请求都设置一个极短的超时时间(例如 5-50ms,远小于锁总 TTL)。若某个实例不可达或超时,立刻尝试下一个。
- 计算总耗时 :当前时间 (T2) 减去 T1,得到
elapsed = T2 - T1。 - 判定加锁成功 :当且仅当成功加锁的实例数 ≥ N/2 + 1 (即超过半数),并且
elapsed < 锁的总体有效时间 (TTL)时,认为加锁成功。此时锁的有效剩余时间为TTL - elapsed。 - 若失败:向所有已加锁的实例发送 Lua 解锁命令,释放锁。
四层说明:
- 参与角色:客户端与 5 个完全独立的 Redis 实例。
- 时序流程:客户端串行请求各实例,超时极短;半数以上成功且总耗时合理,判定成功;否则释放所有。
- 关键决策点 :过半成功是基础;总耗时校验 至关重要,它确保锁的实际有效时间不会被"请求耗时"蚕食,防止锁在客户端认为有效时实际已到期。
- 生产影响:RedLock 试图在牺牲性能(多次网络往返)和可用性(需要多台独立实例)的代价下,换取比单点锁更高的容错性。然而其对时钟漂移、GC 停顿等假设的脆弱性引发了巨大争议。
3.2 RedissonRedLock 实现
Redisson 提供了 RedissonRedLock 来组合多个 RLock 实例。
java
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://192.168.1.1:6379");
RedissonClient client1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://192.168.1.2:6379");
RedissonClient client2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://192.168.1.3:6379");
RedissonClient client3 = Redisson.create(config3);
RLock lock1 = client1.getLock("myLock");
RLock lock2 = client2.getLock("myLock");
RLock lock3 = client3.getLock("myLock");
// 组合成红锁
RLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
// 尝试加锁,等待100s,租约10分钟,均不开启Watchdog
boolean isLocked = redLock.tryLock(100, 600, TimeUnit.SECONDS);
if (isLocked) {
// 处理业务
}
} finally {
redLock.unlock();
}
RedissonRedLock 内部实现了 lock() 和 tryLock(),逻辑为遍历所有 RLock,根据过半成功规则返回。需要注意的是,红锁通常不依赖 Watchdog,因为每个子锁都设置了明确的 leaseTime。
3.3 红锁的依赖假设
RedLock 的安全性建立在以下三个假设之上:
- 时钟同步 :所有实例和客户端的时钟漂移在可控范围内,
elapsed计算准确。 - GC 停顿可忽略:客户端不会因 GC 或网络问题被暂停超过 TTL。
- 无共享资源:各 Redis 实例完全独立,无主从复制等可能产生数据丢失的机制。
这些假设在现实中的脆弱性是红锁争议的根源。
4. 红锁争议:Martin Kleppmann 的分析
分布式系统大神 Martin Kleppmann 在其著名文章《How to do distributed locking》中对 RedLock 提出了尖锐批评,核心观点是:RedLock 并不能在分布式环境中提供其声称的安全性,因为它依赖的假设(时钟、GC 停顿)在现实中不成立。
4.1 GC 停顿引发的竞态条件
RedLock 的安全性严重依赖"锁过期后,其他客户端一定能感知到并获取锁"这一假设。但 Kleppmann 指出,客户端可能因长时间的 Stop-The-World GC 停顿 而被冻结,导致锁在客户端不知情的情况下过期。
攻击序列:
- 客户端 A 成功获取了 5 个实例中的 3 个锁(过半,TTL=30s)。
- 客户端 A 随后进入长时间的 Full GC,暂停了 35 秒。
- 在此期间,A 持有的所有锁均已过期释放。
- 客户端 B 此时申请锁,成功在 5 个实例上获取锁(过半)。
- 客户端 B 修改了共享存储中的数据。
- 客户端 A 的 GC 结束,它仍然认为自己持有锁,并基于过期数据修改存储,导致 B 的写入被覆盖,产生数据冲突。
四层说明:
- 参与角色:客户端 A、B,独立 Redis 实例组,以及最终需要保护的共享存储系统。
- 时序流程:A 加锁 → GC 暂停超过 TTL → 锁释放 → B 加锁写数据 → A 恢复写脏数据。
- 关键决策点:GC 停顿使锁租约的真实过期时刻与客户端感知的时刻分离。Redis 侧锁已失效,但客户端线程不检查"我是否还持有锁"。
- 生产影响 :此场景证明单靠锁服务本身的过期机制无法保证互斥性,因为 锁的持有者(客户端)本身可能故障 。解决之道在于让存储系统参与防护。
4.2 Fencing Token:从存储层防护
Kleppmann 提出的解决方案是引入 Fencing Token (栅栏令牌) 。每当锁服务颁发一个锁时,同时返回一个全局单调递增的数字作为 Token。客户端在访问共享存储时,必须携带这个 Token。存储系统记录下"当前已处理的最大 Token",并拒绝任何携带小于该 Token 的请求(类似乐观锁的版本号)。在上例中:
- A 获取锁时获得 Token=1。
- A GC 停顿,锁过期。
- B 获取锁时获得 Token=2。
- B 携带 Token=2 写入存储,存储记录
maxToken=2。 - A 苏醒后携带 Token=1 写入,存储发现 1 < 2,拒绝写入,数据安全。
这要求锁服务本身支持生成单调递增的 Token,并且存储系统必须配合校验。Redis 自身难以原生提供全局递增 Token(需额外维护),这正是 etcd/ZooKeeper 等具备 全局递增 Revision/ZXid 机制的系统更适合做严格分布式锁的原因。
4.3 Antirez 的反驳与社区共识
Redis 作者 Antirez 撰文《Is Redlock safe?》回应,核心观点:
- 长时间 GC 停顿本身是小概率事件,且可通过
setvbuf等手段降低。 - 若要求绝对的互斥性,任何带过期时间的锁(包括 etcd)都不安全,因为时钟漂移和网络延迟也会导致类似问题。
- RedLock 是为了提供"高性能、可接受的正确性"的锁,而不是为金融交易设计的绝对锁。
目前社区共识:RedLock 适用于效率提升型场景,而非正确性依赖型场景。如果锁的丢失仅导致重复操作(可幂等处理),RedLock 可接受;若可能导致数据损坏,必须使用 etcd/ZK + fencing token 方案。
5. 替代方案:etcd/Consul + fencing token
当业务场景要求金融级数据一致性,不能容忍极低概率的竞态时,应转向基于 Raft/Paxos 等强一致性协议的协调服务。
5.1 etcd 分布式锁与 Fencing Token
etcd 使用 Raft 共识算法,提供强一致性 KV 存储。其事务和租约机制天然适合实现安全锁。
关键原语:
- Lease (租约):客户端可与 etcd 创建租约(带 TTL),并周期性续约。若客户端崩溃,租约到期后自动释放与之绑定的 Key。
- Transaction (事务) :利用
if-not-exists语义原子创建 Key,实现加锁。 - 全局 Revision :etcd 的每个写操作都会导致一个全局、单调递增的
ModRevision。这正是实现 Fencing Token 的完美载体。
加锁流程 (Java jetcd 示例):
java
// 1. 创建带 TTL 的租约
long leaseId = leaseClient.grant(30).get().getID();
// 2. 事务加锁:创建 key "/lock/order",并绑定租约
ByteSequence lockKey = ByteSequence.from("/lock/order", StandardCharsets.UTF_8);
Txn txn = kvClient.txn()
.If(new Cmp(lockKey, Cmp.Op.PUT, CmpTarget.createRevision(0))) // 如果 Key 不存在
.Then(Op.put(lockKey, ByteSequence.from("unique-value"), PutOption.newBuilder().withLeaseId(leaseId).build()))
.Else(Op.get(lockKey));
TxnResponse txnResp = txn.commit().get();
long fencingToken;
if (txnResp.isSucceeded()) {
// 加锁成功,获取创建的 Revision 作为 fencing token
fencingToken = txnResp.getPutResponses().get(0).getHeader().getRevision();
} else {
// 加锁失败,可从 Else 响应中获取当前锁信息
}
解锁 :kvClient.delete(lockKey),或直接让租约过期。
Fencing Token 校验 :存储系统需记录 maxProcessedRevision。客户端携带 fencingToken 写数据时,存储层进行 if token > maxProcessedRevision 校验,更新并执行写入;否则拒绝。
四层说明:
- 参与角色:客户端 A、B,etcd 集群,带 Token 校验的存储层。
- 时序流程:A 取锁得 Token=33 → GC 停顿锁释放 → B 取锁得 Token=35 写存储 → A 用过期 Token 写被拒。
- 关键决策点:etcd 内建全局 Revision 作为 Fencing Token,实现简单且可靠。存储层拒绝低 Token 是安全性的最后防线。
- 生产影响:etcd 方案以增加运维复杂度和牺牲部分性能(Raft 共识耗时)为代价,换取了远高于 RedLock 的正确性保证。
5.2 ZooKeeper 临时顺序节点锁
另一种经典的 CP 锁实现是 ZooKeeper 的临时顺序节点方案:
- 客户端在锁的 ZNode 下创建一个 临时顺序节点。
- 客户端获取该 ZNode 下所有子节点,检查自己是否序列号最小。若是,则获得锁;否则对前一个节点注册 Watcher,等待其删除。
- 临时节点保证客户端崩溃后自动删除;顺序保证公平性。ZK 的 ZXid 也可作为 Fencing Token。
与 etcd 类似,ZK 提供强一致性保证,但同样受限于 CP 系统的可用性瓶颈。
5.3 场景化选型建议
| 需求场景 | 推荐方案 | 原因 |
|---|---|---|
| 高并发、性能敏感(如秒杀库存扣减),可接受极低概率撞锁 | Redis 单点 / Redisson | 吞吐量高,延迟低。通过 Watchdog 解决大部分续期问题。结合 Lua 无锁化可进一步降低依赖。 |
| 分布式任务调度,需保证任务不重复执行 | Redisson (单点 + Watchdog) | 任务执行时间不确定,Watchdog 可有效防止锁提前释放。Redis 主从概率性丢失可通过运维补偿。 |
| 金融记账、主键分配等要求强一致性 | etcd / ZooKeeper + Fencing Token | 必须杜绝任何并发写入。存储层校验 Fencing Token 提供端到端的正确性。 |
| 跨数据中心/跨云部署,对可用性要求极高 | 考虑不依赖分布式锁的最终一致性方案 | 网络分区是常态,CP 系统可能牺牲可用性(少数分区不可写)。更推荐幂等写入、无锁 CAS 设计。 |
6. 无锁化方案:原子操作与 Lua 脚本
并发控制的最高境界是无锁化。如果能利用 Redis 的单线程特性,通过原子命令和 Lua 脚本直接完成数据变更,则完全无需在应用层使用分布式锁,性能与健壮性达到最优。
6.1 INCR/DECR 原子计数
库存扣减最简单的无锁化方案是利用 Redis 的 String 类型自增/自减命令,它们是天然原子的。
java
// 初始化库存 (线程安全,可执行一次)
redisCommands.set("stock:prod:1001", "1000");
// 扣减库存:DECR 命令,返回扣减后值
Long stock = redisCommands.decr("stock:prod:1001");
if (stock >= 0) {
// 扣减成功,处理业务
} else {
// 库存不足,需要回滚:INCR
redisCommands.incr("stock:prod:1001");
throw new BusinessException("库存不足");
}
注意 :此方案要求先 DECR 后判断,若不足需 INCR 回滚。在高并发下,"回滚"意味着短暂超卖但最终被纠正,库存会经历"先负后正"的过程,是否可接受需业务评估。对于秒杀等场景,通常可以接受最终的负库存再回滚,因为用户感知是库存已尽。
6.2 Lua 脚本保证复合操作原子性
若操作包含"判断 + 扣减"等复合步骤,Lua 脚本可一劳永逸保证原子性。
库存检查与扣减 Lua 脚本:
lua
local key = KEYS[1] -- 库存 Key
local request = tonumber(ARGV[1]) -- 请求数量
local stock = tonumber(redis.call('get', key) or 0)
if stock >= request then
redis.call('decrby', key, request)
return 1 -- 成功
else
return 0 -- 库存不足
end
Java 调用 (Lettuce):
java
String script = "local stock = tonumber(redis.call('get', KEYS[1]) or 0) " +
"if stock >= tonumber(ARGV[1]) then " +
"redis.call('decrby', KEYS[1], ARGV[1]) return 1 else return 0 end";
Long result = commands.eval(script, ScriptOutputType.INTEGER,
new String[]{"stock:prod:1001"}, String.valueOf(1));
if (result == 1L) {
// 扣减成功
}
优势:一次网络往返,原子执行,无锁等待,无死锁风险。在高并发秒杀场景中,这是对数据库压力最小的方案之一。
6.3 RPOPLPUSH 安全队列消费
在消息队列或任务队列场景中,消费者从队列中取出消息进行处理,若处理过程中崩溃,消息将丢失。Redis 的 RPOPLPUSH(6.2.0+ 推荐 BLMOVE)提供了安全的消息消费模式。
bash
# 从 processing_queue 尾部取出消息,并将其原子地推入 backup_queue 头部
RPOPLPUSH processing_queue backup_queue
消费者流程:
- 调用
BRPOPLPUSH(阻塞版本) 从主队列取出消息,同时放入"处理中"备份队列。 - 处理业务逻辑。
- 处理成功后,
LREM backup_queue -1 message将消息从备份队列删除。 - 若消费者崩溃,消息仍安全存储在
backup_queue中。可另起监控程序定期扫描备份队列,将滞留超时的消息重新LPUSH回主队列进行重试。
此模式无需锁,完全依赖 Redis 的原子命令保证了消息"至少一次消费",实现高可靠任务队列。
6.4 无锁化方案的局限性
无锁化虽好,但并非万能。其使用范围局限于单步原子操作 或Lua 脚本能完整封装的纯内存交互。若操作涉及多个外部系统(例如扣减库存后调用支付网关),则必须使用分布式锁保证整体原子性。此外,Lua 脚本执行时会阻塞 Redis,应避免耗时过长的逻辑。
7. 面试高频专题
本节以高度详细的方式回答面试中的高频问题,每个问题均包含:一句话总结 、详细原理解析 、多角度深度追问 、加分回答或实战要点。
1. 分布式锁需要满足哪些核心要求?Redis 如何通过 SET NX PX 实现基础锁?
一句话回答 :互斥性、防死锁、可重入、锁续期;SET key value NX PX timeout 原子性地创建带过期时间的 Key,value 标识持有者。
详细原理解析:
- 互斥性 :
NX参数保证 Key 不存在时才能设置成功,若 Key 已存在,命令返回nil。这就确保了同一时刻只有一个客户端能成功设置该 Key,获得锁。 - 防死锁 :
PX参数为 Key 设置一个有限的生存时间(毫秒)。即使客户端崩溃、未主动解锁,Redis 也会在 TTL 到期后自动删除该 Key,其他客户端便可重新获取锁。这就从根本上避免了永久死锁。 - 持有者标识 :
value字段必须唯一标识客户端和线程,通常为UUID:threadId。这是安全解锁的基础------只有锁的持有者才能释放锁,避免误删其他客户端的锁。 - 原子性保证 :
SET ... NX PX在单条命令中完成"检查是否存在、设置值、设置过期"三个动作,杜绝了旧方案SETNX+EXPIRE的两步非原子性引发的死锁风险。
多角度深度追问:
-
追问 1:为什么 value 必须具有全局唯一性?
如果仅用简单的固定字符串(如
"locked"),当客户端 A 持有的锁因超时自动过期后,客户端 B 成功获取了同一把锁。此时 A 执行解锁时,无法区分锁是否为自己持有,会直接DEL删除,导致 B 的锁被误删,互斥性被破坏。通过UUID:threadId的 value,解锁 Lua 脚本可以"先 GET 比对,再 DEL",确保只有加锁者本人才能解锁。 -
追问 2:如果不设置过期时间或设为 0,会发生什么?
完全不设置过期时间,一旦持有锁的客户端崩溃,该锁将永久存在(死锁),其他所有客户端都无法再获取锁,导致业务完全停滞。这是生产环境中最严重的事故之一。因此,在基础实现中,
PX参数是强制性的安全底线。 -
追问 3:
SET NX PX能否满足可重入要求?不能。当同一个线程第二次尝试获取同一个 Key 时,
SET ... NX会因为 Key 已存在而返回失败,线程会把自己阻塞住。可重入要求锁内部能够识别"当前持有者就是我",并允许再次进入并记录重入次数。这需要更复杂的数据结构和逻辑,正是 Redisson 等框架解决的范畴。 -
追问 4:基础锁在业务时间超时后有什么风险?
锁可能在业务逻辑尚未完成时自动释放,此时其他客户端成功获取锁并开始修改同一资源,导致数据错乱。典型场景:A 获取锁执行数据库写操作,因网络抖动延迟;锁过期,B 获取锁也开始修改;A 的网络恢复后继续执行写入,覆盖 B 的数据。因此,除非能保证业务执行时间严格小于锁过期时间,否则必须配合锁续期机制(Watchdog)。
加分回答:
- 原子命令演进历史 :在 Redis 2.6.12 之前,加锁需使用
SETNX mylock value然后EXPIRE mylock 30。若客户端在两条命令之间崩溃,将导致无过期时间的死锁。这也是为什么生产环境绝对禁止使用老旧的SETNX方案。 - 生产最佳实践 :value 的生成可包含更多上下文,如机器 IP、时间戳等,便于排查死锁问题时通过
GET命令快速定位是哪台机器、哪个线程持有锁。
2. Redisson 的 RLock 是如何实现可重入的?Hash 结构存储了什么?
一句话回答 :使用 Redis Hash,Key 为锁名,Field 为 UUID:ThreadId,Value 为重入次数;加锁时通过 hexists 判断自己是否已持有,是则 hincrby 递增。
详细原理解析:
- 数据结构选择:为什么不使用 String?String 只能存一个标量值,若要同时存储"持有者 ID"和"重入计数",必须采用 JSON 编码,但原子性更新 JSON 中的计数需要 CAS 或 Lua 脚本,不够简洁。Hash 结构天然支持多个 field,每个 field 可独立原子增减,完美契合需求。
- 加锁 Lua 脚本解析 :
exists(KEYS[1]) == 0:如果整个 Hash 不存在,说明锁空闲。执行hincrby(KEYS[1], ARGV[2], 1)将UUID:ThreadId的 field 初始化为 1,然后pexpire设置整体过期时间。返回nil表示加锁成功。hexists(KEYS[1], ARGV[2]) == 1:如果锁存在且自己已经是持有者。执行hincrby将重入计数加 1,并重置过期时间。再次返回nil加锁成功。- 以上两者都不成立:锁被他人占有。返回
pttl(KEYS[1])表示锁的剩余生存时间(毫秒),客户端据此决定等待时间或立即放弃。
- 解锁 Lua 脚本解析 :先校验
hexists,再hincrby -1,若计数归零则del删除整个 Key 并通过publish唤醒等待者。
多角度深度追问:
-
追问 1:Hash 的过期时间设置在哪里?是所有 field 共用一个 TTL 吗?
Redis 的过期时间(TTL)是作用于整个 Key 的,无法对 Hash 内部的某个 field 单独设置过期时间。Redisson 通过在每次加锁或重入时对整个 Hash Key 调用
pexpire重置过期时间。只要线程持续重入或 Watchdog 续期,这个 Key 就永不过期。 -
追问 2:如果同一个线程多次重入,解锁时需要多少次 unlock?
需要对称的
lock()和unlock()次数。每次lock()使 field 的 counter 加 1,unlock()使其减 1。当 counter 减到 0 时,锁才被真正释放。这与 JUC 的ReentrantLock语义完全一致,因此一个线程内可以像使用本地锁一样使用分布式锁,例如递归调用或嵌套保护。 -
追问 3:如果线程 A 持有锁,线程 B 在
lock()中等待,A 解锁后 B 如何立即感知?Redisson 使用 Redis 的发布/订阅功能。当 A 完全释放锁时,解锁 Lua 脚本执行
publish channelName 0。所有正在等待这把锁的客户端都订阅了该 channel,收到消息后,信号量释放,B 被唤醒并重新执行加锁 Lua 脚本竞争锁。这比轮询(如每隔 500ms 尝试一次)更高效、延迟更低。 -
追问 4:Hash 结构下,如何调试当前锁的持有状态?
可以直接登录 Redis 执行
HGETALL lockKey,将看到形如{UUID:thread1: 3, UUID:thread2: 1}的结果,可知哪些线程持有锁及重入次数。再执行PTTL lockKey查看剩余过期时间。这在排查锁死锁或未释放问题时非常有用。
加分回答:
- 连接断开后的锁释放 :如果
RedissonClient关闭(shutdown())或与 Redis 连接完全断开,Watchdog 自然停止,锁会在 30s 后自动过期,不会造成死锁。然而,显式指定leaseTime的锁也可能因客户端崩溃而到期释放。因此,Redisson 的锁不会永久死锁,但可能产生较短时间的"假死"状态(最多 30s)。 - 并发度优化:高并发下大量线程竞争同一把锁,会瞬间全部订阅 Pub/Sub 频道,解锁时同时被唤醒,称为"惊群效应"。Redisson 通过信号量公平排序、随机退避等机制缓解,但无法完全消除。对于热点锁,应考虑无锁化设计或分段锁。
3. Redisson 的 Watchdog 机制是如何工作的?什么情况下不开启 Watchdog?
一句话回答 :Watchdog 是一个后台定时任务,每 internalLockLeaseTime/3 (10s) 通过 Lua 脚本续期 30s;当用户显式指定 leaseTime 时不启动。
详细原理解析:
- 触发条件 :在
tryAcquireAsync中,若leaseTime == -1(即用户未指定租约时间),获取锁成功后调用scheduleExpirationRenewal(threadId)启动 Watchdog。 - 核心实现 :内部维护一个
EXPIRATION_RENEWAL_MAP,Key 是锁的 EntryName,Value 是ExpirationEntry对象,记录了线程 ID 和定时器句柄。renewExpiration()方法递归调度自己,形成每 10s 执行一次的周期任务。每次执行时,调用renewExpirationAsync(threadId)向 Redis 发送续期 Lua 脚本。 - 续期脚本 :
hexists检查当前线程是否仍持有锁,若是则pexpire重置过期时间为 30s。若hexists返回 0,表示锁已被释放或由于某种原因丢失,续期自动终止。 - 停止时机 :当线程调用
unlock()且计数器归零时,调用cancelExpirationRenewal(threadId)从EXPIRATION_RENEWAL_MAP中移除 entry 并取消定时器。
多角度深度追问:
-
追问 1:为什么续期间隔是
internalLockLeaseTime/3(默认 10s),而不是接近过期时再续?这是为了提供安全冗余。如果在锁即将过期(如最后 1s)才续期,一旦此时网络闪断或 Redis 轻微阻塞,续期命令可能延迟或失败,导致锁被意外释放。每 10s 续一次,即便某次续期失败,还有下一个 10s 窗口重试。这种"提前量"设计在分布式环境中是必要的。
-
追问 2:如果 Watchdog 线程因为 GC 停顿而暂停超过 30s 会怎样?
续期失败,Redis 中的锁 Key 会在 30s 后过期自动删除。此时其他客户端可以获取锁。当原客户端 GC 结束后,它仍认为自己持有锁,并继续执行业务逻辑,可能导致并发冲突。这正是 Martin Kleppmann 指出的分布式锁的根本脆弱性:客户端自身的停顿无法由锁服务检测。因此,Redisson 的锁只能防止客户端崩溃造成的死锁,无法防止客户端"假死"。
-
追问 3:为什么显式指定
leaseTime会禁用 Watchdog?用户显式指定租约时间,通常意味着业务有明确的时间界限(如"此任务必须在 10s 内完成")。设计上希望锁能自动到期释放,而不被无限续期。如果此时仍开启 Watchdog,续期将破坏用户的意图,导致锁长时间不释放。同时,无 Watchdog 的锁资源消耗更小。
-
追问 4:
tryLock(waitTime, leaseTime, unit)中leaseTime设为null或负数会怎样?Redisson 将
null或-1视为未指定,从而自动启用 Watchdog。若传入0,会立即失败,因为leaseTime为 0 的锁没有实际意义。
加分回答:
- 全局配置 :可通过
Config.setLockWatchdogTimeout(60000)修改默认的 30000ms 为 60s。这会同时影响续期间隔(变为 20s)。生产环境中若业务逻辑普遍执行时间较长,可适度调大此值,但会增加死锁后其他线程的等待时间,需要权衡。 - Watchdog 的资源开销 :每个持锁线程都会有一个定时器任务。大规模系统数万线程同时持有不同的锁,Watchdog 定时器会占用一定的内存和 CPU 资源。因此对于短任务,建议显式设置
leaseTime以减少开销。
4. RedLock(红锁)的算法原理是什么?过半成功判定如何实现?
一句话回答:向 N 个独立 Redis 实例顺序加锁,每个设置短超时,若成功数 ≥ N/2+1 且总耗时 < 锁 TTL,视为成功;否则释放所有已得锁。
详细原理解析:
- 实例数量:建议 N=5,可容忍 2 个实例故障。数量为奇数易于形成多数派。
- 短超时请求:每个实例的加锁请求设置极短的超时(如 1~50ms),避免向某个慢速或故障的实例等待过久,拉长总耗时。
- 总耗时校验 :即使获得多数派,如果
elapsed = 加锁结束时间 - 开始时间已经接近或超过 TTL,那么实际锁的有效时间(TTL - elapsed)可能为零或负值,锁实际已失效。因此,elapsed < TTL是保证锁有效性的关键。 - 失败回滚:如果未能在多数实例上加锁,必须向所有已成功加锁的实例发送 Lua 解锁脚本,释放部分持有的锁,防止残留锁阻塞其他客户端。
多角度深度追问:
-
追问 1:如果 5 个实例中,客户端 A 在 3 个实例加锁成功,耗时 200ms,但锁总 TTL 为 10s,有效时间还有 9.8s。此时客户端 B 尝试加锁,会发生什么?
B 也会依次向 5 个实例请求,对于 A 已加锁的 3 个实例,
SET NX会返回失败(Key 已存在);对于另外 2 个实例,可能会成功。B 最多获得 2 个成功(<3 个),无法满足过半要求,加锁失败。因此互斥性得到保证。 -
追问 2:RedLock 为何要求各实例完全独立,不能使用主从复制?
主从复制是异步的。如果客户端 A 在主节点上成功加锁,该数据尚未复制到从节点,主节点就宕机了。哨兵提升从节点为新主节点,但新主节点中没有 A 的锁信息。此时客户端 B 可以在新主节点上加锁成功,导致 A 和 B 同时认为自己持有锁,破坏互斥性。这就是著名的"主从切换丢锁"问题。RedLock 通过完全独立、无复制的实例规避此风险。
-
追问 3:如果客户端在加锁成功后,业务执行期间,部分实例的时钟发生漂移(比实际快),会有什么影响?
时钟走得快的实例会提前将锁过期释放。如果漂移严重,可能导致过半实例的锁提前过期,此时其他客户端可能成功获取锁,再次形成并发。因此,RedLock 依赖 NTP 等时钟同步机制保持各节点时钟偏差在可接受范围内。
-
追问 4:RedLock 的性能瓶颈在哪?
串行请求多个实例增加了网络延迟(至少 2~3 个 RTT 之和)。每次加锁都需要多次网络往返,延迟明显高于单点 Redis。同时,5 个实例的运维成本和资源消耗也更高。
加分回答:
- RedLock 的学术争论 :Antirez 提出 RedLock 的目的是在"没有完全可靠的协调服务"的环境下,提供一个比单点锁更容错的方案。但 Martin Kleppmann 认为,如果连 etcd/ZK 这样的 CP 系统都不能承受,RedLock 的这种"弱多数派"机制在 GC 停顿面前同样不安全。这引出了分布式系统一个深刻的结论:在异步网络中,无法完全区分"节点故障"和"节点缓慢",任何依赖超时的锁方案在理论上都是不安全的。
5. 红锁存在什么争议?Martin Kleppmann 是如何论证红锁可能违反互斥性的?
一句话回答:客户端因 GC 停顿或网络延迟,在锁过期后仍认为自己持有锁,导致多个客户端同时写数据;红锁缺少 fencing token 机制从存储层防护。
详细原理解析:
- Kleppmann 构造了一个攻击序列:客户端 A 成功获取红锁 → A 进入长时间 GC 停顿(超过锁 TTL)→ 在此期间 Redis 中 A 的锁全部过期 → 客户端 B 获取红锁成功并修改共享资源 → A 的 GC 结束,它仍认为自己持有锁,继续修改共享资源,覆盖 B 的写入。问题根源在于,客户端 A 在 GC 期间已失去锁的持有权,但它没有任何机制来感知这一点。 锁服务单方面宣布锁过期,而原持有者浑然不知。
- 这个场景不局限于 GC,网络延迟、操作系统调度暂停、虚拟机迁移等都可能导致类似效果。
多角度深度追问:
-
追问 1:Watchdog 能否解决这个 GC 停顿问题?
不能。Watchdog 运行在客户端进程中,GC 停顿会同时冻结业务线程和 Watchdog 定时器。Watchdog 无法在此期间发送续期命令,所以锁依然会过期。Watchdog 解决的是"业务执行时间过长导致锁提前释放"的问题,却无法解决"客户端本身假死"的问题。
-
追问 2:Kleppmann 认为分布式锁的正确用途是什么?
他区分了两种用途:
- 效率提升(Efficiency):用于避免重复工作,如防止多个客户端同时执行同一任务(幂等操作)。这类场景下锁丢失导致的重复执行通常可接受。
- 正确性依赖(Correctness):用于保证共享资源的并发访问正确性,如金融记账、文件系统修改。这类场景下锁丢失将导致数据错误,不可接受。Kleppmann 认为 RedLock 最多适用于第一类场景。
-
追问 3:什么是 fencing token?为什么它能够解决该问题?
Fencing token 是由锁服务在授予锁时返回的一个全局单调递增的数字。共享资源服务器(如数据库、文件系统)记录下"最后处理的 token"。任何写请求必须携带 token,服务器拒绝所有 token 小于或等于已处理 token 的请求。即使旧客户端在 GC 后重新苏醒,其旧的 token 也会被拒绝。这实际将"锁正确性"的一部分责任转移给了资源服务器。
-
追问 4:Antirez(Redis 作者)如何回应?
Antirez 认为,如果苛刻要求绝对的正确性,即使是 ZK 或 etcd 实现的锁也无法完全避免类似问题,因为它们的 session 超时和心跳也是基于时间假设的。如果客户端停顿时间超过 session 超时,ZK 也会删除其临时节点,同样可能发生类似的并发。因此,他认为 RedLock 作为一种实用的工程方案,在大多数情况下已足够安全,且性能更优。
加分回答:
- 区分"锁的安全问题"与"数据一致性问题":此争论的本质在于,仅靠客户端侧锁无法提供端到端的一致性。fencing token 机制实际上是让锁服务与资源服务协同工作,这是分布式系统中实现强一致性的通用模式。
- CAP 理论的体现:Redis 单点是 AP 系统(可用+分区容错),ZK/etcd 是 CP 系统(一致+分区容错)。RedLock 试图用多个 AP 节点构建一个"CP 幻觉",但被证明在特定故障下仍回归 AP 特性。若业务真正需要 CP,直接使用 CP 系统是更自然的选择。
6. 什么是 fencing token?如何通过 etcd/Consul 实现带 fencing token 的分布式锁?
一句话回答:锁服务返回全局递增 token,客户端读写存储必须携带 token,存储拒绝小于已处理最大 token 的请求。
详细原理解析:
- etcd 的天然优势 :etcd 的每个写操作都会分配一个全局唯一的
ModRevision,该值单调递增且跨整个集群一致。当客户端通过事务成功创建锁 Key 时,可以获得该 Key 的CreateRevision作为 fencing token。 - 实现步骤 :
- 客户端创建 Lease(租约),并定期 keep alive。
- 执行事务:
If(Key不存在) Then(Put(Key, value, Lease)) Else Get(Key)。 - 若事务成功,从
PutResponse.Header.Revision获得 fencing token。 - 业务操作携带 token 写入资源服务器。
- 资源服务器维护
maxToken,原子比较并更新:if token > maxToken: maxToken = token; execute write; else reject。
多角度深度追问:
-
追问 1:fencing token 必须是全局递增吗?用随机 UUID 行不行?
必须全局单调递增。随机 UUID 无法进行大小比较,只能通过集合判重实现"使用后即失效",但这要求资源服务器存储所有已用过 token 的集合,无法水平扩展且内存无限增长。递增数字只需要维护一个
maxToken,比较操作 O(1) 且状态极小。 -
追问 2:Consul 如何实现 fencing token?
Consul 提供
session和KV Store,其ModifyIndex也是全局递增的,机制与 etcd 类似。使用 consul-api 可在acquire成功时获得ModifyIndex作为 token。 -
追问 3:如果资源服务器不支持 token 校验,能否在应用层实现类似效果?
可以,但安全性下降。应用层可以在获取锁后立即从 Redis 的
INCR一个全局计数器获取 token,然后写入资源时附带此 token。但应用层的 token 生成与锁获取不是原子操作,存在微小窗口:A 获锁 → B 获锁(A 未及时生成 token)→ A 和 B 可能获得相同或错序的 token。因此,理想的 fencing token 必须由锁服务原子生成。 -
追问 4:etcd 锁和 RedLock 在性能上有何差异?
etcd 基于 Raft,每次加锁事务需要多数节点落盘确认,延迟通常在数毫秒到数十毫秒(受网络和磁盘影响)。RedLock 只需多次 Redis 内存操作,微秒级延迟。因此 RedLock 性能显著优于 etcd,适合对延迟敏感的缓存锁场景;etcd 适合对正确性要求严格的关键资源锁。
加分回答:
- Chubby 的启示:Google 的 Chubby 是带 fencing token 锁服务的鼻祖。它使用 Paxos 共识,并提供"lock sequencer"概念,正是 fencing token 的早期实践。etcd 深受 Chubby 影响。
- 混合方案:一些系统设计为"读用 Redis,写用 etcd 锁",在保证读性能的同时,在关键写路径上使用强一致性锁,实现性能与正确性的平衡。
7. Redis 分布式锁 vs etcd 分布式锁:各自适用什么场景?
一句话回答:Redis 锁高性能、低延迟,适合高并发、可接受极低概率失败的场景;etcd 锁强一致、带全局 Token,适合金融、记账等绝对正确场景。
详细原理解析:
- Redis 方案特征:基于内存,单线程处理命令,锁操作 O(1) 且延迟在亚毫秒级。支持 Watchdog 续期,客户端 API 丰富(Redisson)。但 Redis 基于 AP 设计,主从异步复制,cluster 分片也不保证强一致,单点故障可能丢失最近写入的锁。适合如防止重复提交、缓存击穿防护、幂等任务分配等场景。
- etcd 方案特征:基于 Raft,每次锁操作需多数派节点持久化日志,延迟毫秒级。提供 Lease 自动过期、全局递增 Revision 作为 fencing token。牺牲了部分性能,换取了强一致性保证。适合如分布式主键生成、金融账务协调、跨系统事务协调等场景。
多角度深度追问:
-
追问 1:在秒杀场景下如何选型?
秒杀本质是高并发下的库存扣减,核心诉求是性能。推荐使用 Redis Lua 脚本无锁扣减,或结合 Redisson 锁进行分桶后的并行扣减。若使用 etcd 锁,数十毫秒的延迟会让吞吐量急剧下降,成为系统瓶颈。
-
追问 2:如果业务无法接受任何锁丢失,但又要高性能怎么办?
这种情况下,考虑将资源访问设计为"乐观并发控制 + 重试"模式。例如,使用数据库的版本号(version)进行 CAS 更新,失败则重试。这完全绕过了分布式锁,利用数据库的强一致事务保证。Redis 可以充当缓存,加速读取,但写入最终由数据库把关。
-
追问 3:有没有同时兼顾两者的方案?
可以在应用层实现两级锁:本地 JVM 锁 + 分布式锁。JVM 锁减少大部分同进程内的竞争,只有跨节点时才走到分布式锁。此外,使用 Redisson 的 MultiLock 可以组合多个 Redis 实例提升可靠性,但性能会成倍下降。
-
追问 4:部署和维护 etcd 集群有哪些挑战?
etcd 集群至少需要 3 个节点,运维复杂度较高,需要关注磁盘 IO 性能(Raft 日志落盘)、网络延迟、定期备份、升级兼容性等。相比之下,Redis 通常已有现成集群可用,运维成本低,这也是一些团队优先选择 Redis 锁的现实原因。
加分回答:
- 引用 CAP 理论精确选型 :Redis Cluster 是典型的 AP 系统(分区时保证可用性,牺牲一致性);etcd 是 CP 系统(分区时保证一致性,牺牲少数派可用性)。需要高可用、低延迟选 AP;需要严格正确性选 CP。在分布式锁的战场上,不存在同时满足 CP 和高性能的方案。
8. 如何通过 Lua 脚本实现无锁化的库存扣减?与分布式锁方案相比有什么优势?
一句话回答 :将"判断库存 ≥ 请求量 → 扣减"逻辑写入 Lua 脚本,EVAL 原子执行;优势是无锁等待、无死锁、单次网络往返,吞吐量最高。
详细原理解析:
- Lua 脚本原子性 :Redis 以单线程执行 Lua 脚本,整个脚本从开始到结束不会被其他命令打断。因此,
get stock; if stock >= num; decrby stock num这三步操作构成一个原子事务。 - 使用方式 :客户端通过
EVAL script numkeys key [key ...] arg [arg ...]提交脚本和参数,获得成功/失败返回值。脚本需预注册或每次发送(建议使用 Redis 7 的FUNCTION管理脚本)。
多角度深度追问:
-
追问 1:Lua 脚本方案会超卖吗?
不会。因为脚本内的
get、if、decrby是原子执行。但需注意:并发下多个脚本可能先后读到库存大于请求量,而只有第一个脚本的decrby能真正成功,后续脚本可能在判断通过后执行扣减时库存已不足?实际上,判断和扣减在同一原子脚本内,如果库存为 10,三个请求各请求 5,第一个脚本执行后库存变 5,第二个脚本读到的就是 5,只能成功一个。因此不会超卖。但可能出现"部分请求判断通过但库存已无"的情况,脚本应正确处理负值。 -
追问 2:Lua 脚本执行时间过长会怎样?
会阻塞整个 Redis 实例,其他所有命令排队等待。因此,Lua 脚本必须轻量、快速,严禁执行网络 IO 或复杂计算。库存扣减这类 O(1) 操作非常适合;若涉及大量数据扫描(如
KEYS *),则绝对禁止。 -
追问 3:与 Redisson 分布式锁相比,无锁化 Lua 的性能提升有多大?
分布式锁通常需要最少 3 次网络往返:加锁(SET)→ 执行命令(GET/DECR)→ 解锁(EVAL)。Lua 只需 1 次 EVAL 往返。在低延迟环境(如 0.5ms RTT),有锁方案至少 1.5ms,Lua 方案 0.5ms,吞吐量有数量级提升。此外,无锁避免了锁争抢导致的线程阻塞和上下文切换。
-
追问 4:无锁化方案有什么局限性?
只能用于操作可完全在 Redis 内原子完成的场景。一旦涉及外部数据库或服务调用,Lua 脚本无法覆盖,只能退回到分布式锁。此外,复杂的业务逻辑用 Lua 编写和维护成本高,排查困难。
加分回答:
- Redis Functions(Redis 7+)优势 :相比于
EVAL每次传输脚本,FUNCTION可以将脚本持久化在服务端,提供版本控制和更好的性能。在库存扣减场景中,可以将扣减逻辑封装为函数check_and_dec,客户端只需FCALL check_and_dec 1 stock:1001 2。 - 热点库存的分桶 :对于秒杀的热点商品,可以将单一库存 Key 拆分为 N 个 bucket,如
stock:prod:1:0到stock:prod:1:9。扣减时随机选取一个 bucket 进行 Lua 扣减,进一步分散单 Key 的压力,提升并发能力。这是无锁化方案的经典扩展。
9. RPOPLPUSH 是如何实现安全队列消费的?为什么它不需要锁?
一句话回答 :利用 RPOPLPUSH 将消息从主队列原子地移动到备份队列,消费者崩溃后消息仍在备份队列,可被监控程序重入主队列。
详细原理解析:
- 原子移动语义 :
RPOPLPUSH source destination从source列表的右端弹出一个元素,并将该元素原子地推入destination列表的左端。整个操作是原子的,不会有中间状态。 - 可靠性模型 :消费者执行
BRPOPLPUSH processing_queue backup_queue,获取消息的同时将消息备份。消费者处理成功后,调用LREM backup_queue -1 message显式删除。若消费者崩溃,备份队列中留有未处理的消息。监控进程定期扫描backup_queue,通过LLEN或LRANGE检查,将超时未删除的消息RPOPLPUSH backup_queue processing_queue重新投递回主队列。这就实现了"至少一次"消费保证。
多角度深度追问:
-
追问 1:如何保证消息不会被重复消费?
无法绝对保证。如果消费者处理完业务但未执行
LREM时就崩溃,消息会重新投递,导致重复消费。因此要求消费者业务逻辑实现幂等性(如根据唯一消息 ID 去重)。 -
追问 2:
BLMOVE与RPOPLPUSH有何不同?Redis 6.2.0 引入
BLMOVE(阻塞版本),支持不同方向移动,如BLMOVE source destination RIGHT LEFT,等价于BRPOPLPUSH。推荐在新版本中使用BLMOVE,更通用。 -
追问 3:备份队列中的消息长期堆积怎么办?
可能是消费者全部崩溃或处理能力不足。监控程序应设置安全阈值,告警并触发自动扩容或限流。此外,可以设置
EXPIRE为备份队列设置整体 TTL 以防止无限堆积。 -
追问 4:与 Redis Stream 的消费者组相比,
RPOPLPUSH方案有什么劣势?- 功能较弱:不支持消费者组、消息确认、消息持久化历史。
- 错误处理:消息确认与业务处理非原子,可能丢消息或重复。
- Redis Stream(详见后续系列)提供了更完备的消费者组、Pending 消息列表,是构建可靠消息队列的首选方案。
加分回答:
- 无锁化的精髓 :
RPOPLPUSH方案的精妙之处在于,将多步操作(取出 + 备份)设计为一个原子命令,从而避免了在应用层用锁保护这两个操作。这是对 Redis 命令能力的充分理解和利用,是架构设计上的减法。 - 实际应用:Resque、Celery 等早期任务队列框架都采用了类似的原子备份模式。
10. Redisson 的 tryLock 和 lock 有什么区别?leaseTime 参数影响什么?
一句话回答 :lock() 无限等待,不可中断;tryLock() 支持等待超时和中断;leaseTime 决定是否开启 Watchdog,指定值则无自动续期。
详细原理解析:
lock():内部调用tryAcquire(-1, leaseTime, threadId),waitTime=-1表示无限等待。它不响应线程中断(除非使用lockInterruptibly())。未指定leaseTime时启用 Watchdog。tryLock(long waitTime, long leaseTime, TimeUnit unit):可以设置最大等待时间,超时后返回false。若leaseTime指定为正值,锁将在到期后自动释放,不启用 Watchdog;若设为-1,则行为同lock()(无限租约+Watchdog)。leaseTime的角色 :它是锁的"硬租约"。若指定,锁的生命周期完全由该时间决定,到期必释。适合任务执行时间可预测的短任务。若不指定(-1),则由 Watchdog 动态续期,适合执行时间不可预测的长任务。
多角度深度追问:
-
追问 1:如果在持有锁期间手动调用
expire命令会怎样?会破坏 Redisson 的逻辑。不应在外部直接操作 Redisson 管理的 Key。
-
追问 2:
tryLock内部如何等待?是忙等(spin)吗?不是忙等。未立即获取锁的线程会订阅 Redis Pub/Sub 频道,并通过 Java 信号量
Semaphore挂起。当收到释放锁的 Pub/Sub 消息或等待超时时,线程被唤醒,重新尝试获取锁。这大大降低了 CPU 和网络开销。 -
追问 3:如果
waitTime设为 0 会发生什么?
tryLock(0, leaseTime, unit)相当于"仅尝试一次",若锁空闲则立即获取,否则立即返回false。这是非阻塞的抢锁模式。 -
追问 4:多个线程同时调用
tryLock等待,释放锁时哪个线程能获取?Redisson 不是严格公平锁。锁释放后,所有等待线程被 Pub/Sub 消息唤醒,并发竞争
tryAcquire,谁先发送 Lua 脚本到 Redis 谁获胜。若需要公平性,可使用RedissonFairLock,它基于 Redis 队列实现公平调度,但性能较低。
加分回答:
- 内部等待队列 :Redisson 使用
LockPubSub和Semaphore的组合实现高效的等待唤醒。每次锁释放都会publish一次,但只有当前订阅了该频道的线程才会被唤醒。这防止了无关联线程的无效唤醒。 - 生产最佳实践 :始终在
finally块中解锁,且判断isHeldByCurrentThread()后再unlock(),避免抛出IllegalMonitorStateException。
11. 生产环境中 Redis 分布式锁出现了死锁,可能是哪些原因导致的?如何排查?
一句话回答 :原因可能是未设过期时间、Redisson 显式指定 leaseTime 过小却未续期、解锁 Lua 误删、客户端崩溃未释放等;排查应查看 TTL、锁持有者 info、GC 日志。
详细原理解析:
-
死锁原因分类:
- 未设置过期时间 :使用原生
SETNX或SET NX未加PX参数,一旦客户端崩溃,Key 永久存在。 - 租约过短且无 Watchdog :
lock(1, TimeUnit.SECONDS)但业务逻辑执行超过 1s,锁自动释放,但客户端代码未检测锁状态,可能继续执行,这不严格算死锁,是并发冲突。真正的死锁是锁无法释放,即 leaseTime 设置过短可能导致逻辑错误,但 Key 本身会正常过期,不会死锁。 - 解锁异常 :解锁脚本或代码抛出异常,导致
unlock()未执行。Redisson 解锁时若 value 不匹配会忽略,但若网络异常导致DEL未发送,则锁未释放。 - Watchdog 线程异常:极少数情况下 Watchdog 线程因 OOM 或其他错误终止,锁到期释放后可能被其他线程占据,但这不是死锁。
- Redis 内存淘汰 :如果内存满且
maxmemory-policy为allkeys-lru等,锁的 Key 可能被驱逐,导致大量线程同时获取锁,引发逻辑混乱。
- 未设置过期时间 :使用原生
-
排查步骤:
TTL lockKey:查看锁剩余时间,若为-1表示无过期,即可能死锁。HGETALL lockKey:查看是哪个客户端(UUID:ThreadId)持锁,以及重入次数。可反向定位到持有者主机。- 检查持锁客户端的日志、GC 日志,看是否存在长时间停顿或线程僵死。
- 使用
CLIENT LIST或MONITOR命令观察 Redis 命令流,确认是否有续期命令发送。 - 检查 Redis 内存使用和淘汰策略,确认 Key 是否被意外驱逐。
多角度深度追问:
-
追问 1:如何安全地强制释放一个死锁的锁?
如果能确认持有者已宕机,可以直接
DEL lockKey。但危险性极高 ,因为可能发生误判(持有者只是 GC 停顿或网络分区)。Redisson 提供forceUnlock()方法,允许管理员强制解锁,但同样需谨慎。 -
追问 2:使用 Redis Cluster 时,锁的 Key 发生迁移会怎样?
在线迁移数据时,如果锁的 Key 被迁移到另一个节点,原节点的锁定状态会丢失,可能导致锁失效。因此,在 cluster 环境下使用分布式锁应避免对锁 Key 做 ReSharding,或使用 RedLock 跨分片方案。
-
追问 3:死锁和活锁有什么区别?
死锁是锁永远无法获取(被永久持有)。活锁是锁不断被获取和释放,但特定客户端始终获取不到(如不公平锁的饥饿)。Redisson 默认非公平锁可能出现活锁,可通过
RedissonFairLock解决。 -
追问 4:如果怀疑是 Redisson 的 bug,如何进一步深入?
可以开启 Redisson 的 TRACE 日志,查看
tryAcquire、renewExpiration等关键日志。使用断点或arthas动态追踪EXPIRATION_RENEWAL_MAP的状态。
加分回答:
- 分布式锁的监控:生产环境应建立对锁的监控指标:锁平均持有时长、最大持有时长、续期失败次数、等待锁的线程数。这些指标能帮助提前发现死锁或性能瓶颈。
- 降级方案:关键业务应设计分布式锁的降级开关,在出现大面积死锁时能快速关闭锁逻辑,改为乐观并发控制或直接限流,保证核心链路可用。
12. (系统设计题)设计一个秒杀系统的库存扣减方案,要求支持百万级并发、防止超卖、尽可能少用锁,给出完整的 Redis 数据结构和操作流程,并分析极端场景下的竞态风险与解决策略。
回答结构:
-
概述 :采用Redis Lua 脚本无锁扣减为主,辅以数据库最终一致性保证。
-
数据结构 :
stock:item:<itemId>:String 存储总库存。order:set:<itemId>:Set 存储成功抢购的用户 ID,用于幂等。
-
核心流程 :
- 用户请求到达,预校验(限流、黑名单等)。
- 执行 Lua 脚本原子扣减库存,同时将用户 ID 加入 Set 防重。
- 扣减成功返回唯一订单流水号(可由 Redis 生成),前端提示"排队中"。
- 后台异步通过订单流水写入数据库,确认最终订单状态。
-
Lua 脚本设计 :
lualocal stock_key = KEYS[1] local order_set_key = KEYS[2] local user_id = ARGV[1] local request = tonumber(ARGV[2]) -- 幂等检查:已购买用户直接返回 if redis.call('sismember', order_set_key, user_id) == 1 then return -1 -- 重复请求 end local stock = tonumber(redis.call('get', stock_key) or 0) if stock >= request then redis.call('decrby', stock_key, request) redis.call('sadd', order_set_key, user_id) return 1 -- 秒杀成功 else return 0 -- 库存不足 end -
极端场景与解决 :
- 脚本执行缓慢 :库存 Key 热点导致 Redis 单线程阻塞?通过分桶库存 ,将
stock:item:1拆分为 10 个分片stock:item:1:0...9,脚本内随机选一个分片扣减,负载分散。 - Redis 宕机:引入哨兵/Cluster(第 5 篇),并设置合理的持久化策略(AOF+RDB)。但异步复制可能丢少量数据,允许部分超卖?可接受极少量超卖则通过数据库唯一约束兜底,或活动结束后对账退款。
- 用户重复秒杀 :幂等 Set 防止;若内存过大,可改用
Bloom Filter过滤,再做 Set 精确判断。 - 消息积压:扣减成功但后续写数据库缓慢,可限流控制前端流量,配合消息队列削峰填谷。
- 脚本执行缓慢 :库存 Key 热点导致 Redis 单线程阻塞?通过分桶库存 ,将
-
总结:无锁 Lua 脚本方案最大化利用 Redis 原子性,极简高效。通过分桶和限流解决热点与并发压力,业务最终一致性兜底,是工业级秒杀系统的经典范式。
多角度深度追问:
-
追问 1:分桶后,如果某个桶的库存被抢光,而其他桶还有库存怎么办?
客户端在脚本中增加轮转逻辑:若随机选到的桶库存不足,可尝试下一个桶(
stock:item:1:(next_index))。但为防止脚本太复杂或执行时间过长,通常设定尝试上限(如 2-3 次)或由客户端重试。更好的方式是利用 Redis Cluster 的数据分片,将不同桶分布在多个节点,平衡负载。 -
追问 2:如何保证 Set 防重的内存无限增长?
秒杀活动结束后,可将 Set 转为过期或使用
EXPIRE设置过期时间(如 7 天)。对于海量用户,可使用HyperLogLog近似去重或Bloom Filter预先过滤,再用 Set 精准判断,大幅降低内存。 -
追问 3:如果 Lua 脚本中
decrby后发现库存变负(逻辑错误),怎么办?成熟的脚本应在
decrby后检查结果,如果 <0 则回滚incrby并返回 0。我给的示例中先比较再扣减,不会出现负值。但并发下,多个脚本可能同时读库存,比如库存=1,两个脚本都读到 1,第一个扣减后变 0,第二个再执行判断时库存可能是 0(扣减前已读得 1),比较仍通过?不,脚本是单线程顺序执行的,第一个脚本全部执行完毕(包括扣减)后,第二个脚本才开始执行,此时get会读到 0,判断不通过。所以安全。 -
追问 4:该方案中数据库的作用是什么?
数据库作为最终一致性兜底和持久化存储。Redis 中扣减成功只代表"抢购资格获取"。后续订单创建、支付等长流程依赖数据库事务。Redis 与数据库之间通过异步消息队列同步,保证最终一致。
加分回答:
- 多级库存:可在 Redis 和数据库之间再加一层本地缓存(如 Caffeine),应用启动时预热部分库存到本地,线程扣减本地库存,定时异步同步,极限压榨性能。但需处理本地库存与 Redis 的一致性问题,复杂度极高,一般只有顶级大厂在真正极端并发下使用。
- 全链路压测:系统设计需包含全链路压测方案,模拟真实流量验证 Redis 脚本的正确性和分桶负载均衡效果。
分布式锁速查表
| 锁类型 | 实现方式 | 适用场景 | 优点 | 缺点 | 关键参数 |
|---|---|---|---|---|---|
| 基础 Redis 锁 | SET NX PX + Lua 解锁 |
低并发、快速任务,无重入需求 | 简单直接,性能极高 | 不可重入,无续期,单点故障丢失锁 | NX, PX |
| Redisson 单点锁 | Redis Hash + Lua + Watchdog | 大多数分布式业务,需要可重入和续期 | 完全 JUC 接口,可重入,自动续期,Pub/Sub 通知 | 强依赖客户端活性,主从切换可能丢锁 | leaseTime,internalLockLeaseTime=30s |
| RedLock(红锁) | 多个独立 Redis 过半加锁 | 需要提高 Redis 容错性的场景 | 抵御单实例故障,较高可用性 | 性能较差,受 GC/时钟影响,无 Fencing Token 不安全 | 实例数 N=5,请求超时,TTL |
| etcd 锁 | Raft + Lease + 事务 + Revision | 金融、元数据管理等强一致性场景 | 强一致,全局 Token,自动过期 | 性能低于 Redis,运维复杂,CP 限制可用性 | Lease TTL,Revision |
| ZooKeeper 锁 | 临时顺序节点 + Watcher | 分布式协调,如主节点选举 | 强一致,公平锁,自动释放 | 性能中等,写操作较慢,依赖 ZK 集群 | 会话超时,EPHEMERAL_SEQUENTIAL |
| 无锁 Lua 脚本 | EVAL 复合原子操作 |
秒杀库存扣减,计数器,简单状态流转 | 零锁等待,无死锁,最高吞吐量 | 只能用于纯内存操作,复杂业务逻辑难以完全脚本化 | 脚本执行时间 |
安全队列 (RPOPLPUSH) |
原子备份队列 | 可靠消息消费,任务队列 | 无锁,保证消息不丢失 | 仅适用于列表,消费速度受单线程限制,需额外监控 | 备份队列 TTL,监控延迟 |
延伸阅读
- Martin Kleppmann. 《How to do distributed locking》. 2016.
- Salvatore Sanfilippo (antirez). 《Is Redlock safe?》. 2016.
- Redisson 官方文档及 Wiki:github.com/redisson/re...
- 钱文品(老钱). 《Redis 深度历险:核心原理与应用实践》. 分布式锁章节.
- etcd 官方文档:etcd.io/docs/v3.5/l...
- Google. 《The Chubby lock service for loosely-coupled distributed systems》. 2006.
本文以一条
SET命令为起点,遍历了 Redisson 的 Watchdog 精髓、RedLock 的雄心与缺陷、直至 etcd 的 Fencing Token 守护。在这个旅程中,我们反复看到分布式系统设计中的核心命题:没有银弹,只有权衡。理解这些锁机制的内核与边界,将帮助我们在汹涌的并发潮中筑起坚固的堤坝,并在面试与系统设计时游刃有余。