分布式锁的真相:从 Redlock 争论到 Fencing Token
某电商平台的库存扣减服务上线了三个月。架构很标准:用
Redis 的 SET key value NX EX 30
拿锁,拿到锁后查库存、扣减、写回数据库。线上监控显示锁的平均持有时间在
5ms 左右,30
秒的过期时间留了几个数量级的余量。团队对这套方案信心十足。
然后某天大促,一个持有锁的 Java 进程触发了 Full GC,暂停了 40 秒。Redis 中的锁在第 30 秒过期。另一个进程拿到了同一把锁,读到库存 1,执行扣减,写回 0。GC 结束后,第一个进程从暂停处恢复——它的本地变量里还存着”库存 1”,于是也执行扣减,写回 0。
结果:一件商品卖给了两个客户。库存没变成 -1(因为两次都是从 1 扣到 0),所以常规的数据校验没有报警。直到客服收到投诉才发现问题。
这不是虚构的场景。任何用过期时间做互斥的分布式锁方案都存在这个风险。问题的根源不是过期时间设得太短,而是分布式系统中的一个基本事实:你无法保证一个进程在持有锁的整个期间都是活跃的。GC 暂停、网络分区、操作系统调度延迟、甚至虚拟机迁移,都可能导致进程在锁过期后才恢复执行,而此时它对”自己是否还持有锁”一无所知。
这篇文章从这个问题出发,完整拆解分布式锁领域最核心的技术争论与解决方案。
一、为什么分布式锁比你想的难
1.1 单机锁的隐含假设
在单机环境中,互斥锁(mutex)的正确性依赖几个隐含假设:操作系统调度器保证持有锁的线程最终会被调度执行;进程崩溃时操作系统会自动释放锁资源;所有竞争者共享同一个内存空间,锁状态的变更对所有线程立即可见。
这些假设在分布式环境中全部失效。分布式锁的状态存储在远端节点上,锁持有者和锁服务之间隔着不可靠的网络。锁持有者无法通过原子指令直接操作锁状态,而是需要通过消息传递来获取和释放锁。
1.2 进程暂停:看不见的杀手
分布式锁面临的最严重威胁不是节点宕机——宕机是容易检测和处理的。真正的杀手是进程暂停(process pause)。以下场景都可能导致持有锁的进程暂停足够长时间,使锁过期:
- 垃圾回收(GC)暂停:Java 的 Full GC 可以暂停几秒甚至几十秒。Go 的 GC 延迟已经大幅降低,但在极端条件下仍可能出现毫秒级停顿。
- 虚拟机暂停:云环境中,虚拟机可以被宿主机挂起(suspend)以进行实时迁移。暂停时间取决于内存大小和网络带宽,可能长达数秒。
- 操作系统调度:在 CPU 过载的机器上,进程可能被抢占后长时间得不到调度。使用 cgroup 限制 CPU 的容器环境中尤其常见。
- 磁盘 I/O 阻塞:如果进程触发了缺页中断(page fault),需要从交换分区(swap)读取数据,阻塞时间取决于磁盘速度。
- 信号处理:
SIGSTOP信号可以无条件暂停进程,直到SIGCONT恢复。
关键点在于:进程在暂停期间无法执行任何代码,包括检查锁是否过期的代码。从进程的视角看,暂停前后是连续的——它甚至不知道自己被暂停过。
1.3 锁的两个目的
Martin Kleppmann 在他的分析中区分了分布式锁的两个根本不同的目的:
效率(Efficiency):锁用来避免重复工作。例如,防止两个 worker 同时处理同一个任务。如果偶尔有两个 worker 同时拿到锁并处理了同一任务,后果只是浪费了一些计算资源——不会造成数据损坏。对于这种场景,Redis 单实例锁足够好。偶尔的锁失效可以接受,因为上层逻辑是幂等的。
正确性(Correctness):锁用来保护共享数据不被并发修改损坏。例如,上面的库存扣减场景。如果两个进程同时修改了同一份数据,结果可能是数据永久性损坏,无法通过重试修复。对于这种场景,锁必须提供严格的互斥保证——在任何时刻,最多只有一个客户端认为自己持有锁是不够的,还必须保证只有一个客户端能对受保护的资源进行有效操作。
理解这个区分至关重要。大多数关于分布式锁的争论,其实是因为参与者对锁的目的有不同的预期。
1.3a 端到端竞态条件演练
为了具体理解”正确性”场景下分布式锁失败的后果,我们完整走一遍一个库存扣减的竞态条件。
场景设定:电商系统中,某商品库存为 10。进程 A 和进程 B 各自需要处理一笔订单,每笔订单扣减 1 件库存。预期最终库存为 8。
没有 Fencing Token 时的故障过程:
- 进程 A 获取分布式锁,从数据库读取库存
inventory = 10,开始处理订单逻辑。 - 进程 A 触发了一次 Full GC(或网络延迟、CPU 调度延迟),暂停了 35 秒。
- 锁的 TTL 为 30 秒,超时自动过期。
- 进程 B 获取分布式锁(此时锁已释放),从数据库读取库存
inventory = 10,将库存减 1,写回inventory = 9,释放锁。 - 进程 A 从 GC
暂停中恢复,它仍然认为自己持有锁(本地状态尚未更新),将它本地缓存的
inventory = 10减 1,写回inventory = 9。 - 结果:两笔订单都被处理了,但库存只从 10 减到了 9,而非正确的 8。一笔扣减被”吞掉”了。
这就是分布式锁在正确性场景下的核心风险:锁的超时机制是必要的(防止死锁),但超时也意味着锁可能在持有者不知情的情况下被撤销。
Fencing Token 如何阻止数据损坏:
sequenceDiagram
participant A as 客户端A
participant Lock as 锁服务
participant B as 客户端B
participant DB as 数据库
A->>Lock: 获取锁
Lock-->>A: 锁获取成功,token=34
A->>DB: 读取库存=10
Note over A: GC暂停开始
Note over Lock: 锁超时过期
B->>Lock: 获取锁
Lock-->>B: 锁获取成功,token=35
B->>DB: 读取库存=10
B->>DB: 写入库存=9(token=35)
DB-->>B: 写入成功,记录max_token=35
B->>Lock: 释放锁
Note over A: GC暂停结束
A->>DB: 写入库存=9(token=34)
DB-->>A: 拒绝:token 34 < max_token 35
上图展示了 Fencing Token 的完整生命周期。锁服务为每次锁授予分配一个单调递增的令牌:客户端 A 获得 token=34,客户端 B 在锁过期后获得 token=35。数据库在接受写入时记录已见过的最大 token 值,当客户端 A 携带过期的 token=34 尝试写入时,数据库检测到 34 < 35,直接拒绝该操作。这样,即使客户端 A “不知道”自己的锁已失效,数据完整性仍然得到保护。
1.4 时钟的不可靠性
分布式锁方案几乎都依赖某种形式的超时(timeout)或过期(expiry)机制来处理持有锁的客户端崩溃的情况。而超时依赖时钟。
在分布式系统中,有两类时钟:
- 墙上时钟(wall clock):即
gettimeofday()返回的时间。受 NTP 校准影响,可能向前跳变也可能向后跳变。在 Redis 的SET EX命令中,过期时间基于 Redis 服务器的墙上时钟。 - 单调时钟(monotonic clock):即
clock_gettime(CLOCK_MONOTONIC)返回的时间。保证单调递增,但只在单机内有意义,不同机器之间的单调时钟无法比较。
Redlock 算法在计算锁的剩余有效时间时,使用的是客户端的本地时钟来测量获取锁的耗时。如果客户端使用单调时钟来做这个测量,那么客户端侧的计时是可靠的。但 Redis 服务端的锁过期依赖的是 Redis 自己的时钟。如果某台 Redis 服务器发生了时钟跳变(例如被 NTP 向前调整了),它上面的锁可能提前过期,导致锁被另一个客户端获取。
二、Kleppmann vs Antirez 的 Redlock 争论(详细还原)
2.1 背景:Redis 单实例锁
Redis 提供了一种简单的锁实现:
SET resource_name my_random_value NX EX 30NX 表示只在 key
不存在时设置(互斥),EX 30 表示 30
秒后过期(防止死锁)。释放锁时,用 Lua
脚本检查值是否匹配后再删除:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end这个方案简单高效,但有一个明显的弱点:Redis 是单点。如果这个 Redis 实例宕机,锁服务就不可用了。如果使用 Redis 主从复制,主节点在写入锁之后、复制到从节点之前崩溃,从节点提升为主节点后,锁就丢失了——两个客户端可以同时持有同一把锁。
2.2 Redlock 算法
Salvatore Sanfilippo(Antirez,Redis 的作者)在 2014 年提出了 Redlock 算法,试图解决单实例的可用性问题。算法使用 N 个独立的 Redis 实例(通常 N=5),步骤如下:
- 客户端获取当前时间戳
T1(使用本地单调时钟)。 - 客户端依次向 N 个 Redis 实例发送
SET resource_name my_random_value NX EX ttl请求,每个请求设置一个较短的超时时间(例如 5-50ms),避免在某个实例不可用时长时间阻塞。 - 客户端获取当前时间戳
T2,计算获取锁的总耗时elapsed = T2 - T1。 - 如果客户端在 N/2+1(即多数)个实例上成功设置了锁,并且
elapsed < ttl,则认为锁获取成功。锁的剩余有效时间为ttl - elapsed。 - 如果获取失败(成功实例不足多数,或耗时超过 ttl),客户端在所有实例上执行释放操作。
直觉上,Redlock 通过多数派(quorum)机制避免了单点故障:即使少数 Redis 实例不可用,只要多数实例正常,锁服务仍然可用。而且,由于 N 个实例之间完全独立(不存在主从复制关系),不存在复制延迟导致锁丢失的问题。
2.2a Redlock 时序可视化
为了更直观地理解 Redlock 算法的执行过程,下面的时序图展示了客户端依次向 5 个独立 Redis 节点获取锁的完整流程:
sequenceDiagram
participant C as 客户端
participant R1 as Redis节点1
participant R2 as Redis节点2
participant R3 as Redis节点3
participant R4 as Redis节点4
participant R5 as Redis节点5
Note over C: 记录起始时间T1
C->>R1: SET lock val NX EX 30
R1-->>C: OK
C->>R2: SET lock val NX EX 30
R2-->>C: OK
C->>R3: SET lock val NX EX 30
R3-->>C: OK(已获得3/5多数派)
C->>R4: SET lock val NX EX 30
R4-->>C: FAIL
C->>R5: SET lock val NX EX 30
R5-->>C: FAIL
Note over C: 记录结束时间T2
Note over C: 验证:T2-T1 < 30s 且成功数>=3
Note over C: 锁有效期 = 30 - (T2-T1)
该图展示了 Redlock 的核心流程:客户端在获取锁前后记录时间戳 T1 和 T2,用于计算锁的剩余有效期。只要在多数派(3/5)节点上成功设置了锁,且总耗时未超过 TTL,就认为锁获取成功。锁的实际有效期是 TTL 减去获取耗时,这保证了客户端不会使用一把即将过期的锁。
2.3 Kleppmann 的批评
2016 年 2 月 8 日,Martin Kleppmann 发表了文章《How to do distributed locking》,对 Redlock 提出了系统性的批评。这篇文章成为分布式系统领域被引用最多的博客文章之一。
论点一:GC 暂停导致锁失效
Kleppmann 构造了以下场景:
- Client 1 通过 Redlock 成功获取锁,锁的 TTL 为 30 秒。
- Client 1 开始执行受保护的操作。
- Client 1 触发 Full GC,暂停 32 秒。
- 锁在 GC 期间过期。Client 2 通过 Redlock 获取了同一把锁。
- Client 2 开始执行受保护的操作。
- Client 1 的 GC 结束。Client 1 认为自己仍然持有锁(它的本地变量中存储的锁信息没有变化),继续执行操作。
- 两个客户端同时操作受保护资源,数据损坏。
Kleppmann 指出,这个问题不是 Redlock 特有的——任何基于超时的锁方案都有这个问题。但 Redlock 并没有解决它,而是声称自己提供了更强的保证。
有人可能会说:“GC 暂停 32 秒不现实。”但 Kleppmann 的论点不依赖具体的时间数字。问题的本质是:在获取锁和使用锁之间,存在一个时间窗口,在这个窗口内任何类型的延迟都可能使锁过期。只要这种延迟的可能性不为零,锁的互斥保证就不是绝对的。
论点二:Redlock 依赖时钟准确性
Redlock 的正确性依赖以下假设:所有 Redis 节点的时钟漂移速率有上界,且在锁的 TTL 期间,任何一个 Redis 节点的时钟偏移量不超过某个可接受的范围。
考虑以下场景:
- Client 1 在 Redis 节点 A、B、C 上获取锁成功(共 5 个节点,3 个成功即达到多数),节点 D、E 获取失败。
- 节点 C 上发生了时钟跳变(被 NTP 向前调了),锁提前过期。
- Client 2 在节点 C、D、E 上获取锁成功(C 上的锁已过期,所以可以重新获取)。
- 此时 Client 1 和 Client 2 都认为自己持有锁。
Kleppmann 论证说,这种时钟跳变在真实系统中并非罕见。NTP 守护进程可能因为长时间无法同步后突然追赶,虚拟化环境中的时钟行为更加不可预测。一个声称提供正确性保证的分布式锁算法不应该依赖时钟假设。
论点三:Redlock 无法提供隔离令牌(Fencing Token)
这是 Kleppmann 最核心的论点。他认为,即使一个锁方案在正常情况下能保证互斥,但在异常情况下(如上面的 GC 暂停场景)不可避免地会失败。因此,正确的做法不是试图让锁在所有情况下都互斥,而是让受保护的资源能够拒绝来自过期锁持有者的操作。
实现方式是隔离令牌(Fencing Token):每次成功获取锁时,锁服务发放一个单调递增的令牌。客户端在操作受保护资源时必须携带这个令牌。资源服务(如数据库)记录见到的最大令牌号,拒绝任何携带更小令牌号的操作。
Kleppmann 指出,Redlock 无法提供隔离令牌。因为 Redlock 的锁是在 N 个独立的 Redis 实例上分别获取的,没有一个统一的单调递增序列。每个 Redis 实例独立运行,无法协调出一个全局递增的令牌编号。
2.4 Antirez 的反驳
Antirez 在几天后发表了回应文章《Is Redlock safe?》。他逐一反驳了 Kleppmann 的论点。
对 GC 暂停论点的反驳
Antirez 承认 GC 暂停确实会导致问题,但他认为这个问题可以通过以下方式缓解:
- 客户端在获取锁后、开始操作之前,应该检查锁的剩余有效时间。如果剩余时间太短,应该放弃操作并释放锁。
- 如果 GC 暂停发生在检查之后、操作之前,那么——对,确实有问题。但 Antirez 认为这个时间窗口很小,GC 恰好在这个窗口发生的概率极低。
Antirez 还提出了一个更激进的论点:Kleppmann 描述的 GC 暂停问题不是 Redlock 特有的,任何分布式锁方案都有这个问题,包括 ZooKeeper。即使使用 ZooKeeper 的临时节点(ephemeral node),如果客户端因为 GC 暂停而无法及时发送心跳,会话(session)会过期,锁会被释放。另一个客户端可以获取锁。当第一个客户端的 GC 结束时,它发现会话已过期,但此时它可能已经执行了一些操作——和 Redlock 的情况一样。
这个反驳有一定道理:GC 暂停的问题确实不是 Redlock 独有的。但 Kleppmann 的论点并不是说”Redlock 有 GC 问题而其他方案没有”,而是说”正因为任何锁方案都有这个问题,所以需要 Fencing Token 作为额外的安全层”。
对时钟漂移论点的反驳
Antirez 区分了两种时钟问题:
- 时钟跳变(clock jump):时钟突然向前或向后跳动大量时间。这确实是问题,但可以通过配置 NTP 使用渐进调整(slew mode)而非阶跃调整(step mode)来避免。
- 时钟漂移(clock drift):时钟运行速率与真实时间不完全一致。石英晶振的典型漂移率在 200ppm(百万分之 200)以内,即每秒漂移不超过 200 微秒。在 30 秒的 TTL 内,累计漂移不超过 6 毫秒——对 Redlock 的正确性没有影响。
Antirez 认为,Redlock 的正确性假设是合理的:不要求时钟精确同步,只要求时钟漂移率有界且不发生大幅跳变。这是一个在实践中容易满足的假设。
对 Fencing Token 论点的反驳
Antirez 对 Fencing Token 的论点提出了两个反驳:
第一,如果资源服务本身就能通过检查令牌来拒绝过期操作,那为什么还需要分布式锁?直接用资源服务的条件写入(conditional write)就行了。换句话说,Fencing Token 把正确性的保证从锁服务转移到了资源服务——这等于说锁本身不需要提供正确性保证。
第二,Antirez 提出了自动续期(auto-extension)机制作为替代方案:客户端在持有锁期间,定期向 Redis 发送续期请求,延长锁的 TTL。如果客户端因为 GC 或网络问题无法续期,锁才会过期。这个机制类似于 ZooKeeper 的会话心跳。
Redisson(Java 的 Redis 客户端库)实现了这种自动续期机制,称为”看门狗(watchdog)“。每隔 TTL/3 的时间自动延长锁的过期时间。
2.5 争论的核心分歧
剥开技术细节,Kleppmann 和 Antirez 的核心分歧在于对安全性(safety)的定义层次不同。
Antirez 的立场:Redlock 提供的互斥保证在合理的系统假设下是成立的。这些假设(时钟漂移有界、GC 不会暂停太长时间)在实际生产环境中通常是满足的。追求理论上的绝对安全性是不切实际的,工程上的”足够好”才是正确的目标。
Kleppmann 的立场:如果你使用锁是为了正确性(而不仅仅是效率),那么锁方案的安全性不应该依赖任何时序假设。正确的做法是承认锁可能失效,用 Fencing Token 在更高层次上保证安全。Redlock 的问题不在于它的算法有 bug,而在于它试图通过一个本质上不够强的机制来提供它声称的保证。
谁对了?
从严格的分布式系统理论角度看,Kleppmann 的分析更站得住脚。Redlock 的安全性确实依赖时序假设,而这些假设在极端情况下可能不成立。但 Antirez 的工程直觉也有价值:在大多数实际场景中,Redlock 的安全性是”足够的”——尤其是当锁的目的是效率而非正确性时。
更深层的教训是:如果你需要锁来保证正确性,那么仅靠锁本身是不够的。你需要端到端的(end-to-end)安全机制——Fencing Token 就是这样一种机制。
2.6 争论之后的工业实践
这场争论对工业实践产生了深远影响:
- Redis 官方文档明确指出 Redlock 的适用场景和局限性。
- 越来越多的团队在需要正确性保证时转向 etcd 或 ZooKeeper,而将 Redis 锁留给效率类场景。
- Fencing Token 的概念被广泛采纳。etcd 的官方 concurrency 库原生提供了基于 revision 的 fencing 支持。
- 学术界和工业界对”什么样的锁是正确的”有了更清晰的共识。
三、Fencing Token 方案
3.1 核心思想
Fencing Token(隔离令牌)方案的核心思想简洁而强大:每次成功获取锁时,锁服务颁发一个全局单调递增的令牌编号。客户端在对受保护资源执行操作时,必须附带这个令牌。资源服务记录已见过的最大令牌编号,拒绝任何携带更小编号的操作请求。
这个方案之所以有效,是因为它把安全性从”锁必须在任何时刻保证互斥”降低为”资源服务必须能区分新旧令牌”——后者是一个容易实现的条件。
下面用具体场景说明。参考 Fencing Token 流程图:
- Client 1 获取锁,得到令牌 33。
- Client 1 开始处理任务,随后触发 GC 暂停。
- 锁过期。Client 2 获取锁,得到令牌 34。
- Client 2 向存储服务写入数据,携带令牌 34。存储服务记录最大令牌为 34,写入成功。
- Client 1 从 GC 暂停中恢复,尝试向存储服务写入数据,携带令牌 33。
- 存储服务检查:33 < 34,拒绝写入。
数据完整性得到保护。
3.2 令牌的生成
Fencing Token 的有效性取决于一个关键性质:令牌必须是全局单调递增的。后获取锁的客户端的令牌一定大于先获取锁(且锁已过期)的客户端的令牌。
不同的锁服务提供不同的天然令牌来源:
| 锁服务 | 令牌来源 | 保证 |
|---|---|---|
| ZooKeeper | 创建顺序临时节点时返回的 zxid(ZooKeeper Transaction ID) | 全局单调递增,跨所有操作 |
| etcd | 写入锁 key 时返回的 Revision | 全局单调递增,集群级别唯一 |
| Redis(单实例) | 无天然来源,需额外实现 | 需要 Lua 脚本原子递增 |
| Redlock | 无法提供 | N 个独立实例无统一序列 |
ZooKeeper 和 etcd 之所以能天然提供 Fencing Token,是因为它们都是基于共识协议(ZAB 和 Raft)的复制状态机——所有写操作都被排成一个全局有序的日志,日志中的位置就是天然的单调递增令牌。
Redis 单实例可以用 INCR
命令生成递增令牌,但这引入了新的单点故障。Redlock
的多实例架构无法生成全局有序的令牌,因为各实例之间没有协调。
3.3 资源侧的验证
Fencing Token 方案要求受保护的资源服务具备令牌验证能力。具体实现取决于资源类型:
关系型数据库:使用条件更新语句。
UPDATE inventory
SET stock = stock - 1,
fence_token = 34
WHERE item_id = 'SKU-001'
AND fence_token < 34;如果 fence_token 列当前值已经是 34
或更大,WHERE
条件不满足,更新零行——过期客户端的操作被拒绝。
对象存储 / KV 存储:许多存储系统支持条件写入。例如,Amazon S3 的条件写入、etcd 的事务比较操作、Google Cloud Storage 的代际条件(generation condition)。
// etcd 条件事务:只有当 key 的 ModRevision 等于预期值时才写入
txnResp, err := cli.Txn(ctx).
If(clientv3.Compare(clientv3.ModRevision("resource"), "=", expectedRevision)).
Then(clientv3.OpPut("resource", newValue)).
Commit()消息队列 / 文件系统:如果受保护的资源是消息队列或文件系统等不支持条件写入的服务,需要在资源前面加一个验证代理层来检查令牌。
3.4 局限性
Fencing Token 方案并非银弹,它有明确的局限性:
- 要求资源侧配合:资源服务必须能理解和检查令牌。如果受保护的资源是一个不支持条件写入的第三方 API(比如发送短信),Fencing Token 无法直接使用。
- 令牌空间是全局的:在高并发场景下,单一的令牌生成器可能成为瓶颈。不过实际中这很少是问题,因为锁本身就是一个串行化点。
- 不防止重复操作:Fencing Token 防止的是”旧令牌覆盖新令牌的写入”,但不防止同一个令牌的重复提交。去重需要额外的幂等性机制。
- 需要修改业务逻辑:现有系统如果没有令牌检查机制,引入 Fencing Token 需要修改数据访问层,可能涉及数据库 schema 变更。
3.5 为什么 Redlock 不能提供 Fencing Token
回到 Redlock 争论的核心。Redlock 使用 N 个独立 Redis 实例,客户端在其中至少 N/2+1 个实例上成功设置锁即认为获取成功。但这 N 个实例之间没有任何协调:
- 实例 A 上的锁被获取第 10 次,实例 B 上被获取第 7 次,实例 C 上被获取第 12 次。
- 客户端应该用哪个数字作为 Fencing Token?取最大值?取多数?都不行——因为下一次获取锁的客户端可能在不同的实例组合上成功,得到的数字不一定比当前客户端大。
要生成全局单调递增的令牌,需要所有发放令牌的节点就”下一个令牌编号是多少”达成共识。这正是共识协议解决的问题。Redlock 明确避开了共识协议(这是它声称比 ZooKeeper 简单的原因),但也因此无法提供 Fencing Token。
四、基于 etcd 的正确实现
4.1 etcd 的天然优势
etcd 基于 Raft 共识协议,所有写操作被线性化排序。每次写入都会产生一个全局递增的 Revision。etcd 还原生支持租约(Lease)——一种带 TTL 的会话机制,客户端需要定期续租。租约过期后,关联的 key 自动删除。
这些特性使 etcd 天然适合实现带 Fencing Token 的分布式锁:
- 锁实现为与租约关联的 key。
- 租约充当心跳机制——客户端存活时自动续租,崩溃后租约过期,锁自动释放。
- 写入锁 key 时的 Revision 作为 Fencing Token。
4.2 使用 clientv3/concurrency 包
etcd 官方的 Go 客户端提供了 concurrency
包,封装了分布式锁的正确实现:
package main
import (
"context"
"fmt"
"log"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/client/v3/concurrency"
)
func main() {
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"},
DialTimeout: 5 * time.Second,
})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建一个 Session,内部维护一个 Lease 并自动续租。
// TTL 是租约的超时时间:如果客户端崩溃无法续租,
// 该时间后锁会自动释放。
session, err := concurrency.NewSession(cli, concurrency.WithTTL(15))
if err != nil {
log.Fatal(err)
}
defer session.Close()
// 创建一个 Mutex。前缀 "/my-lock/" 标识锁的名字。
mutex := concurrency.NewMutex(session, "/my-lock/")
// 获取锁。如果锁已被其他 Session 持有,阻塞等待。
if err := mutex.Lock(context.TODO()); err != nil {
log.Fatal(err)
}
// mutex.Header().Revision 就是 Fencing Token。
fencingToken := mutex.Header().Revision
fmt.Printf("acquired lock, fencing token = %d\n", fencingToken)
// 在这里执行受保护的操作,使用 fencingToken 作为隔离令牌。
doProtectedWork(cli, fencingToken)
// 释放锁。
if err := mutex.Unlock(context.TODO()); err != nil {
log.Fatal(err)
}
}
func doProtectedWork(cli *clientv3.Client, fencingToken int64) {
// 使用 etcd 事务进行条件写入:
// 只有当目标 key 的 ModRevision 小于 fencingToken 时才执行写入。
ctx := context.TODO()
txnResp, err := cli.Txn(ctx).
If(clientv3.Compare(clientv3.ModRevision("protected-resource"), "<", fencingToken)).
Then(clientv3.OpPut("protected-resource", "updated-value")).
Commit()
if err != nil {
log.Fatal(err)
}
if txnResp.Succeeded {
fmt.Println("write succeeded")
} else {
fmt.Println("write rejected: stale fencing token")
}
}4.3 内部实现原理
concurrency.Mutex
的内部实现值得深入理解:
- 获取锁:在
/my-lock/前缀下创建一个以 LeaseID 为后缀的 key(例如/my-lock/694d81e8c4b2a107),值为空字符串。创建操作使用Put并附带租约。 - 等待机制:创建 key 后,检查前缀下是否有
Revision 更小的 key
存在。如果有,说明其他客户端先于自己创建了锁
key——自己需要等待。客户端使用 etcd 的
WatchAPI 监听 Revision 最小的那个 key 的删除事件。 - 释放锁:删除自己创建的 key。等待队列中的下一个客户端收到 Watch 事件,发现自己的 key 现在是 Revision 最小的,获取锁成功。
这个设计有几个精妙之处:
- 公平性:所有等待者按照创建 key 的 Revision 排序,先到先得。
- 无惊群效应(herd effect):每个等待者只 Watch 自己前面的一个 key,而非整个前缀。当锁被释放时,只有一个等待者被唤醒。
- 自动释放:如果持有锁的客户端崩溃,其 Session 对应的 Lease 过期,锁 key 被自动删除,下一个等待者被唤醒。
4.4 边界情况处理
租约过期但操作尚未完成
这是分布式锁最棘手的情况。如果客户端在执行受保护操作期间因为 GC 暂停或网络分区而无法续租,租约过期,锁被释放。当客户端恢复时,它可能不知道锁已经易手。
etcd 的 Session 提供了一个 Done() 通道,在
Session
过期时关闭。客户端应该在执行关键操作时定期检查这个通道:
select {
case <-session.Done():
// Session 已过期,锁已释放。
// 必须立即停止操作。
return ErrSessionExpired
default:
// Session 仍然有效,继续操作。
}但这种检查和上面讨论的一样,存在检查与操作之间的时间窗口。所以 Fencing Token 仍然是必要的——它是最后一道防线。
网络分区
如果客户端和 etcd 集群之间发生网络分区,客户端的续租请求无法到达 etcd。租约最终过期,锁被释放。分区愈合后,客户端发现 Session 已过期。
在分区期间,客户端可能认为自己仍持有锁(因为它的本地状态没变),但 etcd 集群侧锁已经被释放。这再次说明了 Fencing Token 的必要性:即使客户端在分区期间执行了操作,如果操作携带了过期的令牌,资源侧会拒绝。
五、基于 ZooKeeper 的正确实现
5.1 临时顺序节点
ZooKeeper 实现分布式锁的经典方式使用临时顺序节点(ephemeral sequential node)。每个客户端在锁路径下创建一个临时顺序节点,然后检查自己是否拥有序号最小的节点。
临时节点(ephemeral node)与客户端的会话(session)绑定。如果客户端崩溃或会话超时,临时节点自动删除。这提供了自动释放锁的机制。
顺序节点(sequential node)由 ZooKeeper
自动追加递增序号。多个客户端同时创建
/lock/node- 会得到
/lock/node-0000000001、/lock/node-0000000002
等。这个序号是在 ZooKeeper
服务端原子分配的,保证全局递增。
5.2 锁获取流程
标准的 ZooKeeper 分布式锁实现遵循 Apache Curator 的
InterProcessMutex 算法:
- 在锁路径(例如
/locks/my-resource)下创建一个临时顺序节点:/locks/my-resource/lock-0000000042。 - 获取
/locks/my-resource下的所有子节点列表。 - 如果自己创建的节点序号最小,获取锁成功。
- 否则,找到序号比自己小的前一个节点(例如
/locks/my-resource/lock-0000000041),对它设置 Watcher。 - 等待 Watcher 触发。前一个节点被删除(前一个锁持有者释放或崩溃),重新检查自己是否序号最小。
public class ZkDistributedLock {
private final ZooKeeper zk;
private final String lockPath;
private String myNode;
public ZkDistributedLock(ZooKeeper zk, String lockPath) {
this.zk = zk;
this.lockPath = lockPath;
}
public long lock() throws Exception {
// 创建临时顺序节点
myNode = zk.create(
lockPath + "/lock-",
new byte[0],
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL
);
while (true) {
List<String> children = zk.getChildren(lockPath, false);
Collections.sort(children);
String myName = myNode.substring(myNode.lastIndexOf('/') + 1);
int myIndex = children.indexOf(myName);
if (myIndex == 0) {
// 序号最小,获取锁成功。
// 返回创建节点时的 zxid 作为 Fencing Token。
Stat stat = zk.exists(myNode, false);
return stat.getCzxid();
}
// Watch 前一个节点
String prevNode = children.get(myIndex - 1);
CountDownLatch latch = new CountDownLatch(1);
Stat prevStat = zk.exists(
lockPath + "/" + prevNode,
event -> latch.countDown()
);
if (prevStat != null) {
latch.await();
}
// 循环重新检查
}
}
public void unlock() throws Exception {
zk.delete(myNode, -1);
}
}5.3 Watch 前一节点:避免惊群效应
为什么每个等待者只 Watch 前一个节点,而不是直接 Watch 锁路径下的所有变化?
如果所有等待者都 Watch 同一个路径,当锁被释放时,ZooKeeper 会同时通知所有等待者。所有等待者都会去获取子节点列表并检查自己是否是序号最小的——但只有一个会成功。其余的重新注册 Watch 并继续等待。这就是惊群效应(herd effect):N 个等待者产生 O(N) 的消息量,但只有一个有用。
通过只 Watch 前一个节点,当锁释放时,只有一个等待者(序号排在第二的那个)被通知,它检查后发现自己现在是序号最小的,获取锁成功。消息量从 O(N) 降到 O(1)。
5.4 zxid 作为 Fencing Token
ZooKeeper 的每个写操作都对应一个全局递增的事务 ID(zxid)。客户端创建临时顺序节点时得到的 zxid 可以直接用作 Fencing Token。
具体来说,Stat.getCzxid()
返回的是节点创建时的 zxid。由于 ZooKeeper 的所有写操作通过
ZAB 协议排成全序,后获取锁的客户端创建的节点的 czxid
一定大于先获取锁的客户端——这正是 Fencing Token
所需要的单调递增性。
5.5 会话过期处理
ZooKeeper 客户端与服务器之间通过心跳维持会话。如果服务器在会话超时时间内没有收到心跳,会话过期,所有临时节点被删除。
客户端侧可以通过 Watcher 检测会话状态:
zk.register(event -> {
if (event.getState() == KeeperState.Expired) {
// 会话已过期,锁已释放。
// 必须停止所有受保护操作。
handleSessionExpired();
}
});与 etcd 的情况类似,这个通知可能在客户端已经执行了一些操作之后才到达(尤其是在 GC 暂停期间)。所以 Fencing Token 仍然是最终的安全保障。
六、锁粒度与性能
6.1 粗粒度锁与细粒度锁
锁粒度(lock granularity)指锁保护的资源范围大小。选择锁粒度需要在简单性和并发度之间权衡。
粗粒度锁(Coarse-grained Lock)保护一个较大的资源范围。例如,整个数据库表的锁、整个服务的全局锁。优点是实现简单,不容易出死锁。缺点是并发度低——所有操作都被串行化。
细粒度锁(Fine-grained Lock)保护一个小的资源单元。例如,单行数据的锁、单个用户的锁。优点是并发度高——不同资源的操作互不影响。缺点是锁的数量多,管理复杂,可能出现死锁。
在分布式环境中,锁粒度的选择还受到性能的制约。每次获取和释放锁都需要至少一次网络往返。如果使用 etcd 或 ZooKeeper,这意味着一次 Raft/ZAB 共识操作——延迟通常在数毫秒到数十毫秒之间。对于高频操作,锁的开销可能成为瓶颈。
6.2 锁竞争与吞吐量
锁竞争(lock contention)是分布式锁性能的核心问题。当多个客户端频繁竞争同一把锁时,大部分时间花在等待上,有效吞吐量急剧下降。
阿姆达尔定律(Amdahl’s
Law)的扩展形式可以描述锁竞争的影响:如果一个操作中有比例
p
的部分需要持有锁执行,那么即使有无限的并发客户端,总吞吐量也不会超过
1/p 倍的单线程吞吐量。
减少锁竞争的策略:
- 缩短临界区:只在必须互斥的操作上持有锁,锁内不做 I/O。
- 分段锁(Striped Lock):将一个资源拆分为多个段,每段一把锁。例如,ConcurrentHashMap 的分段设计。
- 读写锁(Read-Write
Lock):读操作共享锁,写操作排他锁。适用于读多写少的场景。etcd
的 concurrency 包提供了
ReadMutex和WriteMutex。 - 避免锁:如果可以用无锁数据结构或乐观并发控制替代,就不用锁。
6.3 性能对比
不同锁实现的性能特征差异很大:
| 方案 | 获取锁延迟(P50) | 获取锁延迟(P99) | 安全性保证 | 适用场景 |
|---|---|---|---|---|
| Redis 单实例 | <1ms | 1-2ms | 弱(单点故障、主从切换丢锁) | 效率类场景 |
| Redlock(5 节点) | 2-5ms | 10-20ms | 中(依赖时钟假设) | 效率类场景 |
| etcd | 5-15ms | 20-50ms | 强(Raft 共识 + Fencing Token) | 正确性场景 |
| ZooKeeper | 5-10ms | 15-40ms | 强(ZAB 共识 + Fencing Token) | 正确性场景 |
数字来自典型的三节点集群部署,同机房网络延迟在 1ms 以内的环境。跨机房场景下延迟会显著增加。
Redis 的优势在于简单和快。如果锁的目的是效率(避免重复计算),偶尔的锁失效可以接受,Redis 单实例锁就足够了。如果锁的目的是正确性(防止数据损坏),应该使用 etcd 或 ZooKeeper,并配合 Fencing Token。
6.4 无锁替代方案
在某些场景下,完全可以避免使用分布式锁:
- 幂等操作:如果操作本身是幂等的,重复执行不会造成问题,就不需要锁来保证”恰好一次”。
- CRDT(Conflict-free Replicated Data Type):无冲突复制数据类型允许并发修改而不需要协调,适用于计数器、集合等特定数据结构。
- 队列化:将并发请求排入队列,由单个消费者顺序处理,用串行化代替互斥锁。
- 数据库原生机制:关系数据库的
SELECT ... FOR UPDATE或序列化隔离级别提供了内置的互斥能力,不需要外部锁。
七、Advisory Lock 与 Mandatory Lock
7.1 Advisory Lock
Advisory Lock(建议锁 / 协作锁)是一种协作式的锁机制:锁的正确性依赖所有参与者自觉遵守协议——在访问共享资源前先获取锁,操作完成后释放锁。系统不强制检查客户端是否持有锁。
几乎所有分布式锁都是 Advisory Lock。Redis 的
SET NX、etcd 的
concurrency.Mutex、ZooKeeper
的临时顺序节点——它们只是在一个外部服务上记录”谁持有锁”的状态。如果某个客户端绕过锁直接访问受保护资源(比如直接连数据库修改数据),锁完全无法阻止它。
Advisory Lock 的风险在于:
- 新加入团队的开发者可能不知道某个资源需要先获取锁。
- 代码重构过程中,锁获取逻辑被意外删除。
- 不同服务用不同的锁 key 保护同一个资源(key 命名不一致)。
7.2 Mandatory Lock
Mandatory Lock(强制锁)由系统层面强制执行:未持有锁的客户端根本无法访问受保护的资源。访问控制在资源侧实现,而非依赖客户端自律。
典型的 Mandatory Lock 实现:
- 数据库行锁:当事务对某行执行
SELECT ... FOR UPDATE后,其他事务对同一行的写操作会被阻塞或报错。这是数据库引擎强制执行的,客户端无法绕过。 - 文件系统强制锁:Linux 的
fcntl锁在挂载文件系统时使用-o mand选项后可以变为强制锁,但实际使用很少。 - 操作系统内核锁:内核级别的互斥机制(如 Linux 的 futex)是强制的——违反互斥会导致进程阻塞或内核崩溃。
7.3 Fencing Token 弥合 Advisory 与 Mandatory 的鸿沟
Fencing Token 的巧妙之处在于:它在不改变锁本身的 Advisory 性质的前提下,在资源侧增加了接近 Mandatory 的保护。
考虑一下完整的链路:
- 锁服务本身仍然是 Advisory 的——一个恶意或 bug 客户端可以不获取锁就尝试操作资源。
- 但资源服务要求每个操作携带有效的 Fencing Token。如果客户端没有锁,它就没有 Token;如果它的锁已过期,它的 Token 一定小于当前持有者的 Token。
- 资源服务拒绝无效 Token 的操作——这是在资源侧强制执行的。
这不是完美的 Mandatory Lock——客户端可以伪造 Token(如果 Token 可猜测)、客户端可以不通过锁服务直接猜一个大的 Token 值。但在协作型系统(所有客户端都是自己团队的代码、没有恶意攻击者)中,Fencing Token 提供了比纯 Advisory Lock 强得多的安全保证。
7.4 数据库层面的强制锁
在许多场景中,最简单的做法是直接使用数据库提供的锁机制,而不是引入外部的分布式锁服务。
PostgreSQL 的 Advisory Lock:
PostgreSQL 提供了原生的 Advisory Lock 功能:
-- 获取 Advisory Lock(阻塞式)
SELECT pg_advisory_lock(12345);
-- 执行受保护操作...
-- 释放
SELECT pg_advisory_unlock(12345);这种锁存储在 PostgreSQL 的共享内存中,具有事务级别或会话级别的生命周期。连接断开时自动释放。它的优势是不需要额外的锁服务——如果你的受保护资源就是这个数据库中的数据,用数据库自带的锁是最自然的选择。
MySQL 的 GET_LOCK:
-- 获取命名锁,超时 10 秒
SELECT GET_LOCK('my_resource', 10);
-- 执行受保护操作...
-- 释放
SELECT RELEASE_LOCK('my_resource');这些数据库级别的锁是 Mandatory 的(只要所有客户端都通过同一个数据库访问数据),因为数据库控制了资源访问的唯一入口。
八、“用版本号代替锁”的论点
8.1 乐观并发控制
与锁(悲观并发控制)相对的是乐观并发控制(Optimistic Concurrency Control,OCC):不加锁,允许并发操作自由进行,只在提交时检查是否有冲突。
乐观并发控制的核心机制是版本号(version)或时间戳。每个数据项维护一个版本号,每次修改递增。客户端读取数据时记住版本号,写入时携带这个版本号作为条件:
读取:data = read(key) -> {value: "foo", version: 42}
修改:new_value = process(data.value) -> "bar"
写入:write(key, "bar", expected_version=42)
-> 成功(如果当前版本仍为 42)
-> 失败(如果版本已变为 43,说明有其他人修改过)
8.2 Compare-and-Swap 操作
比较并交换(Compare-and-Swap,CAS)是乐观并发控制在存储层面的原子操作。etcd、ZooKeeper、DynamoDB、Cosmos DB 等都原生支持 CAS 语义。
etcd 的 CAS 事务:
func casUpdate(cli *clientv3.Client, key, oldVal, newVal string) (bool, error) {
ctx := context.TODO()
txnResp, err := cli.Txn(ctx).
If(clientv3.Compare(clientv3.Value(key), "=", oldVal)).
Then(clientv3.OpPut(key, newVal)).
Else(clientv3.OpGet(key)).
Commit()
if err != nil {
return false, err
}
return txnResp.Succeeded, nil
}使用 ModRevision 的 CAS:
更精确的做法是用 ModRevision
而非值来做条件判断。ModRevision
在每次修改时自动递增,不需要客户端自己维护版本号:
func casUpdateByRevision(cli *clientv3.Client, key, newVal string, expectedModRev int64) (bool, error) {
ctx := context.TODO()
txnResp, err := cli.Txn(ctx).
If(clientv3.Compare(clientv3.ModRevision(key), "=", expectedModRev)).
Then(clientv3.OpPut(key, newVal)).
Commit()
if err != nil {
return false, err
}
return txnResp.Succeeded, nil
}8.3 版本向量与条件写入
在多副本系统中,简单的单一版本号不足以捕获所有冲突。版本向量(Version Vector)为每个副本维护独立的计数器,可以精确检测并发修改:
副本 A: {A: 5, B: 3}
副本 B: {A: 4, B: 4}
如果副本 A 收到一个来自 B 的更新,向量为
{A: 4, B: 4},A 发现 B 的分量(4 >
3)比自己新,但 A 的分量(5 > 4)也比 B
新——这是一个并发修改,需要冲突解决。
DynamoDB 和 Riak 等系统使用版本向量的变体来实现乐观并发控制。
8.4 版本号方案与锁方案的对比
| 维度 | 锁(悲观) | 版本号(乐观) |
|---|---|---|
| 冲突频率高时 | 性能好(等待而非重试) | 性能差(大量重试和浪费) |
| 冲突频率低时 | 性能差(每次都有锁开销) | 性能好(通常一次成功) |
| 实现复杂度 | 需要锁服务 + 死锁检测 | 需要重试逻辑 + 版本管理 |
| 正确性 | 依赖锁的正确释放 | 由存储层原子保证 |
| 操作的可观测性 | 容易监控锁等待时间 | 难以监控重试率 |
8.5 何时用版本号替代锁
以下场景适合用版本号代替锁:
- 读多写少:冲突概率低,乐观策略几乎不需要重试。
- 操作可重试:操作本身是幂等的或可安全重试,CAS 失败后重新读取-计算-写入即可。
- 避免外部依赖:不想引入 etcd、ZooKeeper 等外部锁服务,希望用存储系统自身的 CAS 能力解决问题。
- 跨数据源操作:如果操作涉及多个独立的数据源,分布式锁需要跨所有数据源的协调,而版本号可以在每个数据源上独立检查。
以下场景仍然需要锁:
- 操作不可重试:例如,发送邮件、调用支付 API 等有副作用的操作。CAS 失败后无法”撤销”已执行的副作用。
- 写入冲突频繁:乐观方案在高冲突下退化严重——大量客户端反复读取-计算-写入-失败-重试,浪费计算和网络资源。
- 长时间操作:如果操作耗时长(秒级),在操作期间数据被修改的概率高,乐观方案的成功率低。
- 多步骤事务:如果操作涉及多个步骤,需要在整个事务期间保持互斥,锁比多个 CAS 更容易推理正确性。
8.6 混合方案
实际系统中,锁和版本号常常结合使用。Fencing Token 本身就是一种混合方案:用锁减少冲突频率,用令牌(本质上是版本号)保证极端情况下的正确性。
Google Spanner 的读写事务是另一个例子:事务开始时获取锁(悲观),但写入使用时间戳排序(乐观),两者结合提供了序列化隔离级别。
九、工程实践建议
9.1 决策框架
选择分布式锁方案时,首先回答以下问题:
问题一:锁的目的是什么?
如果是效率(避免重复工作),用 Redis 单实例锁。简单、快、运维成本低。锁偶尔失效不会造成数据损坏——上层逻辑应该是幂等的。
如果是正确性(保护数据完整性),用 etcd 或 ZooKeeper,配合 Fencing Token。接受更高的延迟和更复杂的运维,换取更强的安全保证。
问题二:受保护的资源支持条件写入吗?
如果支持(如 SQL 数据库、etcd、支持 ETag 的 API),可以实现 Fencing Token,获得端到端的安全保证。
如果不支持(如发送短信、调用第三方 API),Fencing Token 无法使用。需要考虑其他策略——如将操作拆分为”准备”和”提交”两阶段,在”提交”阶段通过支持条件写入的中间存储来检查令牌。
问题三:能否完全避免锁?
如果操作是幂等的且冲突频率低,考虑用 CAS / 版本号替代锁。减少系统复杂度。
9.2 锁超时设置
锁的超时时间(TTL)设置是一个经典的工程权衡:
- 太短:正常操作可能还没完成锁就过期了,导致并发冲突。
- 太长:客户端崩溃后,锁长时间不释放,阻塞其他客户端。
推荐做法:
- 测量正常操作的 P99.9 延迟,将 TTL 设置为该值的 3-5 倍。
- 使用自动续租机制(etcd Session、Redisson Watchdog),将 TTL 设得较短(如 15-30 秒),但在客户端正常运行时自动续期。
- 如果使用 Fencing Token,TTL 的设置可以更激进(更短),因为 Fencing Token 提供了额外的安全保障。
9.3 监控与告警
分布式锁的问题往往是沉默的——直到造成数据损坏才被发现。建立完善的监控至关重要:
核心指标:
- 锁获取延迟:P50、P99、P99.9。延迟突增通常意味着锁竞争加剧。
- 锁等待队列长度:排队等待获取锁的客户端数量。持续增长意味着锁成为瓶颈。
- 锁持有时间:客户端持有锁的时间分布。异常长的持有时间可能意味着客户端卡住了。
- Fencing Token 拒绝率:资源侧因令牌过期而拒绝操作的频率。大于零说明发生了锁失效——虽然 Fencing Token 保护了数据,但需要排查根因。
- 锁续租失败率:续租请求失败的频率。持续增长可能是网络问题的征兆。
告警规则:
- Fencing Token 拒绝发生时立即告警(P0,说明锁失效且差点损坏数据)。
- 锁获取延迟 P99 超过 TTL 的 50% 时告警(锁竞争可能导致超时)。
- 锁持有时间超过 TTL 的 80% 时告警(操作可能在锁过期前完不成)。
9.4 测试分布式锁
分布式锁的正确性很难通过单元测试验证。需要故障注入(fault injection)测试:
手动故障注入:
# 模拟网络分区:使用 iptables 阻断客户端与 etcd 的通信
iptables -A OUTPUT -p tcp --dport 2379 -j DROP
# 模拟时钟跳变(Redis Redlock 测试)
date -s "+60 seconds"
# 模拟 GC 暂停:对 Java 进程发送 SIGSTOP/SIGCONT
kill -STOP <pid>
sleep 40
kill -CONT <pid>使用 Jepsen 框架:
Jepsen 是分布式系统正确性测试的标准工具。它可以自动注入网络分区、进程崩溃、时钟偏移等故障,同时检查系统是否违反了线性一致性等正确性条件。etcd 和 ZooKeeper 都经过了 Jepsen 测试。
关键测试场景:
- 客户端持有锁期间网络分区 30 秒以上,验证锁是否正确释放且 Fencing Token 是否阻止了过期操作。
- 锁服务集群 Leader 节点宕机,验证锁的状态是否在新 Leader 上正确恢复。
- 多个客户端同时获取和释放同一把锁,持续几小时,验证没有出现”两个客户端同时持有锁”的情况。
- 客户端在持有锁期间触发 GC 暂停(通过设置极小的堆或调用
System.gc()),验证 Fencing Token 是否正确拒绝了过期操作。
9.5 常见反模式
反模式一:用 Redis SETNX +
EXPIRE 两条命令实现锁
SETNX my-lock client-id
EXPIRE my-lock 30如果 SETNX
成功后客户端崩溃,EXPIRE
未执行,锁永远不会过期——死锁。必须用
SET key value NX EX ttl 单条命令原子设置。
反模式二:释放锁时不检查持有者
DEL my-lock如果客户端 A 的锁已过期,客户端 B
获取了同一把锁,然后客户端 A 执行
DEL——删除的是客户端 B 的锁。必须用 Lua
脚本检查锁的值是否匹配后再删除。
反模式三:在锁的临界区内执行 I/O 密集操作
锁的持有时间应该尽可能短。在临界区内执行网络请求、磁盘 I/O 等不可预测延迟的操作会显著增加锁持有时间,加剧竞争。应该将 I/O 操作移到临界区外面:先读取所有需要的数据,计算结果,然后只在写入时持有锁。
反模式四:跨服务使用同一个锁 key 但没有统一管理
不同服务用字符串拼接生成锁 key,拼接规则不一致导致同一个资源被不同 key 保护——互斥失效。应该建立集中的锁 key 命名规范和注册表。
反模式五:忽视锁续租的重要性
获取锁后不续租,完全依赖初始 TTL。如果操作偶尔超过 TTL,锁静默过期,数据损坏。应该使用自动续租机制,或在操作期间定期检查锁是否仍然有效。
参考文献
- Kleppmann, M. (2016). How to do distributed locking. https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
- Salvatore Sanfilippo (Antirez). (2016). Is Redlock safe?. http://antirez.com/news/101
- Redis Documentation. Distributed Locks with Redis (Redlock). https://redis.io/docs/manual/patterns/distributed-locks/
- Kleppmann, M. (2017). Designing Data-Intensive Applications. O’Reilly. Chapter 8.
- etcd Documentation. Distributed locks. https://etcd.io/docs/v3.5/dev-guide/api_concurrency_reference_v3/
- Apache Curator. Shared Lock. https://curator.apache.org/docs/recipes-shared-lock
- Hunt, P., Konar, M., Junqueira, F. P., & Reed, B. (2010). ZooKeeper: Wait-free Coordination for Internet-scale Systems. USENIX ATC.
- Chandra, T. D., Griesemer, R., & Redstone, J. (2007). Paxos Made Live - An Engineering Perspective. PODC.
- Herlihy, M. (1991). Wait-Free Synchronization. ACM TOPLAS, 13(1).
Prev: etcd 深度解剖 | Next: 成员协议:SWIM 与 Gossip
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【分布式系统百科】ZooKeeper 内核:从 ZAB 协议到分布式协调实践
深入拆解 ZooKeeper 的核心机制:ZAB 协议的三阶段流程、ZNode 数据模型、Watch 一次性通知、会话管理,以及分布式锁、Leader 选举、配置管理等典型用法。分析惊群效应等已知问题,并梳理 ZooKeeper 在 Kafka、HBase、Hadoop 生态中的角色。
【分布式系统百科】etcd 深度解剖:从 Watch 机制到 MVCC 存储引擎
深入剖析 etcd 的核心机制:持久化 Watch 与 Revision 追溯、Lease 租约机制、基于 BoltDB 的 MVCC 存储引擎、与 Raft 共识的联动方式,以及在 Kubernetes 中的关键角色。涵盖性能调优策略、容量限制与规模化方案。
【分布式系统百科】Raft 深度重写:从论文的 18 页到 etcd 的 15000 行
Raft 论文 18 页就能读完,但 etcd/raft 用了 15000 行 Go 才把它变成能在生产环境跑的代码。这篇文章从论文的每一个核心机制出发,逐一拆解工程实现中论文没说的东西:PreVote、ReadIndex、LeaderTransfer、ConfChange V2、流水线复制、Async Apply,以及 TiKV 的 Multi-Raft 实践。最后做一次精确的 Paxos 对比,并坦诚讨论 Raft 的已知缺陷。
【分布式系统实战】Raft 实现拆解:etcd 的共识算法到底长什么样
Raft 论文 18 页,etcd raft 库 ~15000 行 Go。中间的差距不是代码量,是论文没提的工程 edge case:PreVote、流水线复制、ReadIndex、joint consensus。