跳到主内容

分布式锁演进踩坑录:从SETNX到Redlock再到租约机制的十年教训 ​

分布式锁大概是所有分布式原语里「看起来最简单、错法最丰富」的一个。十年前一句 SETNX 就敢上生产,如今回头看,那一代锁埋的雷(死锁、误删、过期失效)至今还在一些老系统的代码库里。分布式锁的演进史,本质是一部「对故障场景的认知升级史」:每一代方案都修复了上一代的一个致命假设,又暴露出新的边界。这篇文章按演进时间线复盘四代方案,每一代讲清楚「它修复了什么、还差什么」,最后给出 2026 年视角下的选型结论。

第一代:裸 SETNX——三个致命缺陷的起点 ​

最早的实现用 SETNX key value 抢锁、业务完成后 DEL 释放。它有三个今天看来不可原谅的缺陷:

text
缺陷一:加锁后进程崩溃,锁永不释放 → 死锁
缺陷二:加锁与设置过期时间是两条命令,中间崩溃同样死锁
缺陷三:任何客户端都能 DEL,A 超时后 B 加锁,A 恢复后误删了 B 的锁

缺陷三的杀伤力最大且最隐蔽:任务耗时超过了锁的过期时间,持有者 A 的锁过期后被 B 抢走,A 执行完毕顺手 DEL——删掉的是 B 的锁,此时 C 又能加锁进来,互斥彻底失效。这个场景是所有后续方案的设计原点。

第二代:SET NX PX + 唯一值 + Lua——把原子性补齐 ​

Redis 2.6.12 之后,一条命令解决加锁原子性,Lua 脚本解决释放原子性,这一代实现至今仍是单实例 Redis 锁的标准写法:

java
// 加锁:一条命令原子地完成「不存在才设置 + 过期时间」
String lockKey = "lock:order:close:" + orderId;
String requestId = UUID.randomUUID().toString(); // 唯一标识持有者
boolean locked = redis.set(lockKey, requestId, "NX", "PX", 30_000);

// 释放:Lua 保证「校验持有者 + 删除」原子执行,根治误删
private static final String UNLOCK_LUA = """
    if redis.call('GET', KEYS[1]) == ARGV[1] then
        return redis.call('DEL', KEYS[1])
    else
        return 0
    end
    """;

但这一代仍然留着两个问题:其一,过期时间与业务耗时赛跑——设短了业务没跑完锁先失效,设长了进程崩溃后其他节点干等 30 秒;其二,主从切换会丢锁——加锁写在主库、还没同步到从库时主库宕机,从库提升后锁消失,两个客户端同时持锁。第二个问题引出了第三代方案。

第三代:Redlock 与它的争议——多数派真的更安全吗 ​

Redlock 的思路是把「单点可靠性」升级为「多数派可靠性」:向 N 个(通常 5 个)独立 Redis 主节点依次加锁,半数以上成功且总耗时小于锁有效期才算加锁成功。它在主从切换场景下确实更稳,但也引发了分布式系统领域著名的技术争论——Martin Kleppmann 指出其核心软肋依赖时钟假设:Redlock 的安全性建立在「各节点时钟漂移可控」之上,一旦发生长时间 GC 停顿或时钟跳变,客户端自认为还持有锁,实际锁已过期,两个客户端同时进入临界区。

反方(antirez)的回应是工程上可通过 fencing token 收敛风险。Fencing Token 的思想值得每个用锁的人刻进脑子:锁服务发放单调递增的令牌,存储层校验令牌新鲜度,拒绝旧令牌的写操作。

java
// fencing token 的防御模式:锁只是建议,令牌校验才是保证
boolean locked = redlockClient.lock(lockKey, Duration.ofSeconds(30));
if (!locked) {
    return Result.processing();
}
long fenceToken = redlockClient.currentToken(lockKey); // 单调递增
try {
    // 存储端执行条件更新:令牌小于已见最大值则拒绝,彻底挡住停顿客户端的迟到写
    storageMapper.updateWithFence(resourceId, newValue, fenceToken);
} finally {
    redlockClient.unlock(lockKey, requestId);
}

争论的实际结论是:Redlock 的复杂度换来的增量可靠性有限,且必须配合 fencing 才严谨——既然要 fencing,单实例锁加 fencing 也能达到相近安全性,那 Redlock 的五个节点运维成本就性价比不高了。

锁粒度是这套体系里常被低估的性能杠杆。按全局加锁(lock:order)会把所有订单的关闭操作串成单队列,吞吐被压到单线程水平;按业务键加锁(lock:order:close:{orderId})则只在同一订单内互斥,不同订单完全并行。粒度选择的原则是「互斥范围恰好等于数据冲突范围」:扣库存锁到 SKU,账户操作锁到账户 ID,防重复提交锁到业务流水号。粒度过粗是性能问题,过细是正确性问题(该互斥的没互斥到),两者都应该在方案评审中显式确认,而不是默认沿用上一个项目的写法。

第四代:租约机制——把时间问题交给共识系统 ​

etcd 与 ZooKeeper 提供的锁本质是租约(Lease)机制:客户端向共识集群申请带 TTL 的租约,把锁 key 绑定到租约上,通过心跳续期维持持有;进程崩溃则心跳停止,租约到期自动释放。关键差异在于:共识集群用 Raft/ZAB 协议保证了「租约过期」这一事件的全局一致视图,不再依赖客户端本地时钟,Kleppmann 批评 Redlock 的时钟假设在这里不成立。

bash
# etcd v3 锁命令:TTL 30 秒,心跳自动续期,进程崩溃租约到期自动释放锁
etcdctl lock user:task:refresh --ttl 30

生产级用法通常基于会话(Session)与竞争监听:

go
// 基于 etcd 的租约锁:崩溃自动释放,续期由客户端库托管
session, err := concurrency.NewSession(etcdClient, concurrency.WithTTL(30))
if err != nil {
    return fmt.Errorf("创建 etcd 会话失败: %w", err)
}
defer session.Close()
mutex := concurrency.NewMutex(session, "/locks/order-close")
if err := mutex.Lock(context.Background()); err != nil {
    return fmt.Errorf("获取锁失败: %w", err)
}
defer mutex.Unlock(context.Background())
// 临界区:session 心跳持续续期,业务耗时不再与固定过期时间赛跑

租约机制的「看门狗续期」由客户端库托管,业务耗时与锁有效期的赛跑问题被结构性解决——这是它与 Redisson 看门狗(第四代之前的改良)共同的优势,但 etcd 的一致性保证更强。代价是引入一套共识集群的部署与运维,且 etcd 写性能低于 Redis。

选型决策表与避坑清单 ​

方案可靠性性能运维成本适用场景
SET NX PX + Lua中(主从丢锁)极高极低效率锁、可容忍偶发并发
Redisson 看门狗中高高低Java 栈效率锁的事实标准
Redlock中高(时钟依赖)中中不推荐新项目首选
etcd/ZK 租约高中中高正确性敏感的互斥

先分清你用的是「效率锁」还是「正确性锁」:防止重复计算、重复推送这类锁坏了代价可控的,Redis 单实例 + Redisson 足够;锁失效即资损的(双写互斥、主备切换),用 etcd/ZK 租约,并配合 fencing 校验。

避坑五条:

  1. 加锁必须一条命令带过期时间,释放必须 Lua 原子校验持有者——这两条是底线,不设过期时间的锁等于定时死锁。
  2. 锁的过期时间永远按业务 P99 耗时的 3~5 倍设置,或直接用看门狗续期,别拍脑袋定 30 秒。
  3. 永远不要假设「拿到锁就安全」:跨进程停顿(GC、VM 挂起)会让持有者失去时效感知,存储端 fencing 校验是最后防线。
  4. 锁不等于幂等:锁释放后到达的重试请求会再次进入临界区,幂等由业务键与状态机另行保证。
  5. Redlock 不建议作为新项目首选——它省下的钱(不加 etcd)不多,添的复杂度不少,要么简单锁要么上租约。

分布式锁十年演进给出的最大启示是:锁只提供「建议性互斥」,真正的安全由下游存储的条件校验兜底。理解了这一层,四代方案各自的边界就不再是需要背的知识点,而是顺理成章的推论。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。