48.3 分布式锁
一句话
分布式锁用共享存储的原子操作保证同一时刻只有一个持有者;但「加锁成功」不等于「一直独占」——GC 停顿、网络分区、主从切换都可能让两个客户端同时持锁,正确性最终靠租约 + fencing token,而不是靠锁本身。
三个性质:互斥只是第一个
| 性质 | 含义 | 不满足的后果 |
|---|---|---|
| 互斥(safety) | 任意时刻最多一个持有者 | 双写、超卖、重复扣款 |
| 无死锁(liveness) | 持有者崩溃后锁能释放 | 资源永久锁死 |
| 正确性(fencing) | 过期持有者的写不能生效 | 「互斥」被打破后仍然造成资损 |
多数实现能保证前两个,第三个才是生产事故的分水岭。可重入、公平是加分项,不是正确性前提。
实现方式对照
| 实现 | 机制 | 互斥边界 | 同区延迟 | 主要风险 |
|---|---|---|---|---|
Redis SET NX PX | 单 key 原子写 + TTL | 单实例 | 0.2–1ms | 主从异步复制,切主丢锁 |
| Redlock | N 个独立 master,多数派加锁 | 多实例 | 0.5–2ms | 依赖时钟/暂停有界;无 fencing |
| ZooKeeper | 临时顺序节点 + watch 前驱 | 会话 | 1–5ms | 会话超时;GC 停顿丢节点 |
| etcd | lease + txn + revision | 租约 | 1–5ms | 租约续期失败;CP 不可用 |
| 数据库 | 行锁 / 唯一索引 / 事务 | 事务/连接 | 0.5–5ms | 长事务阻塞;连接断开回滚 |
| 文件锁 | flock/fcntl | 单机(NFS 不可靠) | µs | 跨机无效;容器内易误用 |
🔑 锁的「强度」不由实现方式决定,而由故障模型决定:单实例 Redis 在进程崩溃时没问题,在主从切换时会双持有;ZK/etcd 在分区时不会双持有,但会不可用。选型先问「分区时我要安全还是可用」。
双持有是怎么发生的
t=0 A 拿到锁,token=1,TTL=5s
t=2 A 进入 GC / STW,暂停 10s
t=5 租约过期(锁服务认为 A 已死)
t=5.1 B 拿到锁,token=2
t=6 B 写入,storage.lastToken = 2
t=12 A 恢复,继续执行,带着 token=1 写入
无 fencing:A 覆盖 B(或重复扣款)
有 fencing:storage 拒绝 token=1 < 2
关键点:A 在暂停期间什么都没做错——它没有收到任何通知,恢复后仍以为自己持有锁。锁服务也没有错——租约到期就该释放。错的是「把锁的持有当作永久的授权」。
算一笔账:TTL 与暂停分布
模拟:持锁后工作 2s,中途一次暂停;99% 的暂停 ≤200ms,1% 的暂停 1–30s(长 STW、Full GC、VM 迁移、网络分区)。B 在租约到期后拿锁,1s 后写入;A 恢复后完成并写入。
package main
import (
"fmt"
"math/rand"
)
// 每次加锁后:A 工作 2s,中途一次暂停(GC / 网络 / STW)
// 99% 的暂停 ≤ 200ms;1% 的暂停 1–30s(长 STW、Full GC、VM 迁移、网络分区)
func samplePause(r *rand.Rand) float64 {
if r.Float64() < 0.99 {
return r.Float64() * 0.2
}
return 1 + r.Float64()*29
}
func run(ttl float64, trials int) (expired, double int) {
r := rand.New(rand.NewSource(42))
for i := 0; i < trials; i++ {
pauseStart := r.Float64() * 2.0 // 工作 2s 内随机时刻暂停
pause := samplePause(r)
finish := 2.0 + pause // 暂停期间工作不推进
if finish > ttl {
expired++
}
// B 在 ttl 时刻拿到锁,1s 后写入;A 恢复后完成并写入
if pauseStart+pause > ttl && finish > ttl+1.0 {
double++
}
}
return
}
func main() {
const trials = 200000
fmt.Printf("%-5s %-12s %-16s %-12s\n", "TTL", "租约过期", "双持有(无 fencing)", "fencing 拦截")
for _, ttl := range []float64{5, 10, 30} {
e, d := run(ttl, trials)
fmt.Printf("%-5.0f %-12s %-16s %-12s\n", ttl,
fmt.Sprintf("%.2f%%", float64(e)*100/trials),
fmt.Sprintf("%.2f%%", float64(d)*100/trials),
"100%")
}
}
输出:
TTL 租约过期 双持有(无 fencing) fencing 拦截
5 0.92% 0.88% 100%
10 0.76% 0.71% 100%
30 0.07% 0.03% 100%
读法:
- 即使 TTL 30s,仍有 0.03% 的双持有——因为那 1% 的暂停里存在 1–30s 的长暂停;只要暂停可能超过租约,双持有就存在。
- TTL 从 5s 加到 30s,双持有从 0.88% 降到 0.03%,但故障恢复也变慢:持有者崩溃后要等 30s 锁才释放。
- fencing 把「静默双写」变成「显式失败」:A 的写被拒绝,A 必须重试/回滚。这比覆盖 B 的数据安全得多。
🔑 不要问「TTL 设多少才不会有双持有」,要问「双持有发生时,下游能不能识别并拒绝旧持有者」。前者做不到 100%,后者可以。
Fencing token:把「锁」变成「单调授权」
锁服务在每次成功加锁时发放一个单调递增的 token(不是客户端自己生成的 UUID):
storage 侧(与业务写入同一事务/原子操作):
if req.token <= storage.lastToken { reject }
storage.lastToken = req.token
apply write
正确性依赖四件事:
| 条件 | 不满足会怎样 |
|---|---|
| token 由锁服务生成且单调 | 客户端伪造/重复 token,fencing 失效 |
| token 在锁服务持久化(或按共识递增) | 锁服务重启后 token 回退 |
| 所有写路径都校验 token | 只要有一条旁路,旧持有者仍能写 |
| 校验与写入原子 | 校验通过后、写入前被抢占,仍会覆盖 |
第 3 条最容易被忽略:数据库连接、缓存、消息队列、对象存储——每一条能改数据的路都要带 token 校验,否则 fencing 只是「主路径上的装饰」。
token 的来源:Chubby 的 sequencer、ZooKeeper 的 zxid、etcd 的 revision 都是单调值,可以作为 fence;Redis 的 INCR 也行,但前提是锁服务本身不丢计数(单实例宕机/切主会回退)。
Redis 锁:三个必须知道的坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 解锁不校验 owner | A 超时后,B 拿到锁,A 的 DEL 把 B 的锁删了 | Lua:if GET(key)==token then DEL(key) |
| 主从异步复制 | 锁写到 master 但未同步,master 挂,新 master 上没锁,B 又能加锁 | 用 fencing,或 Redlock/多副本,或换 CP 系统 |
| 续期看门狗 | 续期线程被 GC 暂停,租约仍会过期;续期成功也可能在「过期后」成功 | 续期失败要主动放弃执行;token 仍要校验 |
Redlock 的争论,一句话结论
Redlock 在 N 个独立 Redis master 上取多数派,租约有效性按 TTL − 已耗时 − 时钟漂移 计算。Kleppmann 的批评指出它依赖「暂停有界、时钟不跳、网络延迟有界」,而这些在真实环境不成立,且没有 fencing;antirez 的回应强调它是工程折中。
工程结论:Redlock 适合「效率锁」(避免重复跑批、减少重复计算),不适合「正确性锁」(资金、库存、唯一性)。正确性场景用 fencing token 或 CP 系统;如果只是「尽量别重复」,单实例 Redis + TTL 就够,不必上 Redlock。
把三条坑落成命令(Redis 8.10 实测)
# 1) 加锁:NX 保证互斥,PX 保证不会永久死锁;token 必须唯一
redis-cli SET lock:order:42 <token> NX PX 30000 # OK / nil
# 2) 解锁:GET+DEL 不是原子,必须用 Lua 比对 token
redis-cli EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:order:42 <token>
# 3) 续约:只续自己还没过期的锁
redis-cli EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('pexpire',KEYS[1],ARGV[2]) else return 0 end" 1 lock:order:42 <token> 30000
实测结果:A 加锁 OK;B 用不同 token 加锁返回 nil;B 去解锁返回 0(没删掉 A 的锁);A 自己解锁返回 1。
| 配套 | 做法 | 不做会怎样 |
|---|---|---|
| token 唯一 | UUID / 请求 id 写进 value | A 超时后删掉 B 的锁 |
| 续约 watchdog | 业务未结束时周期 PEXPIRE,周期 < TTL/3 | 业务还在跑,锁已经过期 |
| fencing token | 每次成功加锁递增(DB 序列 / INCR),下游校验单调性 | 旧持有者暂停恢复后仍能写 |
| 故障切换 | 关键路径换 etcd/ZK 或 DB 条件更新 | 主从异步复制丢锁 → 双持有 |
把上面四条变成可回归的脚本:
T1=$(uuidgen); T2=$(uuidgen)
test "$(redis-cli SET lock:x $T1 NX PX 5000)" = "OK"
test -z "$(redis-cli SET lock:x $T2 NX PX 5000)" # 第二个必须失败
test "$(redis-cli EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:x $T2)" = "0"
test "$(redis-cli EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:x $T1)" = "1"
这套脚本只证明“单实例 + 单 key”的互斥;它不证明主从切换后不双持有——那要靠 fencing token 或 CP 存储,脚本测不出来。
ZooKeeper / etcd:CP 的代价与同样的坑
| 维度 | ZooKeeper | etcd |
|---|---|---|
| 加锁 | 创建临时顺序节点,watch 前一个节点 | lease + txn 创建,watch key |
| 释放 | 会话结束/节点删除 | lease 过期/主动 revoke |
| 公平 | 是(顺序节点) | 基本是(revision 顺序) |
| 分区 | 少数派不可用 | 少数派不可用 |
| 坑 | GC 停顿超过会话超时 → 节点被删,别人拿锁,自己恢复后仍以为持有 | keepalive 失败 → lease 过期;客户端暂停同理 |
⚠️ ZK/etcd 也不是「天然安全」:它们保证锁服务自身不双主,但保证不了「客户端暂停后仍以为持锁」。会话超时/租约过期就是双持有窗口,fencing 仍然必要。
ZK 的羊群问题:所有等待者都 watch 同一个锁节点,释放时全部被唤醒。正确做法是每个等待者只 watch 自己的前驱节点,释放时只唤醒下一个。
数据库锁:事务边界就是锁边界
| 方式 | 机制 | 适用 | 注意 |
|---|---|---|---|
SELECT ... FOR UPDATE | 行锁,事务结束释放 | 短事务内的互斥 | 长事务阻塞;死锁检测;连接断开回滚 |
| 唯一索引 | 插入唯一键成功=拿到锁 | 幂等/防重 | 崩溃后残留行,需要 TTL/清理 |
| 乐观锁 | version CAS | 冲突少的更新 | 冲突多时重试放大 |
| 咨询锁 | pg_advisory_lock | 会话级互斥 | 会话结束才释放;连接池要小心 |
数据库锁的「TTL」是事务/连接的生命周期:连接池里的连接如果一直不结束事务,锁会一直持有;反过来,连接被中间件杀掉,事务回滚,锁突然释放——客户端可能还在执行。数据库锁同样需要 fencing 或「锁内操作可重入/幂等」。
什么时候不该用分布式锁
| 场景 | 更好的做法 | 原因 |
|---|---|---|
| 防重复提交 | 幂等键/唯一索引(25.4 幂等设计与去重) | 锁失败会重试,幂等天然可重试 |
| 库存扣减 | 条件更新 UPDATE ... WHERE stock >= n / CAS | 一条语句原子完成,不用锁 |
| 同 key 串行 | 分区队列/单写者 actor | 队列天然有序,避免锁的获取/续期开销 |
| 只跑一次的任务 | 调度器单例 + 幂等消费 | 锁过期/重试会重复执行 |
| 选主 | 共识租约(21.5 Raft:选主) | 选主需要任期/租约语义,不是普通锁 |
| 配置/元数据更新 | 版本号 CAS / 乐观并发(见 25.1 乐观并发与悲观并发) | 冲突少,重试成本低 |
🔑 锁是「协调」的最后手段。能用唯一约束、条件更新、幂等、单写者解决的问题,不要引入一个需要高可用、需要续期、还会过期的外部依赖。
使用规范
| 规范 | 理由 |
|---|---|
| 临界区尽量短,不在锁内做 RPC/长事务 | 持有时间不可控,TTL 与续期都难设 |
| TTL ≥ 3–5 × p99 持有时间 + 最大暂停预算 | 太少会误过期,太多会拖慢故障恢复 |
| 续期失败立即停止业务写 | 续期失败说明可能已失去锁 |
| 获取锁设超时 + 退避重试 | 无限等待会拖垮线程池 |
| 多把锁按固定顺序获取 | 避免死锁(TTL 只能解死锁,不能防死锁) |
| 锁服务不可用时 fail closed | 正确性场景宁可拒绝,不可放行 |
| 监控持有时间 P99、续期失败、fencing 拒绝数 | 这些指标比「加锁成功率」更早暴露问题 |
性能量级
| 实现 | 同区加锁 | 跨地域 | 公平性 | 故障时 |
|---|---|---|---|---|
| Redis 单实例 | 0.2–1ms | +1 RTT | 无 | 切主可能丢锁 |
| Redlock(5 节点) | 0.5–2ms | +1 RTT(多数派) | 无 | 复杂,仍有假设 |
| ZooKeeper | 1–5ms | +1 RTT | 有 | 少数派不可用 |
| etcd | 1–5ms | +1 RTT | 基本有 | 少数派不可用 |
| 数据库行锁 | 0.5–5ms | +1 RTT | 无 | 随事务/连接 |
本地 sync.Mutex | 20ns 级 | 不适用 | 无 | 仅进程内 |
跨地域锁的延迟下界就是 1 个 RTT(北京–上海 30ms、中美 180ms)。不要在每个请求上跨地域加锁:要么把锁粒度改成「按用户/分片本地锁」,要么用队列/单写者把协调收到一个地域。
反模式
| 反模式 | 后果 |
|---|---|
| 把锁当「永久授权」,不做 fencing | 双持有后静默覆盖数据 |
| 用客户端本地时间判断租约是否过期 | 时钟漂移导致误判 |
| 解锁不校验 owner | 删掉别人的锁 |
| 锁内做长 RPC / 大事务 | 租约过期,别人拿锁,双写 |
| TTL 设得比业务最大耗时还短 | 正常业务也会双持有 |
| TTL 设得极长 | 持有者崩溃后锁长期不释放 |
| 锁服务不可用时 fail open | 分区期间所有请求都放行,互斥失效 |
| 所有等待者 watch 同一个节点 | 释放瞬间惊群,注册中心被打满 |
| 多把锁无顺序获取 | 死锁;靠 TTL 解开时业务已失败 |
| 用 Redis 单实例锁保护资金 | 主从切换丢锁,资损 |
自检
Q1 SET key value NX PX 5000 已经原子了,为什么还不能保证互斥?
▸ 答案:它只保证「加锁那一刻」原子。加锁成功后,持有者可能 GC 暂停超过 5s,锁自动过期,另一个客户端拿到锁;原持有者恢复后仍以为自己持锁,于是两个客户端同时执行。另外,Redis 主从异步复制时,master 上的锁可能还没同步就切主,新 master 会再次发锁。原子加锁 ≠ 持续互斥。
Q2 fencing token 怎么工作?为什么「只在锁服务里校验」不够?
▸ 答案:锁服务每次发锁递增一个 token;客户端带着 token 去写数据;存储端原子地检查 token > lastToken,小于等于就拒绝。只在锁服务里校验不够,因为真正被修改的是业务存储——旧持有者绕过锁服务直接写数据库/缓存/对象存储时,锁服务拦不住。所有能改数据的路径都必须校验 token,否则 fencing 形同虚设。
Q3 ZooKeeper 的临时节点锁,在客户端 GC 停顿时会发生什么?
▸ 答案:GC 停顿期间客户端无法给 ZK 发心跳,会话超时后临时节点被删除,锁被释放;另一个客户端创建节点拿到锁并开始写。原客户端恢复后,本地仍认为持有锁(它没有收到通知),继续写——双持有。ZK 保证的是「锁服务不双主」,不是「客户端暂停后仍独占」;所以 ZK 锁同样需要 fencing,或者在每次写前检查节点是否还存在(但检查与写之间仍有 TOCTOU)。
Q4 业务 p99 耗时 2s,锁 TTL 设 30s,安全吗?
▸ 答案:不一定。p99 只说明 99% 的请求在 2s 内完成,剩下 1% 可能远超 30s;更关键的是暂停:GC/STW、网络分区、VM 迁移可能让持有者在 30s 内完全没有推进。安全评估要看「最大暂停预算 + 最大执行时间」,而不是 p99;TTL 应大于两者之和,并配合续期与 fencing。如果无法给出暂停上界,就必须假设双持有会发生。
Q5 什么情况下不该用分布式锁?
▸ 答案:能用幂等键/唯一索引防重、能用条件更新(WHERE stock >= n)做原子扣减、能用分区队列/单写者串行化、能用版本号 CAS 解决冲突时,都不该引入分布式锁。锁会带来额外依赖、延迟、续期与过期语义、fail-open/fail-closed 决策;它适合「确实需要跨进程互斥且临界区很短」的场景,不是通用的并发控制手段。