[My Blog | 状态: 在线]
核心温度 22.5°C
网络节点 80
ARTICLE_LOG
ID #9915
字数统计 4,200 字
预计阅读 11 分钟
状态 数据已同步

Redis 分布式锁:生产环境 7 大坑位深度复盘与 3000 字全架构进阶指南

写分布式锁容易,写“生产级”分布式锁难。很多团队在初创期用 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 时才会生效。

坑位 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 规定了以下步骤:

  1. 获取当前时间(T1)。
  2. 依次加锁:按顺序向 5 个节点发起 SET NX PX 请求,每个请求设置一个很小的超时时间(如 5-50ms),防止在某个宕机节点上死等。
  3. 计算耗时:计算获取锁的总耗时(ΔT = T2 - T1)。
  4. 判断成功
    • 多数派原则:必须在大多数节点(3个)上加锁成功。
    • 时间有效性ΔT 必须小于锁的 TTL。此时锁的实际剩余有效期 = TTL - ΔT
  5. 失败回滚:如果加锁失败(未达到多数派或超时),必须向所有节点发起解锁请求(即使之前加锁失败的节点也要尝试解锁,防止“幽灵成功”)。

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,用于清理已过期的无效等待者。

公平锁加锁流程

  1. 清理无效等待者:每次尝试加锁前,先移除 ZSet 中已超时的线程。
  2. 排队检查:如果队列不为空且当前线程不是队首,则必须排队。
  3. 订阅通知:利用 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 在业务线程执行完后再关闭。

第七部分:生产监控与 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

  1. 原子性底线:必须使用 SET NX PX 或 Lua 脚本。
  2. 唯一标识:Value 存入 UUID,解锁前必须校验。
  3. 过期冗余:设置 1.5~2 倍 TTL,配合 Watchdog。
  4. 可观测性:接入监控,记录锁统计指标。
  5. 兜底防御:数据库 WHERE stock > 0 是最后一道防线。
  6. 故障熔断:非核心锁必须支持 Fail-fast。
  7. 一致性权衡:涉及大额金融,考虑 Fencing Token 或强一致性组件。