写分布式锁容易,写“生产级”分布式锁难。很多团队在初创期用 SETNX 玩得挺顺,一旦流量上量,各种死锁、锁丢失、数据错乱就会接踵而至。
本文直接复盘生产环境中排查出的 7 个致命坑位,并深入 Redisson 的底层实现逻辑,剖析分布式领域经典的 Redlock 世纪论战,分享如何构建一个抗住千万级 QPS 的分布式锁体系。
第一部分:战地复盘——分布式锁的 7 大坑位
坑位 1:加锁与过期不同步(死锁地狱)
- 战术指南:必须使用
SET key value NX PX 30000。该命令在 Redis 内部是原子的,即使客户端崩溃,Redis 也会在过期后自动释放锁。
坑位 2:业务跑太久,锁“不辞而别”
- Redisson Watchdog 深度拆解:
- 自动续期逻辑:Redisson 内部维护了一个
ConcurrentMap,记录了所有处于“活跃”状态的锁。 - 续期频率:它会启动一个
Timeout定时任务,每隔leaseTime / 3(默认 10s)执行一次 Lua 脚本进行PEXPIRE。 - 局限性:Watchdog 只有在没有手动指定 leaseTime 时才会生效。
- 自动续期逻辑:Redisson 内部维护了一个
坑位 3:解铃还须系铃人(误删他人的锁)
- 战术指南:加锁时 Value 存入唯一的客户端标识(如 UUID + 线程 ID)。解锁前先通过 Lua 脚本判断 Value 是否匹配,再执行删除。
坑位 4:主从切换时的“幽灵锁”
- 深度尸检:Redis 主从同步是异步的。Master 写入成功后立即返回,Slave 随后同步。这是典型的 AP 模型,在 Master 宕机时可能导致锁丢失。
坑位 5:网络延迟触发的“幽灵成功”
- 战术指南:无论加锁结果如何,
finally块里都要尝试根据 UUID 标识进行幂等解锁。
坑位 6:惊群效应(CPU 瞬间炸裂)
- Redisson 的解法:利用 Redis 的
SUBSCRIBE订阅锁释放频道。线程未抢到锁时,会通过Semaphore进入挂起等待,极大减少了无效的网络轮询。
坑位 7:单点故障后的“全线罢工”
- 战术指南:哨兵(Sentinel)或集群(Cluster)是生产标配。非核心业务应开启熔断降级(Fail-fast)。
第二部分:Redlock 论战——分布式系统的世纪对决
在分布式锁的发展史上,著名的 Redlock 世纪论战 至今仍是经典。
1. Martin Kleppmann 的质疑(强一致性视角)
Martin Kleppmann 曾在其博客中严厉批评 Redlock。他的观点基于两个核心风险:
- 时钟漂移(Clock Drift):Redlock 依赖本地系统时钟。如果某个 Redis 节点的时钟跳跃(比如 NTP 同步),可能导致锁在还没到期时就失效。
- 暂停(Pause)与延迟(Delay):如果进程在加锁成功后发生了长时 GC 停顿(STW),等到进程恢复时,锁可能已经过期并被他人持有,但进程仍认为自己持有锁。
2. Antirez 的回击(工程实践视角)
Redis 之父 Antirez 认为:
- 时钟跳跃可控:通过逐步调整时钟(Slewing)而非突然跳变(Stepping)可以解决大部分问题。
- 相对时间 vs 绝对时间:Redlock 使用的是相对时间(生存时间),对时钟绝对值的依赖较低。
3. Redlock 算法 5 步曲:如何在多节点环境下达成共识
为了在 5 个独立的 Redis Master 节点上获取锁,Redlock 规定了以下步骤:
- 获取当前时间(T1)。
- 依次加锁:按顺序向 5 个节点发起
SET NX PX请求,每个请求设置一个很小的超时时间(如 5-50ms),防止在某个宕机节点上死等。 - 计算耗时:计算获取锁的总耗时(ΔT = T2 - T1)。
- 判断成功:
- 多数派原则:必须在大多数节点(3个)上加锁成功。
- 时间有效性:
ΔT必须小于锁的 TTL。此时锁的实际剩余有效期 =TTL - ΔT。
- 失败回滚:如果加锁失败(未达到多数派或超时),必须向所有节点发起解锁请求(即使之前加锁失败的节点也要尝试解锁,防止“幽灵成功”)。
4. 终极解法:Fencing Token(栅栏令牌)
Martin 提出了一个更严谨的改进方案:Fencing Token。
- 原理:每次客户端加锁成功后,持久化存储返回一个递增的序列号(Token)。
- 校验:客户端在进行数据库操作时,必须带上这个 Token。数据库层会检查该 Token 是否是当前看到的最新值。
- 效果:旧锁持有者的 Token 一定比新锁持有者的 Token 小,数据库操作将被拒绝。
第三部分:分布式锁选型全对比——Redis vs Zookeeper vs ETCD
在架构选型时,没有最好的组件,只有最适合场景的方案。
1. Redis (Redisson) —— 追求极致性能
- 模型:AP 模型(一致性弱,可用性强)。
- 性能:极高(内存操作,单机可达 10W+ QPS)。
- 可靠性:依赖主从异步同步,存在锁丢失风险。
- 适用场景:高并发、短耗时业务,对极端情况下的锁重入/丢失有一定容忍度。
2. Zookeeper —— 追求绝对一致
- 模型:CP 模型(一致性强,可用性弱)。
- 原理:利用临时顺序节点(Ephemeral Sequential Node)和 Watch 机制。
- 优点:锁自动释放(Session 断开即释放),天然支持公平锁,强一致性。
- 缺点:性能受限于磁盘 I/O 和 ZAB 协议,频繁创建/销毁节点开销大。
- 适用场景:对安全性要求极高、并发量中等的业务(如:金融结算、主备切换)。
3. ETCD —— 云原生时代的宠儿
- 模型:CP 模型(基于 Raft 协议)。
- 特点:提供 Lease(租约)机制,支持多版本并发控制(MVCC)。
- 性能:介于 Redis 和 Zookeeper 之间。
- 适用场景:K8S 内部组件同步、关键配置锁定。
第四部分:Redisson 源码级逻辑深挖
1. 可重入性实现(HSET 逻辑)
Redisson 的锁使用 Hash 结构,这不仅支持了可重入,还记录了持有锁的线程标识。
- Key: 锁名称(如
order_lock:123) - Field:
UUID + ThreadID(全局唯一的客户端标识 + 线程 ID) - Value: 重入次数(Counter)
加锁 Lua 逻辑深度剖析:
-- KEYS[1] = 锁名称, ARGV[1] = 过期时间 (ms), ARGV[2] = 线程标识 (UUID:ThreadID)
-- 1. 锁不存在,直接加锁
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; -- 返回 nil 表示加锁成功
end;
-- 2. 锁已存在且是当前线程持有,重入计数 +1
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;
-- 3. 锁被他人持有,返回锁的剩余过期时间 (PTTL)
return redis.call('pttl', KEYS[1]);
技术洞察:使用 nil 作为成功标志,而返回 pttl 作为失败反馈,这种设计让客户端能够精确感知重试等待时间,避免了盲目自旋。
2. 公平锁(Fair Lock)实现机制:ZSet 与 List 的精妙配合
公平锁的难点在于如何保证“先到先得”且在节点宕机时不会导致队列阻塞。Redisson 引入了两个关键结构:
- 等待队列 (List):
redisson_lock_queue:{lock_name}。存储所有正在等待锁的线程标识。 - 超时权重队列 (ZSet):
redisson_lock_timeout:{lock_name}。存储线程的currentTime + waitTime,用于清理已过期的无效等待者。
公平锁加锁流程:
- 清理无效等待者:每次尝试加锁前,先移除 ZSet 中已超时的线程。
- 排队检查:如果队列不为空且当前线程不是队首,则必须排队。
- 订阅通知:利用
SUBSCRIBE机制,当锁释放时,只有队首线程会被唤醒。
3. PubSub 机制:告别死循环重试
传统的 while(true) 重试会造成巨大的网络开销。Redisson 采用了 基于信号量的订阅机制:
- 当线程获取锁失败时,它会通过
redis.subscribe("redisson_lock__channel:lock_name")订阅锁释放的消息。 - 线程进入
Semaphore.tryAcquire阻塞状态,不再消耗 CPU。 - 当持有锁的线程执行
unlock()时,会发送PUBLISH消息。 - 被唤醒的线程再次尝试竞争,若失败则继续进入订阅阻塞。
4. 可过期性信号量(Permit Expiration)
在某些高阶限流场景中(如:每人每天只能领 3 次券),传统的 Redis Semaphore 无法对单个 Permit 设置 TTL。
Redisson 扩展了这一能力:
- 使用
ZSet存储每个 Permit 的过期时间戳。 - 定时任务自动清理过期 Permit,将其归还到可用池中。
- 这种实现完美解决了“占着坑位不拉屎”的僵尸 Permit 问题。
第四部分:分布式锁 vs 分布式事务——边界划分与架构选型
很多开发者在遇到数据一致性问题时,第一反应就是“加锁”。但加锁并非万能药,有时甚至会成为系统瓶颈。
1. 分布式锁的适用场景(并发控制)
- 互斥性操作:如防止超卖、防止重复提交、保证任务不被重复执行。
- 资源保护:防止多个节点同时修改同一个外部资源(如 S3 文件、第三方 API 状态)。
- 性能优化:防止缓存击穿(多个请求同时查询数据库并更新缓存)。
2. 分布式事务的适用场景(数据最终一致性)
- 跨库操作:A 服务扣减余额,B 服务增加积分。
- 长事务流程:涉及多个微服务的复杂业务链路。
3. 架构建议:能用事务解决的,不要用锁
- 锁是阻塞的:在高并发下,锁竞争会显著拉低系统吞吐量。
- 锁是有时效的:分布式锁无法绝对保证“原子性”,因为它可能因为网络超时或 GC 停顿而失效。
- 方案选型指南:
- 优先使用数据库行级锁:
UPDATE table SET stock = stock - 1 WHERE id = 1 AND stock > 0。这是最强的一致性保障。 - 其次考虑幂等性设计:通过
unique_key保证操作的幂等。 - 最后才考虑分布式锁:仅用于控制入口流量和防止重复触发。
- 优先使用数据库行级锁:
第五部分:实战案例深度剖析
1. 场景一:分布式任务调度中的“防重触发”
痛点:多实例部署下,Quartz 或 Spring Task 容易在同一秒内多次触发同一任务。 Redisson 方案:
public void executeTask() {
RLock lock = redissonClient.getLock("task:sync_user_data");
// waitTime=0, leaseTime=60s. 没抢到立即返回,不等待
if (lock.tryLock(0, 60, TimeUnit.SECONDS)) {
try {
// 执行业务逻辑
} finally {
lock.unlock();
}
}
}
关键点:waitTime 设为 0 是防止任务积压的最佳实践。
2. 场景二:全局限流锁与热点 Key 保护
痛点:某直播间突发流量,导致 Redis 热点 Key 访问过载。 进阶方案:
- 多级锁机制:先在 JVM 内部通过
StripedLock(分段锁)或Semaphore过滤 90% 的无效请求,只有 10% 的请求能触达 Redis。 - 动态 TTL:根据当前系统负载动态调整锁的过期时间。
第六部分:性能调优与云原生挑战
1. Redisson 关键参数优化
- nettyThreads: 默认是 CPU 核数的 2 倍。在高并发下,如果 CPU 占用高且网络延迟大,建议调大该值(如 4 倍或 8 倍)。
- connectionPoolSize: 默认 64。根据 QPS 预估,单个请求耗时 10ms,则 64 个连接每秒只能处理 6400 个请求。对于万级 QPS,应调至 256 或更高。
- retryInterval & retryAttempts:对于核心业务,建议调低
retryInterval(如 100ms),增加retryAttempts(如 5 次),提高锁获取的成功率。
2. K8S 环境下的 Pod 驱逐与锁丢失
在云原生环境下,Pod 可能随时因为 OOM、节点压力或发布而重启。
- 挑战:Pod 被
SIGTERM信号终止时,Watchdog 会停止续期,锁将在 TTL 后失效。但此时业务代码可能还在运行(未及时感知信号)。 - 对策:
- preStop 钩子:在
preStop中执行lock.unlock()或设置状态位。 - 优雅停机:在 Spring Boot 中配置
server.shutdown=graceful,并确保 RedissonClient 在业务线程执行完后再关闭。
- preStop 钩子:在
第七部分:生产监控与 AOP 实战
1. 监控核心指标(Prometheus + Grafana)
- P99 锁等待耗时:反映锁竞争压力。如果该值持续升高,说明需要拆分锁粒度(如分段锁)。
- 锁持有时间:统计
finally块执行的时长。 - Watchdog 续期失败率:警惕网络抖动或 Redis 实例压力。
2. AOP 注解封装的生产级考量
简单的 AOP 封装往往忽略了异常处理。一个健壮的封装应包含:
- 错误降级:当抢锁超时时,支持返回默认值或执行 fallback 方法。
- 多级 Key 解析:支持 SpEL 表达式解析复杂对象属性。
- 监控埋点:自动记录加锁时长和结果。
@Around("@annotation(distributeLock)")
public Object around(ProceedingJoinPoint joinPoint, DistributeLock distributeLock) throws Throwable {
String key = SpelUtils.parse(distributeLock.key(), joinPoint);
RLock lock = redissonClient.getLock(key);
long start = System.currentTimeMillis();
boolean res = false;
try {
res = lock.tryLock(distributeLock.waitTime(), distributeLock.leaseTime(), TimeUnit.SECONDS);
if (res) {
return joinPoint.proceed();
} else {
// 记录监控指标:加锁失败
Metrics.counter("lock.acquire.fail", "key", key).increment();
throw new BizException("系统繁忙,请稍后再试");
}
} finally {
if (res && lock.isHeldByCurrentThread()) {
lock.unlock();
}
// 记录监控指标:总耗时
Metrics.timer("lock.process.time", "key", key).record(System.currentTimeMillis() - start, TimeUnit.MILLISECONDS);
}
}
第八部分:生产级 FAQ——你可能会踩的“隐形地雷”
Q1:锁的 TTL 到底设置多久合适?
建议:没有标准答案,但通常建议设置为 业务最大预估耗时的 2~3 倍。配合 Redisson Watchdog,TTL 更多是作为“兜底”机制(防止进程宕机后锁无法释放)。
Q2:如果 Redis Cluster 发生主从切换,我的锁真的会丢吗?
真相:在主从异步复制模式下,确实会丢。如果业务极度敏感(如:10 亿级的资金拨付),请务必使用 Redlock 或转向 Zookeeper/ETCD。
Q3:Redisson 的解锁操作 unlock() 必须放在 finally 吗?
建议:是的,且解锁前必须判断 lock.isHeldByCurrentThread()。否则,如果锁已自然过期或被他人抢占,直接调用 unlock() 会抛出 IllegalMonitorStateException。
Q4:为什么分布式锁不建议设置过长的 waitTime?
深度剖析:过长的 waitTime 会导致大量请求积压在 Tomcat 线程池中,引发“雪崩效应”。建议根据 SLA 设置合理的超时(如 1-3s),超时后应立即 Fail-fast。
结语:没有银弹,只有权衡
分布式锁的实现从 SETNX 进化到 Redisson,再到云原生环境下的各种挑战,其核心本质始终是 在性能(AP)与一致性(CP)之间寻找动态平衡。
作为架构师或核心开发,我们不应迷信某种特定方案。在 90% 的互联网业务中,Redis + Redisson 配合合理的业务幂等性设计已绰绰有余。而对于剩下的 10% 极高一致性场景,请毫不犹豫地拥抱 CP 模型。
记住,分布式系统里没有魔法,只有深思熟虑后的取舍。
战地笔记:终极 Checklist
- 原子性底线:必须使用
SET NX PX或 Lua 脚本。 - 唯一标识:Value 存入 UUID,解锁前必须校验。
- 过期冗余:设置 1.5~2 倍 TTL,配合 Watchdog。
- 可观测性:接入监控,记录锁统计指标。
- 兜底防御:数据库
WHERE stock > 0是最后一道防线。 - 故障熔断:非核心锁必须支持 Fail-fast。
- 一致性权衡:涉及大额金融,考虑 Fencing Token 或强一致性组件。