分布式锁不是选 Redis 就完事了

Redis SETNX 分布式锁有三种失败场景:进程崩溃锁卡死、续期失败双持、主从切换锁丢失。本文从症状倒推根因,对比 etcd/ZK 设计,给你一棵三维决策树帮你选型。

封面

本文实验基于 Redis 7.x / etcd 3.5.x / ZooKeeper 3.8.x / Go 1.22+

导读:Redis SETNX 的三个失败场景,一套诊断框架,帮你做选型。

0. 你的锁该做次体检了

凌晨三点,监控告警炸了——同一个订单扣了两次款。

你打开日志,发现两个 Pod 同时拿到了那把"分布式锁"。这画面让人脑壳疼:明明加了 Redis 锁,为什么还出问题?

凌晨告警

我先抛一个反直觉的判断:你以为加了锁就安全?这是把分布式当单机来想了。Redis SETNX 不是"加了就保你"的护身符。它更像一个温度计,能给你显示一个数,但显示出来不等于事就结了。

这篇文章不打算给你"Redis vs etcd vs ZK 选哪个最好"的标准答案。我想做的是带你做一次体检——把 Redis 锁在三种场景下会怎么"病"先摆出来。

再分析这三种"病"的共因,然后看 etcd 和 ZK 是怎么针对这些根因设计的,最后给你一棵决策树。结尾我不会替你下处方,只把判断条件交到你手上。

你需要的前置只有:知道 SETNX 是怎么用的,用过 Redis 主从(或 Sentinel),听说过 etcd 和 ZK。


1. 症状清单:三种你可能没意识到的失败场景

我把三种场景压在一章里,因为它们不是"独立故障"——后面你会看到,它们其实有共同的根因。

每个场景我都用同样的四步呈现:现象 → 复现代码 → 时序 → 为什么。代码全部能在本机跑,evidence 目录里有完整可执行版本(go run ./01-process-crash 那种)。

1.1 症状一:进程崩溃,锁卡死直到 TTL

最常见的场景。客户端 A 拿到了锁,但执行业务时进程被 kill -9——可能 OOM,可能滚动更新强杀。它来不及调 DEL 释放锁。这把锁会卡在 Redis 里,直到 TTL 自己过期。

我跑了个小实验。A SETNX 拿锁 TTL=10s 后什么都不做(模拟崩溃);B 不停尝试 SETNX,每 200ms 一次。

[T+ 0.00s] client_A SETNX → true   (TTL=10s)
[T+ 0.00s] client_A 模拟 kill -9,不调用 DEL/UNLINK
[T+ 0.00s] client_B 开始尝试 SETNX,每 200ms 一次
[T+ 10.09s] client_B SETNX → true  ✅ 拿到锁(第 51 次尝试)

B 等了 10.09 秒,尝试了 51 次。

这不是 Redis 的 bug,是设计选择。Redis 默认配置下没有"客户端心跳"机制,它没法知道你是真在干活还是已经死了,只能让 TTL 来兜底。

等满 TTL

你能做的是把 TTL 调短。但这又会带来另一个问题:如果业务跑得比 TTL 长,锁还没用完就过期了,让别人误抢。这就引出第二个症状。

而且 TTL 这个值你没法做"绝对正确"。设 1 秒,业务偶尔 1.2 秒就翻车;设 30 秒,崩溃恢复就要等半分钟。你的实际选择是在"误抢概率"和"恢复延迟"之间画一条线,没有银弹。

1.2 症状二:续期失败,醒来已不是自己的锁

业务比 TTL 长怎么办?工业界标准做法是 watchdog 续期(如 Java 的 Redisson 看门狗):后台线程每隔 N 秒续期一次锁,让锁在业务完成前不会过期。下面用 Go goroutine 模拟同样逻辑。

听起来挺好。但 watchdog 跟业务在同一个进程里。

我做了个 STW 模拟。A SETNX TTL=3s,watchdog 间隔 1s。业务跑 2 秒后,整个进程被"冻结"5 秒,GC pause、虚拟机迁移、调度延迟都是常见的原因。watchdog 一起停了。

[T+ 0.00s][A] SETNX client_A → true (TTL=3s)
[T+ 1.00s][A.watchdog] EXPIRE → 续期成功?true
[T+ 2.00s][A] ⚠️ 模拟 STW 5s 开始(业务 + watchdog 都被冻结)
[T+ 5.04s][B] SETNX client_B → true  ✅ 抢到锁
[T+ 7.00s][A] STW 结束,业务继续
[T+ 7.00s][A] GET 锁 owner = "client_B"
[T+ 7.00s][A] ⚠️ 锁已易主——但仅在主动 GET 时才察觉

T+5.04s B 抢到了锁。T+7s A 苏醒。如果业务代码没做 owner 校验就直接 DEL+扣库存,一次双持事故就这样发生了。

双持时序

这里的关键不是"watchdog 没用",在没有长时间进程冻结的常规场景下,watchdog 确实能兜住。真正没解的是跨 TTL 的 STW。如果你业务最长会卡多久心里没数,TTL 选多大都是赌博。

工程上能做的兜底是:每次写之前 GET owner 字段,确认还是自己再继续;用 Lua 脚本做 GET+DEL 原子比较。但这只是把竞态推后,没消灭。A 真的发出 GET 之前,那一小段窗口里还是可能扣到 B 的库存上。

我在线上见过的处理是这样:把锁拿来当意图协调而不是互斥保护——锁只决定"谁来发起这次扣减",真正防扣超的是数据库的乐观锁(version 字段或 SQL 条件更新)。Redis 锁丢一次,最坏是两个 worker 同时尝试,但数据库会让其中一个失败。这种"锁 + 强幂等"的组合,是大部分用 Redis 锁的业务实际在用的兜底。

1.3 症状三:主从切换的瞬间,锁就消失了

前两个场景至少 Redis 自己没"撒谎"。第三个场景更刺眼:Redis 的复制是异步的。主库写入成功后,可能还没同步到从库,主库就挂了。

我在本机用 Docker 起了一对主从,跑了一个最坏时序:

[T+ 0.00s] === E3 主从切换丢锁实验开始 ===
[T+ 0.01s] ✅ 主从关系建立:master=127.0.0.1:6379, replica=127.0.0.1:6380
[T+ 0.01s] 🔌 replica 提前断开复制(模拟最坏复制延迟)
[T+ 0.01s][A] master.SETNX → true (TTL=30s)
[T+ 0.95s] 💥 旧 master 已关闭
[T+ 1.16s] ⚠️  新 master 上 GET order:1003:lock → 不存在(锁丢了!)
[T+ 1.16s][B] new_master.SETNX → true

A 在 master 上 SETNX 成功。在复制还没到达 replica 时,master 挂了。哨兵把 replica 提升为新 master。客户端 B 连上新 master,对同一个 key 做 SETNX,也成功了

这一刻,同一把锁被两个客户端持有。

主从切换丢锁

为了让本机能稳定复现这个时序,实验里我让 replica 提前断开复制。真实事故里这个延迟来自网络抖动、replica 还在做 RDB 全量同步、跨机房链路阻塞。延迟具体是 100 毫秒还是 5 秒不重要,重要的是它存在。

Redis 异步复制优先保证低延迟和高可用,以写入一致性为代价。如果你在 redis.conf 里配 min-replicas-to-write 1,写入会要求至少 1 个 replica 满足滞后条件才接受。这能缩小丢锁的窗口(Redis 文档的原话是"best effort data safety mechanism"),但不能彻底消除——复制仍是异步的。代价是可用性下降(replica 不可达时拒绝写入),窗口缩小了,没消除。

1.4 三个症状到这里就够了

读到这你会发现一件事:这三个场景都不是 Redis 实现得不好,它们是 SETNX 这个抽象本身的边界

根因在更深一层。


2. 病理分析:三种症状的共同根因

把三个症状摆开看,它们看起来很不一样:一个是进程死了、一个是进程没死但卡了、一个是 Redis 自己换了主。但我发现,它们在更深一层是同一类问题

我归出三个根因。

2.1 根因一:异步是性能的代价,也是一致性的漏洞

Redis 主从是异步复制;watchdog 续期是异步心跳;客户端连接 Redis 也是异步往返。异步说白了就是"我假设另一边在线,但我不真知道"。你写入主库假设它会同步到 replica,但你不会等同步完成;watchdog 假设业务还在跑,但 STW 期间它什么都不知道。

E3 是异步复制踢到的雷。E2 是异步心跳踢到的雷。E1 也算——客户端断了,server 端没 hook 去知道。

你想消灭异步?可以,上 raft,写入要求过半节点 ack 才返回。代价是 QPS 下降,因为每次写都要走一轮共识。

2.2 根因二:时钟假设比你想象的更脆

Redis 锁的 TTL 是基于 server 端时钟。Redlock 算法依赖五个 Redis 节点的时钟"基本一致"。Kleppmann 那篇著名的文章里反复强调,分布式系统中时钟不可靠。NTP 调整、虚拟机时钟漂移、容器宿主机迁移,都会让"server 端的 1 秒"和"另一个 server 端的 1 秒"不再相等。

E2 的本质是进程暂停(process pause):客户端被冻结,无法执行任何代码(包括续期),而 Redis 服务端时钟正常走,TTL 照常过期。这不是时钟漂移,是进程对时间流逝完全无感知。时钟假设问题更多暴露在 Redlock 场景——多个 Redis 节点间的时钟漂移会直接破坏"多数派有效时间计算"。

时钟假设藏在分布式锁的每一寸里:TTL、心跳、过期判定、共识 leader election 都依赖时钟。差别只是有些方案让时钟假设暴露在皮肤上,有些把它藏在皮下

2.3 根因三:进程状态对 server 是黑盒

Redis 不知道你的进程是不是还在跑。它只能看 TCP 连接是不是断了,但 TCP 连接断的判定依赖 keepalive 或主动 RST,可能要等几十秒。

E1 里 A 进程被 kill -9 之后,TCP 连接"看起来还在",Redis 没法及时知道,要等 TTL 自己过期。

这是为什么 etcd 和 ZK 走了完全不同的路。它们让 server 端主动维护"客户端是不是活着"的状态,用 lease(etcd)或 session(ZK)。客户端定期发心跳,断了 server 主动清理。这把"我假设你在线"换成了"我看着你呢"

2.4 看出来了吗

E1/E2/E3 不是 Redis 的"三个 bug",是 SETNX 这个 API 的三个体质风险。要选别的方案,关键在于它们针对这三个根因做了不同的设计

根因映射


3. 处方对照:etcd 与 ZK 是怎么针对根因设计的

我不打算讲"etcd 是什么、ZK 是什么",这两个概念的入门资料满地都是。我想讲的是:它们针对症状一、二、三分别用了什么机制

3.1 etcd:lease + Txn(CAS) + Watch

这三件套各自对应一个根因。

Lease 解决"进程状态对 server 是黑盒"。客户端 Grant 一个 lease(带 TTL),KeepAlive 定期续期。客户端断了,server 自己 revoke。我跑了个 demo:

[T+ 1.01s] === 场景 2:B 获锁后崩溃 → server 自动 revoke ===
[T+ 1.01s][B] Grant lease (id=18f9e7d22bbd80b, ttl=3s)
[T+ 1.01s][B] Txn(CAS) Put → succeeded=true(不调 KeepAlive,模拟崩溃)
[T+ 4.50s][C] Watch 到 key 删除,立即 Txn(CAS) → 拿到锁,等待 3.489s

B 不调 KeepAlive 模拟崩溃,3.489 秒(接近 TTL=3s,多出的约 0.5s 来自 lease 过期检查间隔 + raft 提交延迟)后 server 自动 revoke 了 lease,绑定的 key 自动消失。这里和 Redis 的关键差别:Redis 是"key 自己 TTL 过期",etcd 是 lease 到期后 revoke 走 raft 共识——集群一致删除,不会因主从切换丢失过期判定。

原子互斥靠 Txn(CAS):If(CreateRevision == 0).Then(Put)——只在 key 不存在时写入。这跟 SETNX 表面上是一回事,但底层不同:etcd 的 Txn 走 raft,写入需要多数派节点 ack 才算成功。

剩下的问题是抢占公平——etcd concurrency 包的实现是:所有候选者在同一 prefix 下创建带 lease 的 key,按 CreateRevision 排序,最小者持锁;其余 watch 自己的前驱 key 删除事件——与 ZK watch 前驱等价,无惊群。

需要明确:etcd 的 lease KeepAlive 同样跑在客户端进程里——STW 时它一样停,lease 照样会被 server revoke。etcd 对 E2 的防御不是"永远不丢锁",而是 revision 做 fencing:即使锁被 revoke、新客户端拿到锁,旧客户端苏醒后的写入会被存储层拒绝(revision 已过期)。

对应根因二(异步/复制):raft 是多数派共识,写入过半才返回。没有"复制还没到达 replica,master 就挂了"这个窗口——挂了的 master 它的写入也没"成功",新 leader 不会有这条幻数据。E3 在 etcd 里不存在。

etcd 机制

3.2 ZK:临时顺序节点 + Watch 前驱

ZK 的设计哲学跟 etcd 类似,但有两个有趣的差别。

EPHEMERAL 节点 = etcd 的 lease。Session 断了,节点自动删,锁自动释放。

SEQUENTIAL(顺序)+ Watch 前驱 = 公平 + 抗惊群。所有候选者在同一路径下创建顺序节点,ZK 给每个附加单调递增序号;序号最小的是持锁者,其他人 Watch 自己前一个节点的删除事件。这有两个好处:

  • 公平:按到达顺序排队,不是先轮询到的赢
  • 无惊群:只 watch 前驱,持锁者释放只唤醒一个后继

etcd concurrency 包已内置类似 ZK 的 watch 前驱机制(按 CreateRevision 排序),两者在公平性和抗惊群上实质等价。ZK 的独特优势是 SEQUENTIAL 序号天然可作 fencing token(单调递增,无需额外映射)。

我跑了个三 client demo:

[T+ 0.04s][C1] 创建顺序节点 lock-0000000000
[T+ 0.04s][C1] 🟢 我是序号最小(lock-0000000000)→ 持锁
[T+ 0.23s][C2] 创建顺序节点 lock-0000000001
[T+ 0.23s][C2] Watch 前驱节点 lock-0000000000
[T+ 0.43s][C3] 创建顺序节点 lock-0000000002
[T+ 0.43s][C3] Watch 前驱节点 lock-0000000001
[T+ 0.74s][C2] 收到前驱 lock-0000000000 删除事件,重新检查序列
[T+ 0.74s][C1] 🔓 已释放锁(删除 lock-0000000000)
[T+ 0.74s][C2] 🟢 我是序号最小(lock-0000000001)→ 持锁
[T+ 1.65s][C3] 收到前驱 lock-0000000001 删除事件
[T+ 1.65s][C3] 🟢 我是序号最小(lock-0000000002)→ 持锁

三个客户端按顺序拿锁。任何一个中途 session 断,ZK 删它的 ephemeral 节点,后继自动顶上。

SEQUENTIAL 序号本身就是 fencing token:单调递增,存储层可以拒绝旧 token。这是 Kleppmann 在那篇文章里推崇 ZK 的关键原因——fencing 不是另外加的一层,是协议自带的。

ZK 链路

3.3 这两个方案不是没有代价

维度 etcd ZK
写入 QPS(本机串行) 896(实测) 未实测(参考:ZAB 共识 + JVM 开销,预期略低于 etcd)
客户端语言生态 Go 原生,gRPC 多语言 Java(Curator)成熟,其他语言一般
运维心智 raft 集群 + compact + quota JVM 调优 + observer + ZAB
Fencing token revision 号 sequential 序号

两者都用强一致协议(raft/ZAB),都用 server 端心跳替代客户端 TTL,都内置抢占公平和 fencing。但都比 Redis 慢:本机 benchmark 测下来 etcd 比 Redis 慢 2.21 倍(单 client 串行 round-trip:Redis 1978 QPS,etcd 896 QPS)。

慢这件事不能怪 etcd 写得差,是共识协议本身有代价:每次写要等多数派节点 ack。这是它能消除症状三的代价。


4. 处方判定:QPS × 一致性 × 运维成本三维决策树

到这里,三种方案的能力边界你应该清楚了。能力边界不等于决策。你的业务是什么决定了你该选谁。

我把三个维度做成决策树,每条分叉对应一个真实约束。

4.1 三维度的含义

QPS:单位时间获锁/释放的次数。Redis 单实例本机实测 1978,etcd 896,比例 2.21x;并发吞吐差距更大(etcd 写入要走 raft 共识)。如果你的锁场景是"秒杀峰值 5000 QPS",三方案都能接住;如果是"每秒 10 万次去重",只有 Redis 撑得住。

一致性:失败一次的代价。“扣库存扣超"和 “缓存重建多算一次” 完全不是一个量级——前者三天事故复盘,后者用户都没察觉。

运维成本:你团队有没有 Java 同学维护 ZK?你有没有独立部署的 etcd 集群?时钟同步、跨机房链路,这些隐形成本不能忽略。

4.2 决策树

你需要分布式锁的目标是什么?

A. 互斥即可(去重/限流/防重复点击) → Redis SETNX(主从 + watchdog),业务侧做幂等兜底

B. 必须保证不重复(金融/库存/订单) → B1. QPS < 5k

  • 团队有 etcd 运维经验 + 独立部署的 etcd 集群 → etcd
  • 否则 → ZK(如果有 Java 团队)/ etcd(独立部署)

B2. QPS > 5k

  • 业务能拿 fencing token 在写入侧校验 → Redis SETNX + 业务 token
  • 否则 → 重新设计(这种 QPS 下分布式锁未必是答案,考虑分片 / 无锁队列 / 乐观并发)

⚠️ k8s 控制面的 etcd 不适合直接承载业务锁——资源竞争会影响集群稳定性,且权限隔离不足。已有 etcd 指独立部署的集群。

决策树

4.3 一个 worked example:电商超卖

业务:库存扣减 QPS:日常 500,秒杀峰值 5000 一致性要求:库存不能扣超 已有基础设施:独立 etcd 集群(团队有运维经验)+ Redis 主从

走决策树:

  1. 必须保证不重复 → 右分支
  2. QPS < 5k(峰值刚好踩线)→ 左分支
  3. 团队有 etcd 运维经验 + 独立集群 → 选 etcd

可能的反对意见:

  • “Redis + 业务幂等更轻”——可以。但业务幂等需要订单号或自然键去重,且要承认 E1/E2/E3 三场景的概率
  • “etcd 只能 ~10k QPS”——确实有上限,但 5k 还在舒适区

4.4 反例:什么时候选 Redis 也是对的

场景 为什么 Redis 够
限流(每秒 N 次) 不需要严格互斥,丢一两次锁可以接受
缓存重建去重 多个 worker 重建结果一致,丢锁最多多重建一次
后台批处理调度 业务侧本身有任务 ID 去重
防止重复点击 短期 TTL,宁可松一点也别拖延用户响应

核心判断:失败一次的代价 vs 严格一致性的运维成本。失败的代价小,Redis 性价比最高。

决策矩阵


5. 医患交底:Redlock 论战与"我不替你做选择”

不写完 Redlock 论战,这篇文章就缺了一块。但我不打算下结论。

Redlock 的核心思路:部署 N 个独立 Redis 节点(无主从),客户端依次向每个节点 SETNX,超过半数成功且总耗时 < TTL 剩余时间,则认为获锁成功。antirez 设计它是为了绕过单点 Redis 的主从复制问题——用多数派代替主从。

Kleppmann 在 2016 年那篇 How to do distributed locking 里说:如果你需要正确性,不要用 Redlock;如果你只要效率锁,单节点 Redis 就够。他主张用 fencing token——单调递增 token 配合存储层拒绝旧 token,这才是真正的安全。

antirez(Redis 作者)的反驳是:时钟跳变在实践中远小于锁 TTL,工程上够用;fencing token 的论证更像学术分析而非工程现实——如果你已经有 fencing token 能用,那本身就不需要分布式锁了。

我读过双方的原文,老实说没法当裁判。如果非要站队——我更认同 Kleppmann 的安全性分析框架,前四章的实验已经说明了原因。但 antirez 关于工程约束的反驳是真实的:不是每个团队都有能力落地 fencing token。两边的论据都成立:Kleppmann 在分析理论安全性的边界,antirez 在守护工程务实的可行性。截至 2026 年,双方核心分歧仍未解决。

我能给你的是判断条件——

  • 如果你的业务"丢一次锁会引来三天的事故复盘",按 Kleppmann 的判断走——选 etcd 或 ZK,要 fencing
  • 如果你的业务"丢一次锁顶多多发一次通知",按 antirez 的判断走——单 Redis 或 Redlock 都行
  • 如果你说不清自己在哪一档,先去想清楚业务,再回来选锁

回到开头那个凌晨三点的告警。你的锁不是"要不要选最强的",是"要不要先体检"。看清楚自己的业务能容忍什么,再决定开什么药。

我不替你做这个选择。

先看清你的业务,再选你的锁


附录:实验代码和原始数据

本文 6 组实验的代码和运行日志已开源:

GitHub:zhiyulab-evidence/distributed-lock-selection

子目录 内容 对应正文
01-process-crash/ 进程崩溃后锁卡死直到 TTL 实验 §1.1 症状一
02-renewal-fail/ STW 续期失败双持实验 §1.2 症状二
03-master-failover/ Redis 主从切换丢锁实验(docker-compose) §1.3 症状三
04-etcd-lock/ etcd lease+Txn(CAS) 锁实验 §3.1 etcd
05-zk-lock/ ZK 临时顺序节点锁实验 §3.2 ZK

每个子目录都有独立 README,说明如何复现。二进制编译产物不入库,跑实验前自己 go build

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →