分布式锁的实现与陷阱:Redis、etcd 与时钟
刀落樱花
8 min read
判断:分布式锁解决的问题是"多个进程在同一时刻只让一个干活",但它远比单机的互斥锁脆弱。锁服务、时钟、GC、网络任何一个环节出问题,都可能让两把锁同时生效。把锁当成"尽力而为"的协调机制,而非绝对保证。
Redis 实现:SET NX PX
最朴素的写法:
SET lock:order:123 unique_value NX PX 30000
NX 保证只有一个客户端能写入成功,PX 给锁加过期时间,防止持有者崩溃后锁永不释放。释放时必须用 Lua 脚本校验 value 再删,避免误删别人的锁:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
value 必须是全局唯一(如 UUID + 线程 ID),不能用固定字符串——否则客户端 A 的锁过期后,B 拿到锁,A 操作完回来一删,把 B 的锁删了。获取锁失败(返回 nil)要有明确语义:要么立即放弃,要么按业务重试,不要自旋空转烧 CPU。
时钟带来的第一道裂痕
PX 30000 假设 Redis 的时钟和你预期一致。若 Redis 发生时钟回拨(NTP 校正、虚拟机暂停),过期判定会出现偏差。更隐蔽的是客户端侧:假设锁 TTL 30 秒,但一次 GC 停顿了 40 秒,业务还没执行完,锁已被 Redis 过期回收,另一个客户端拿到锁,两份逻辑并行。
解法之一是看门狗(watchdog):持锁客户端起一个定时任务,在锁到期前不断续期(如每 10 秒把 TTL 刷新到 30 秒)。只要客户端还活着且在干活,锁就不会过期。客户端崩溃,续期停止,锁自然释放。看门狗也有边界:若进程没崩溃但陷入死循环不释放锁、也不续期,需要超时兜底;续期任务本身也可能被 GC 停顿影响,所以续期间隔要远小于 TTL。
etcd 的实现:租约
etcd 用 lease(租约)表达"带生命的锁"。加锁等价于创建一个带 TTL 的 key,只要租约被持续刷新,key 就存在;客户端崩溃,租约过期,key 自动消失。相比 Redis 的 PX,etcd 的租约机制把"过期"和"续期"内建得更干净,且 etcd 的一致性(Raft)让"谁持锁"这件事在集群内更可信。
client := clientv3.New(...)
resp, _ := client.Grant(ctx, 30) // 30 秒租约
client.Put(ctx, "lock/order/123", "me", clientv3.WithLease(resp.ID))
// 后台 KeepAlive 续租
keepAlive, _ := client.KeepAlive(ctx, resp.ID)
etcd 还天然支持乐观锁式的 CAS 操作(Compare-And-Swap),在"抢锁 + 写数据"需要原子时比 Redis 多一层保障。但 etcd 的吞吐低于 Redis,高频抢锁场景要评估其压力。
| 维度 |
Redis + PX |
etcd 租约 |
| 一致性模型 |
异步复制(主从) |
Raft 强一致 |
| 续期机制 |
客户端看门狗 |
客户端 KeepAlive |
| 适用吞吐 |
高 |
中 |
| 失效检测 |
依赖 TTL 到期 |
依赖租约到期 |
多节点与 Redlock 的取舍
单 Redis 实例挂了,锁就没了。Redlock 思路是在 N 个独立节点上都去申请锁,多数成功才算拿到。但 Redlock 依赖各节点时钟不出现大跳变,且对"GC/网络延迟导致锁提前失效"无能为力——它解决的是节点可用性,不解决持有者侧的时钟问题。生产里若要用 Redlock,需接受它的前提假设。
fencing token:兜底的防线
无论锁服务多可靠,都无法 100% 排除"锁过期后旧持有者又醒来写数据"。fence token 是给每次加锁递增的令牌,写入共享存储时校验令牌必须单调递增:
加锁 → 拿到 token=5
写数据 → 存储比较 token:若 5 > 已记录的 4,则允许并记下 5;否则拒绝
这样即便旧持有者迟到,它的 token=4 小于当前 5,写入被拒,避免覆盖。这是 Martin Kleppmann 提出的思路,代价是存储侧要配合校验。fence token 不依赖锁服务时钟,只依赖一个单调递增计数器,因此能挡住"锁过期 + 旧进程迟到"这一最经典的竞态,是分布式锁方案里性价比极高的补丁。
选型建议
读多写少、对偶发双持锁容忍度高的场景,Redis 单实例 + 看门狗足够;要求锁状态在集群内强一致、且已有 etcd 基础设施,用 etcd 租约更省心;无法接受任何双持锁且存储侧可控,务必上 fence token。三者不是互斥,常常组合使用。
多实例抢锁的惊群与可观测性
大量客户端同时抢同一把锁,会出现"惊群":锁一释放,几十个客户端同时去 SET NX,只有一人成功,其余扑空。对 Redis 单实例这尚可接受,但高并发下无效竞争会放大响应时间。用红锁或多节点时,惊群还会放大成跨节点请求风暴。缓解手段包括获取失败后带 jitter 的随机退避,以及把"抢锁"收敛成"排队"(如基于发布订阅的通知式唤醒)。
锁本身必须可观测:谁持锁、持了多久、续期是否跟上、是否存在"获取失败却继续执行"的异常路径。缺乏这些指标,双持锁往往是用户先投诉、你后知后觉。建议在持锁路径埋点,对"锁持有时间逼近 TTL"和"续期失败"告警,把隐患暴露在故障前。
一个典型故障推演:客户端 A 拿到 TTL 为 30 秒的锁,执行到第 25 秒时发生一次 20 秒的 GC 停顿,期间看门狗没能续期,Redis 在第 30 秒回收锁;客户端 B 在第 31 秒拿到锁开始干活;第 45 秒 A 从 GC 中恢复,以为锁还在,继续写共享数据——此时 A、B 并发写,fence token 是挡住 A 的唯一手段。这个例子说明:看门狗解决"崩溃释放",解决不了"停顿后误以为仍持锁",必须靠 fence token 兜底。
获取失败路径要显式处理
抢锁失败不是异常,而是正常分支。业务上要定义清楚:拿不到锁时,是直接拒绝用户(如提示"操作进行中")、进入排队、还是走降级路径。把失败分支当成默认成功来写,是最隐蔽的 bug——看起来没加锁,其实根本没拿到锁就执行了。建议对"获取锁失败"打点统计,失败率异常升高往往意味着锁竞争激烈或持锁时间过长,是容量或死锁的前兆。
踩坑清单
锁过期时间拍脑袋:设太短,业务没跑完锁没了;设太长,持有者崩溃后要等很久才释放。用看门狗动态续期比固定长 TTL 稳妥。
在锁内做远程调用或重 IO:持有锁期间发 HTTP、查慢库,会放大锁占用时间,拖垮并发。锁内只放必须互斥的临界区。
误以为锁保证原子性:分布式锁不替代事务。两个进程拿锁的顺序和你想象的不同,临界区外的竞态依然存在,关键写仍需数据库唯一约束或乐观锁兜底。
只在单实例 Redis 上跑生产锁:实例故障即锁失效。要么接受这个概率,要么上 etcd/多节点方案并理解其假设。
释放锁忘了校验 value:固定 value + 直接 DEL,会删掉别人的锁,造成双持锁。务必 Lua 校验。
获取锁失败却继续执行业务:没拿到锁还往下走,等于没加锁。获取失败必须终止或走降级路径。
1 view
0 likes
0 comments