实现一个分布式锁到底有多难

实现一个分布式锁到底有多难

一个服务里多线程操作某共享资源时,我们会使用锁来保证并发安全,但是在分布式系统中操作共享资源,实现一个分布式锁却异常困难。

网上关于分布式锁的文章非常多,Redis 作为 Web 服务中最常用的中间件之一,也有关于实现分布式锁的方案,想知道 Redis 分布式锁为什么这么设计,要从单机 Redis 实现分布式锁说起。

设置分布式锁需要这么一条指令:

1
set share_resourse_id uuid NX PX 30000

逻辑很简单, 给一个叫 share_resourse_id 的 key, 值是一个随机的 uuid, 过期时间 30s。

  • 必须使用1条指令来实现,如果客户端先 set 再 expire,set 后崩溃了这个锁就无法释放了;

  • uuid 的值需要保证一段时间是唯一的, 具体原因后面会分析;

  • NX表示只有当share_resourse_id对应的 key 不存在的时候才能SET成功。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁;

  • PX 30000表示这个锁有一个30秒的自动过期时间。这里只是一个例子,客户端根据需要设置合适的过期时间。

释放的时候要做到类似 CAS 的效果, 先比较 share_resourse_id 对应 key 的值还是不是当前客户端设置的 uuid,如果是就释放,如果不是就不释放,那为什么要这么做呢?

  • key 一定要设置过期时间,因为客户端可能出现设置锁后崩溃、宕机等任何情况,如果不设置过期时间会导致所有客户端都无法设置锁;
  • uuid 的值必须是一段时间内唯一的,如果客户端A设置锁后发生 GC,锁过期了,被客户端B设置上了相同的 value,而A在 GC 完成操作后发起了释放锁的操作,会把客户端B的锁释放掉;
  • 释放锁要使用 lua 脚本, 这样才能保证 CAS 的原子性。

然而为了避免系统出现单点故障,Redis 一般是以集群的方式部署的,这就可能产生新的问题:

  • 客户端A设置了锁后 redis master 崩溃了, 由于异步复制的原因, slave 上还没有这个锁。随后客户端B在新的redis master 上设置了锁,两个客户端并发访问了;

除此之外,给锁设置过期时间还需要面对和“延时双删”一样的问题,这个过期时间多久合适?到时间了没完成怎么办?

Redis 的作者 antirez 基于 N 个 Redis 节点给出了一个 Redlock 的实现,算是 Redis 官方对于实现分布式锁的指导规范。

获取锁时客户端依次执行下面各个步骤,来完成获取锁的操作:

  1. 获取当前时间(毫秒数)。

  2. 按顺序依次向 N 个 Redis 节点执行获取锁的操作,跟前面基于单 Redis 节点的获取锁的过程相同。

    • 为了保证在某个 Redis 节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。

    • 客户端在向某个 Redis 节点获取锁失败以后,应该立即尝试下一个 Redis 节点。这里的失败,应该包含任何类型的失败,比如该 Redis 节点不可用,或者该 Redis 节点上的锁已经被其它客户端持有。

  3. 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数 Redis 节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。

  4. 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。

  5. 如果最终获取锁失败了(可能由于获取到锁的 Redis 节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有 Redis 节点发起释放锁的操作。

释放锁和之前的流程一样,都需要 lua 脚本实现,但注意是需要向所有 Redis 节点发起,因为 Redis 节点可能返回的响应丢失了,但本身已经在获取锁流程时设置上了。

这种方案解决了 Redis 单节点 failover 时锁失效的问题,但真的天衣无缝了吗?

  • 获取到锁后如何计算剩余时间是否足够完成操作?如果计算完剩余时间但发生 GC 了,锁实际上已经到期了被其它客户端强占了,依然存在并发问题,这是业务需要考虑的;
  • Redis ABCDE 5个节点,客户端1 拿到了 ABC 节点的响应,成功设置了锁,C 节点恰好宕机重启了,由于 fsync 或其它原因没有保存之前的锁,客户端2拿到了 CDE 节点的响应也认为成功持有了锁。这点作者在后续提出了延时重启的想法,一个节点崩溃后应该在超过 expire_time 后重启,避免对已持有锁的节点造成影响。

除此之外,分布式系统中的时钟情况也会有影响,如果某节点的时钟发生了跳跃,导致锁快速失效,也会产生上面 ABC、CDE 这样多客户端同时持有锁的问题。antirez 承认所有的分布式锁的实现,包括 Redlock,是没有什么好办法来应对的,只能通过基础设施和良好的运维方式来尽可能避免,好在分布式锁对于时间的精度要求也不是非常苛刻。

Kubernetes 依赖的底层存储 etcd 也能提供分布式锁的实现,etcd 是一个分布式 kv 存储,有事务、watch、全局version、lease 等多个重要特性,它的实现思路如下:

  • 每个客户端创建固定前缀的 key, 比如/share_resourse_id/{uuid} , 具体的实现是基于事务提供的原子性保证,先判断是否存在以 /share_resourse_id 为前缀且 create_version 比自己小的:

    • 如果不存在,就新建自己的 key
    • 如果存在,新建自己的 key, watch create_version 离自己最近的 key
    • 这个 key 需要绑定到一个 lease 下,客户端会不断续约 lease 代表自己还存活(类似心跳机制),如果 lease 过期这个 key 会被删除,不会由于客户端崩溃而锁永久存在。

首先解释一下为什么用 固定前缀key 的方式而不是抢占同一个 key,多个节点抢占同一个 key 并 watch 这个 key 的删除会产生“惊群”,key 删除时所有的客户端都被惊醒继续抢占。

这种实现方式相比 Redis 有什么好处?

  • 客户端可以实现阻塞直到获取锁的行为:Redis 如果取不到锁想阻塞需要业务自行 while 循环实现,etcd 可以阻塞等待 watch 主动通知;
  • 公平: create_version 是按照客户端创建 key 的顺序递增的,近似于按照请求到达的顺序获取锁;
  • 业务简单:不再需要考虑客户端宕机和 key 的过期时间,lease 续约失败自动删 key;
  • 避免惊群:每个客户端在获取不到锁时 watch 前一个 key,也就是锁被删除的时候只会唤醒下一个等待者,性能更好。

那 etcd 的实现就完美吗?

  • lease 续约就像心跳机制一样,当客户端繁忙或者发生意外时,可能会续约失败,这时 etcd 误认为客户端宕机了,会删除锁并通知下一个客户端。

作为经典的分布式协调组件,Zookeeper 自然也提供了分布式锁的实现,和 etcd 比较类似,详细的逻辑这里就不分析了。

相信经过上面的分析,即使出现新的协调组件实现分布式锁,只要能够对所能提供的安全性的程度有充分的了解,那么我们就能做出自己的选择了。这里顺便附上 DDIA 作者对于 Redis实现分布式锁 的结论:

  • 如果是为了效率 (efficiency) 而使用分布式锁,允许锁的偶尔失效,那么使用单 Redis 节点的锁方案就足够了,简单而且效率高。Redlock 则是个过重的实现(heavyweight)。
  • 如果是为了正确性(correctness)在很严肃的场合使用分布式锁,那么不要使用 Redlock。它对于系统模型的假设中包含很多危险的成分(例如timing),不是建立在异步模型上的一个足够强的算法。

参考:

【1】基于Redis的分布式锁到底安全吗(上)?

【2】基于Redis的分布式锁到底安全吗(下)?


实现一个分布式锁到底有多难
https://zhuwenjie0716.github.io/2026/09/19/实现一个分布式锁到底有多难/
作者
Wenjie Zhu
发布于
2026年9月19日
许可协议