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 决策;它适合「确实需要跨进程互斥且临界区很短」的场景,不是通用的并发控制手段。

关联