9 分布式锁、租约与选主
为什么 SET NX 成功不等于互斥?租约的 TTL 到底在保护谁?进程 GC 停顿 12 秒、锁 TTL 只有 10 秒时,旧持有者还能不能写?Redlock 的争论到底在争什么?ZooKeeper 的临时节点和 etcd 的 lease 能提供什么、不能提供什么?选主和锁是不是一回事?什么时候根本不该用锁?
能画出 acquire/renew/use/release/fence 五步协议;能用 Lua 写出不会误删他人锁的解锁与续期;能解释 Redis 主从切换为什么丢锁;能在资源端用单调 token 拒绝过期持有者;能用 etcdctl lease timetolive 和 redis-cli INFO 判断租约健康;能写出一份锁的使用边界与评审清单。
凌晨 02:00,对账任务由 12 个 Pod 竞争执行。代码里只有一行保护:
if redis.set("lock:recon:2026-10-18", owner, nx=True, px=10_000):
run_reconciliation()
这段代码在测试环境跑了半年,没出过问题。某天夜里,Pod A 拿到锁后触发了一次 12 秒的 Full GC;锁的 TTL 是 10 秒,到期后 Redis 删掉了 key。Pod B 随即拿到锁,开始跑同一批退款对账。A 在 12 秒后恢复,它并不知道自己的租约已经过期,继续把内存里那批退款记录写进账本。B 也在写。第二天财务发现,同一批退款被记了两次。
三方都没有「写错」:
- Pod A:「我
SET NX成功了,锁在我手里。」 - Redis:「TTL 到了,我按约定删 key,这是租约合同的一部分。」
- Pod B:「我拿到锁了,当然可以执行。」
三个正确的局部行为,拼出了一次重复副作用。 问题不在于谁撒谎,而在于「持有锁」这件事本身有时效,而暂停的进程不会自动知道自己已经过期。
第 1 章把一次调用拆成了逻辑请求、物理尝试和 unknown;第 3 章讲复制时提到了故障切换与 fencing;第 5 章讲 Raft 时区分了「接受写」和「提交写」。这一章要把这些线索收拢到一个问题上:在异步、会暂停、没有全局时钟的环境里,如何让「同一时刻只有一个执行者」这件事尽量成立,并且在它不成立时不让副作用落地?
答案不是一个 key,而是三层:互斥、租约、fencing。本章路线:9.1 建立三层模型;9.2 讲 Redis 锁的加锁、解锁、续期和主从丢锁;9.3 讲 Redlock 与时钟假设;9.4 讲 ZooKeeper/etcd 的临时节点、lease 和 watch;9.5 讲选主与锁的区别;9.6 讲 fencing token 如何在资源端落地。然后进入工程部分:9.7 用 GC 停顿时间线拆失败模式,9.8 用两条命令看租约,9.9 复盘 2016 年的 Redlock 辩论,9.10 拆「锁过期 + 重试」造成的双执行,9.11 收束到使用边界,最后是陷阱清单和出口检查。
9.1 锁的三个层次:互斥、租约、fencing
「分布式锁」最容易被理解成「在 Redis 里放一个 key,谁设上谁赢」。这个理解只覆盖了第一层。真实系统里,锁至少由三个层次组成,每一层解决一个不同的问题,也各自引入新的失败模式。
| 层次 | 回答的问题 | 典型机制 | 能挡住什么 | 挡不住什么 |
|---|---|---|---|---|
| 互斥 | 同一时刻最多一个持有者? | SET NX、临时节点、CAS | 正常并发下的重复进入 | 崩溃后不释放、分区、过期 |
| 租约 | 持有者失效后多久释放? | TTL + 续期、session timeout | 崩溃导致的永久占锁 | 进程暂停、时钟跳变、网络隔离 |
| fencing | 过期持有者还能写吗? | 单调 token + 资源端最大值 | 任意暂停后的旧写 | 资源端不校验、旁路写 |
9.1.1 第一层:互斥只保证「正常情况下只有一个」
互斥的定义很朴素:在任意时刻,最多只有一个客户端认为自己持有锁。用 Redis 实现时,SET key token NX PX ttl 把「判断不存在」和「写入」合成一个原子操作;用 ZooKeeper 实现时,多个客户端在同一个父节点下创建临时顺序节点,编号最小的那个获得锁。
这一层只回答「谁先到」。它默认锁服务本身是权威的,且持有者会主动释放。一旦持有者崩溃,key 就永远留在那里,其他客户端永远拿不到锁。于是有了第二层。
9.1.2 第二层:租约把「永久占用」变成「有期限的授权」
租约(lease)是锁服务给出的一份有时间上限的授权:在 [acquire, acquire + TTL] 这段时间内,锁服务承诺不再把锁发给别人;超过这个时间,锁服务可以单方面收回。持有者要在到期前续期(renew),续期成功就顺延,续期失败就必须停止工作。
租约解决了崩溃后的释放问题,但引入了一个更隐蔽的假设:持有者能在 TTL 内完成工作,或者至少能在 TTL 内发现自己已经失去租约。 进程被 GC 停顿、被 cgroup 限流、被虚拟机挂起、被 SIGSTOP 时,这个假设不成立。它既不能完成工作,也不能发现自己已经失去租约。
租约不是失败检测器。失败检测器回答「对方死了吗」,租约回答「到什么时候为止,我还承认你」。前者永远有假阳性,后者永远有时钟假设。
9.1.3 第三层:fencing 把「旧持有者」挡在资源外
fencing 的思路是:锁服务在每次授予锁时,额外发一个单调递增的 token;客户端在写资源时带上这个 token;资源端记录见过的最大 token,拒绝所有更小的 token。
图里 A 在 GC 停顿时租约过期,B 拿到 fence=2 并写入资源;A 恢复后带着 fence=1 写,被资源端拒绝。注意,锁服务并没有阻止 A 发请求,它只是发了一个号;真正让 A 的写失效的是资源端的比较逻辑。这就是本章最重要的一句话:
锁服务只负责发号,资源端负责执行。
如果资源端不校验 token,或者存在一条绕过校验的写路径(缓存、消息队列、运维脚本、另一个服务直连数据库),fencing 就形同虚设。很多「我们加了锁还是重复」的事故,根因不是锁服务,而是资源端从来没有真正成为唯一的写入口。
一个可用的分布式锁协议至少包含五步:
- acquire:客户端用唯一 token 尝试获取锁,锁服务返回租约期限和 fencing token;
- renew:在租约到期前续期;续期失败或本地 deadline 已过,立即停止工作;
- use:在临界区内执行副作用,每次写资源都携带 fencing token;
- release:用 token 做比较后删除锁,不能无条件
DEL; - fence:资源端在同一个原子操作里比较 token 与已记录的最大值,拒绝旧 token。
五步里任何一步缺失,协议都不完整:没有 renew,崩溃后锁会占死;没有 release 的比较,会误删别人的锁;没有 fence,过期持有者仍能写。
9.1.4 三个时间,不要混着用
锁的讨论里经常出现三个「时间」,它们由不同的人维护,含义也不同:
| 时间 | 谁维护 | 用来回答 | 典型错误 |
|---|---|---|---|
| 锁服务的 TTL | Redis / etcd / ZooKeeper | 什么时候可以把锁发给别人 | 用客户端时钟去算服务端过期 |
| 客户端的 lease deadline | 持有者进程的单调时钟 | 我什么时候必须停止工作 | 暂停后继续用旧 deadline |
| 资源的提交顺序 | 数据库 / 存储 / 日志 | 最终哪个写生效 | 没有 token,只按到达顺序覆盖 |
第一和第二个时间之间没有共享时钟。锁服务按自己的时钟过期,客户端按自己的时钟判断「还剩多少时间」;两者只能靠保守的余量对齐,不能靠「应该差不多」。第三个时间更独立:即使前两个都判断正确,两个写到达资源的顺序也可能与获取锁的顺序不同。
锁不是一把钥匙,而是一份带过期时间的授权;授权会过期,而暂停的进程不会自动知道。
本章用两个贯穿例子:对账任务 lock:recon:2026-10-18,以及退款单 refund:8842。前者用来讲租约和 GC 停顿,后者用来讲重复副作用和资源端 fencing。
9.2 Redis 锁:SET NX PX、Lua 解锁、续期
Redis 是最常见的「顺手加个锁」的地方,也是误解最多的地方。这一节把 Redis 锁拆成四个动作:加锁、解锁、续期、故障切换。每个动作都给出可执行命令和失败路径。
9.2.1 加锁:一条命令,三个要点
TOKEN=$(python3 -c 'import secrets; print(secrets.token_hex(16))')
redis-cli SET lock:refund:8842 "$TOKEN" NX PX 30000
第一次执行返回:
OK
在锁未过期时再次执行,返回:
(nil)
这条命令有三个要点。
第一,NX 和 PX 必须在同一条命令里。 常见错误是:
redis-cli SETNX lock:refund:8842 "$TOKEN" # 1
redis-cli EXPIRE lock:refund:8842 30 # 2
如果进程在 1 和 2 之间崩溃,key 就永远没有 TTL,锁永久泄漏。SET ... NX PX 是原子的,不会留下这个窗口。
第二,token 必须是随机且足够长的。 不要用 hostname、pid、user_id 或时间戳当 token:这些值可能重复,重复的 token 会让「比较后删除」失去意义。16 字节随机数的碰撞概率可以忽略。
第三,TTL 是租约,不是「业务超时」。 TTL 应该大于临界区 p99.9 的耗时,并留出续期和网络抖动的余量;但它不可能大到「绝对不会过期」,因为停顿没有上界。把 TTL 设成 5 分钟只是把事故概率降低,不是消除。
客户端 A 拿到锁,TTL 30 秒。A 的处理耗时 35 秒,锁在 30 秒时过期;B 拿到锁,开始处理同一张退款单。A 在 35 秒处理完后执行 DEL lock:refund:8842,把 B 的锁删掉了。C 随即拿到锁,三个客户端同时进入临界区。
A 的 DEL 本身没有「恶意」,它只是不知道自己已经不再是持有者。修复不是「让 A 更快」,而是让解锁变成「只有 token 匹配才删除」。
9.2.2 解锁:Lua 比较后删除
GET 再 DEL 不是原子操作:A 在 GET 之后、DEL 之前可能恰好过期,B 拿到锁,A 的 DEL 删掉 B 的锁。正确做法是把比较和删除放进同一个 Lua 脚本:
-- unlock.lua:只有 token 匹配才删除
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
调用:
redis-cli --eval unlock.lua lock:refund:8842 , "$TOKEN"
返回 1 表示删除成功,0 表示 token 不匹配(锁已过期或被别人持有)。Redis 执行 Lua 时不会被其他命令打断,所以「比较 + 删除」是一个原子动作。
这段脚本只保护锁 key,不保护资源。A 的解锁会正确地失败,但 A 仍然可能带着旧身份去写数据库。Lua 解锁解决的是「不要误删别人的锁」,不是「不要让过期持有者写资源」。 后者只能靠 fencing。
9.2.3 续期:在 deadline 之前,用同一个 token 延长
续期同样要比较 token:
-- renew.lua:token 匹配才延长 TTL
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0
一个最小的 Python 租约封装:
import secrets
import time
UNLOCK_LUA = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
"""
RENEW_LUA = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0
"""
class RedisLease:
def __init__(self, client, key: str, ttl_ms: int = 30_000):
self.client = client
self.key = key
self.ttl_ms = ttl_ms
self.token = secrets.token_hex(16)
self.deadline = 0.0
def acquire(self) -> bool:
ok = self.client.set(self.key, self.token, nx=True, px=self.ttl_ms)
if ok:
self.deadline = time.monotonic() + self.ttl_ms / 1000
return bool(ok)
def renew(self) -> bool:
# 本地 deadline 已过,不再续期;即使 Redis 里 key 还在,也不能信
if time.monotonic() >= self.deadline:
return False
ok = self.client.eval(RENEW_LUA, 1, self.key, self.token, self.ttl_ms)
if ok:
self.deadline = time.monotonic() + self.ttl_ms / 1000
return bool(ok)
def release(self) -> None:
self.client.eval(UNLOCK_LUA, 1, self.key, self.token)
三个细节决定了这段代码是否可用:
- 续期间隔应小于 TTL 的 1/3,例如 TTL 30 秒、每 10 秒续一次;这样即使一次续期丢包,还有两次机会。
renew()返回False时,业务必须立刻停止写资源,而不是「再试一次」。续期失败可能意味着锁已经过期、被删除、或被别人持有;继续写就是过期写。- 续期线程和业务线程共享同一个进程。Full GC 会同时暂停两者;cgroup 限流也一样。把续期放到独立 sidecar 可以避免「业务暂停导致续期停止」,但 sidecar 无法知道业务是否还活着,可能出现「锁一直续着,业务早已卡死」的另一种故障。
心跳的目的是「告诉别人我还活着」,续期的目的是「延长一份有期限的授权」。心跳丢了可以重连,续期失败必须停止工作。把续期写成「失败了就重试到成功」,等于把租约当成永久锁。
9.2.4 主从切换:锁 key 可能根本没到从库
Redis 主从复制默认是异步的。考虑下面这条时间线:
| 时间 | 主库 | 从库 | 客户端 A | 客户端 B |
|---|---|---|---|---|
| t0 | SET NX 成功,返回 OK | 尚未收到 | 认为持锁,开始处理 | — |
| t1 | 写入 repl buffer | 网络延迟/排队 | 处理中 | — |
| t2 | 崩溃/被 Sentinel 判定下线 | 被提升为新主 | 暂停或继续 | — |
| t3 | — | 新主上没有锁 key | 仍认为持锁 | SET NX 成功 |
| t4 | — | — | 写资源(无 fence) | 写资源(持锁) |
t4 时刻,A 和 B 都认为自己有资格写。Redis 的 WAIT 命令可以等待指定数量的从库确认:
redis-cli SET lock:refund:8842 "$TOKEN" NX PX 30000
redis-cli WAIT 1 100
WAIT 1 100 返回 1 表示至少一个从库在 100 ms 内确认了写。但「确认收到」不等于「故障切换后一定被提升」:被提升的可能是另一个没有收到这条写的从库;确认也可能只到达了从库的内存,没落盘。WAIT 缩小窗口,不消除窗口。
min-replicas-to-write 和 min-replicas-max-lag 可以在「没有健康从库」时拒绝写,降低丢锁概率;但它们不改变「异步复制 + 提升可能落后」的事实。AOF 的 appendfsync everysec 最多丢 1 秒;always 更慢,也不保护「提升了一个没追上日志的从库」。RDB 快照丢得更多。
结论不是「Redis 不能用」,而是:Redis 锁在故障切换时可能同时存在两个持有者。 如果业务只能容忍「极低概率的重复」,可以接受;如果业务是扣款、退款、库存,必须加 fencing,或者把锁换成有 quorum 语义的系统。
9.2.5 过期、驱逐与重启:Redis 锁的其它敌人
除了主从切换,还有三条容易被忽略的丢锁路径:
| 路径 | 触发方式 | 后果 | 防护 |
|---|---|---|---|
| TTL 到期 | 临界区超过 TTL,续期没赶上 | 另一个客户端拿到锁 | TTL 留余量 + 续期 + fence |
| 内存驱逐 | maxmemory-policy allkeys-lru 把锁 key 挤掉 | 锁提前消失 | 锁实例用 noeviction,或独立实例 |
| 重启/恢复 | 未持久化,或从旧 RDB 恢复 | 锁 key 消失 | 不把 Redis 当唯一正确性来源 |
TTL 命令的返回值也值得记住:-2 表示 key 不存在(过期或被删),-1 表示 key 存在但没有过期时间。-1 在生产里通常是一个 bug:说明某段代码用了 SET 而不是 SET ... PX,或者续期时把 TTL 弄丢了。把 TTL == -1 做成告警,比事后排查「为什么锁永远不释放」便宜得多。
9.2.6 Redis 锁适合什么
| 用途 | 是否适合 | 理由 |
|---|---|---|
| 防止重复劳动(同一个报表只生成一次) | 适合 | 重复的代价是浪费 CPU,不是数据错误 |
| 短临界区、可容忍极低概率重复 | 适合 | 实现简单,延迟低 |
| 扣款、退款、库存扣减 | 不适合单独使用 | 主从切换和暂停会制造双持有者,必须 fence |
| 跨多个资源的强一致事务 | 不适合 | Redis 锁不是事务,资源的提交顺序不受它控制 |
一个实用的判断:如果「锁失效」的后果可以用「再跑一次」修复,Redis 锁够用;如果后果是「钱多了一笔」或「库存少了一件」,就需要资源端 fencing,或者干脆不用锁。
9.3 Redlock 争议与时钟假设
Redis 作者 antirez 提出过 Redlock:用 N 个独立的 Redis master(通常 N=5,不做复制),客户端依次向它们申请锁;只有在多数派(3 个)上成功,并且总耗时小于 TTL,才认为锁获取成功。
9.3.1 算法:多数派 + 有效期
一个简化的 Python 版本:
import time
def acquire_redlock(clients, key: str, token: str, ttl_ms: int) -> int:
start = time.monotonic()
acquired = 0
for client in clients:
if client.set(key, token, nx=True, px=ttl_ms):
acquired += 1
elapsed_ms = (time.monotonic() - start) * 1000
drift_ms = ttl_ms * 0.01 + 2 # 允许 1% 时钟漂移 + 2ms
validity_ms = ttl_ms - elapsed_ms - drift_ms
if acquired >= len(clients) // 2 + 1 and validity_ms > 0:
return int(validity_ms)
# 失败:尽力释放所有已经拿到的锁
for client in clients:
client.eval(UNLOCK_LUA, 1, key, token)
return 0
实际实现要用 try/finally 保证失败时释放已经拿到的锁;释放失败只能等 TTL 过期。
这段代码里有三个时间量:ttl_ms 是每个 Redis 实例上的过期时间;elapsed_ms 是客户端观察到的获取耗时;validity_ms 是客户端认为自己还能安全使用锁的剩余时间。算法要求 validity_ms > 0,否则即使多数派成功也放弃。
Redlock 的设计目标是在没有复制、没有 Sentinel 的情况下,用多个独立实例提高可用性:少数实例故障不影响获取;多数实例故障时无法获取,但也不会错误地发放。它不要求实例之间同步,也不要求它们有共享时钟。
9.3.2 时钟假设:有效期是一个减法
Redlock 的安全性依赖三个假设:
- 网络延迟有上界:客户端能在一个已知的时间内完成多数派获取;
- 时钟漂移有上界:
validity_ms的减法不会因为时钟跳变而严重失真; - 进程暂停有上界:客户端在拿到锁之后,不会暂停到超过
validity_ms才继续执行。
前两个假设可以通过工程手段逼近:用单调时钟计算 elapsed_ms,把漂移上限写进公式,部署 NTP。第三个假设不行:GC、虚拟机挂起、cgroup 限流、SIGSTOP 都没有上界。
更麻烦的是,单调时钟也不是万能的。Linux 的 CLOCK_MONOTONIC 在系统挂起(suspend)期间通常不前进;CLOCK_BOOTTIME 才包含挂起时间。Python 的 time.monotonic() 在 Linux 上映射到 CLOCK_MONOTONIC。如果虚拟机被挂起 20 秒,进程恢复后可能认为只过了 1 秒,而锁服务的 TTL 已经按真实时间过期。这种情况下,本地 deadline 检查会给出错误的「还很安全」。
单调时钟解决的是「NTP 把墙上时钟往回拨」的问题,不解决「进程/虚拟机根本没有运行」的问题。前者是时钟源问题,后者是执行模型问题;后者只能靠 fencing 或外部监督。
9.3.3 Kleppmann 的批评:暂停发生在检查之后
2016 年 2 月 8 日,Martin Kleppmann 发表《How to do distributed locking》,对 Redlock 提出了一组批评。核心不是「Redis 性能不好」,而是「Redlock 把正确性建立在时间假设上」。他的关键论证可以压缩成一条时间线:
| 时间 | 客户端 A | 锁服务 | 客户端 B | 资源 |
|---|---|---|---|---|
| t0 | 获取锁,validity=10s | 授予 | — | — |
| t1 | 开始执行,尚未写资源 | — | — | — |
| t2 | GC 暂停 12 秒 | 租约到期 | — | — |
| t3 | — | 锁可被获取 | 获取锁 | — |
| t4 | — | — | 写资源 | 接受 B 的写 |
| t5 | 恢复,认为锁还有效 | — | — | A 也写,重复/覆盖 |
Kleppmann 的结论是:Redlock 无法防止「客户端在拿到锁之后、写资源之前暂停」;要防住这个,资源端必须能拒绝旧持有者,也就是 fencing token。他还指出,如果只是为了效率(避免重复劳动),单实例 Redis 就够了,不需要 5 个 master;如果为了正确性,Redlock 不够,需要 fencing 或共识系统。
9.3.4 antirez 的回应:假设之内是安全的
antirez 随后发表《Is Redlock safe?》回应。他的论证大致有三点:
- Redlock 假设时钟漂移有界,不是任意跳变的墙上时钟;算法里的
drift项就是为漂移留的余量。只要漂移在有界范围内,有效性判断是可靠的。 - 进程暂停是客户端故障,不是锁算法的责任。如果客户端暂停到超过租约,它已经失去了持有资格;一个「暂停后仍继续写」的客户端,任何基于租约的锁都救不了它。要救,就得在资源端做 fencing。
- Redlock 的定位是效率,不是强一致。它比单实例 Redis 更可用,但不是什么都能防;需要绝对安全时,应该用 fencing 或共识系统。
这场辩论没有一个「谁对谁错」的判决。两人其实在回答不同的问题:Kleppmann 问「Redlock 能不能作为正确性原语」,antirez 答「在它声明的假设内,它可以安全地发放租约;假设之外要靠 fencing」。如果把辩论读成「Redis 不能做锁」,就错过了真正的工程结论。
| 场景 | 只用 Redlock | Redlock + 资源端 fence |
|---|---|---|
| 正常并发 | 互斥 | 互斥 |
| 客户端崩溃 | TTL 后释放 | TTL 后释放 |
| 客户端 GC 暂停超过 TTL | 两个持有者,可能双写 | 旧 token 被拒,安全 |
| 时钟跳变/挂起 | 有效期判断可能失真 | token 仍然单调,安全 |
| 多数 Redis 实例故障 | 无法获取(可用性下降) | 同样无法获取 |
9.3.5 工程结论:不要用 Redlock 买「正确性」
把争论落到选型上:
| 需求 | 建议 | 原因 |
|---|---|---|
| 避免重复劳动、短临界区 | 单实例 Redis + TTL + token 解锁 | 简单;重复的代价可控 |
| 高可用、能容忍极低概率重复 | Redlock 或 Redis Cluster + 监控 | 多数派提高可用性,但不改变时间假设 |
| 扣款、退款、库存、迁移 | 共识系统(etcd/ZooKeeper)的锁 + 资源端 fence | 锁服务本身要有 quorum;资源端要有最后一道闸 |
| 多资源原子操作 | 把操作放进同一个共识状态机/事务 | 锁只能串行化入口,不能保证多个资源的提交原子性 |
Redlock 的 5 个 master 买到的是可用性,不是正确性。把「多数派获取成功」当成「绝对不会双写」,是把效率工具当成了正确性证明。
9.4 ZooKeeper/etcd:临时节点、lease、watch
Redis 的锁是一个 key;ZooKeeper 和 etcd 的锁是一套「会话 + 临时节点/lease + watch」的机制。它们提供了更强的顺序保证,但没有消除租约和暂停的问题。
9.4.1 ZooKeeper:临时顺序节点 + 前驱 watch
ZooKeeper 的经典锁配方:
- 每个客户端在
/locks/recon下创建一个临时顺序节点,例如/locks/recon/lock-0000000001; - 客户端
getChildren("/locks/recon"),如果自己的编号最小,获得锁; - 否则,对前一个节点设置
existswatch,等待它被删除; - 前驱消失后,重新读取子节点列表,确认自己是否最小;
- 释放时删除自己的节点;会话过期时,ZooKeeper 自动删除临时节点。
create -e -s /locks/recon/lock- ""
# /locks/recon/lock-0000000001
ls /locks/recon
# [lock-0000000001]
stat -w /locks/recon/lock-0000000000 # 前驱节点;不存在时 watch 创建事件
为什么要 watch 前驱,而不是 watch 父节点?如果所有等待者都 watch 父节点,每次有人释放,所有人都会收到通知并同时 getChildren,形成「羊群效应」(herd effect)。watch 前驱把唤醒范围缩小到一个客户端,释放路径变成一条链。
ZooKeeper 的会话(session)是租约的载体:客户端定期发 ping,服务端在 session timeout 内保持会话;超时后会话过期,临时节点被删除。会话过期是服务端单方面决定的,客户端可能因为 GC 停顿而不知道;恢复后它会收到 SessionExpired,此时必须放弃「我还持锁」的假设。
ZooKeeper 的写是线性一致的(经过 leader),但读默认由所连接的 follower 提供,可能落后于其他客户端的最新写。锁配方的安全性不来自「读一定最新」,而来自 zxid 顺序、临时顺序节点的编号,以及「自己的创建已经提交」这一事实。需要强读时,先 sync 再读。
9.4.2 etcd:lease + revision 队列
etcd 的锁配方建立在 lease 上:
export ETCDCTL_API=3
LEASE=$(etcdctl lease grant 30 | awk '{print $2}')
etcdctl put /locks/recon/owner-a "host-a" --lease="$LEASE"
etcdctl lease timetolive "$LEASE" --keys
示例输出:
lease 694d7d8c2c4a4b3d granted with TTL(30s), remaining(21s)
/locks/recon/owner-a
客户端必须持续续约(keepalive);如果续约停止超过 TTL,etcd 会删除这个 lease 下的所有 key。锁的互斥不是靠 SET NX,而是靠 revision 顺序:
- 每个竞争者创建自己的 key(带 lease),etcd 返回一个全局递增的 revision;
- revision 最小的竞争者获得锁;
- 其他人 watch 自己的前一个 revision 对应的 key;
- 前驱被删除后,watch 触发,重新检查自己是否最小。
etcd 的 clientv3/concurrency 包把这套逻辑封装成 Mutex:NewSession 创建 lease,NewMutex 创建锁对象,Lock(ctx) 阻塞直到获得锁。它同样用「前驱 watch」避免羊群效应,并且 revision 天然单调,适合作为 fencing token 的来源。
9.4.3 临时节点/lease 的本质:服务端 TTL,不是客户端保险
ZooKeeper 的临时节点和 etcd 的 lease 都做同一件事:把「持有者是否还活着」的判断,变成「服务端在 TTL 内有没有收到续约」。这解决了崩溃后的释放,但也带来两个必须接受的边界:
| 边界 | ZooKeeper | etcd | 后果 |
|---|---|---|---|
| 客户端暂停超过 TTL | 会话过期,临时节点删除 | lease 过期,key 删除 | 旧持有者恢复后可能仍以为自己持锁 |
| 续约网络分区 | 会话超时 | keepalive 失败 | 锁被释放,业务被另一个持有者接管 |
| 服务端时钟/租约检查 | 依赖 session tracker | 依赖 lease 过期队列 | 服务端故障时可能延迟释放 |
| 客户端收到过期通知 | SessionExpired | keepalive 返回失败 | 通知可能晚于旧持有者的下一次写 |
这些系统能保证「锁的状态变更在服务端是线性一致的」,但不能保证「旧持有者在暂停后不会继续写外部资源」。第 5 章 5.11 引用的 Jepsen etcd 3.4.3 分析就指出:etcd 的 KV 操作在分区、暂停、时钟偏移下仍是严格可串行化的,但 lock API 不保证互斥;等待锁的路径存在 lease 有效性未复查的问题。这不是说 etcd 的实现「很差」,而是说 lease 模型的边界在 API 层也会显现。
9.4.4 watch 不是锁
watch 只是一个「状态变化通知」,不是「授权凭证」。它有三种常见的坑:
- ZooKeeper 的 watch 是一次性的:触发后必须重新注册;如果你在回调里做了一次耗时操作,期间的变化不会再有通知。
- etcd 的 watch 可能被取消或压缩:客户端太慢、历史 revision 被 compaction、网络断开,都会让 watch 中断;恢复后必须重新 list,不能假设「没收到事件就是没变化」。
- watch 事件不等于「轮到我」:收到前驱删除的通知后,必须重新读取当前子节点/revision 顺序,确认自己确实是最小;否则可能在一个已经过期的视图上进入临界区。
客户端 B 在 etcd 上排队等锁,watch 自己的前驱。网络抖动导致 watch 流断开,B 的客户端库没有立即重连;前驱在这段时间被删除,B 没有收到事件。B 的应用层认为「还没轮到我」,一直等待,锁却已经空闲。另一种更危险的情况是:B 在断线前收到「前驱删除」的事件,但在重连后没有重新检查 revision,直接进入临界区;此时 C 可能已经用更小的 revision 拿到了锁。
正确做法:watch 断线后重新 get 当前锁前缀,按 revision 重新排序,再决定是否持有;不要把「曾经收到过事件」当成「现在持有锁」。
9.4.5 三者对比
| 维度 | Redis | ZooKeeper | etcd |
|---|---|---|---|
| 锁原语 | SET NX PX | 临时顺序节点 | lease + revision |
| 一致性 | 主从异步,故障切换可能丢锁 | 写线性一致,读可能陈旧 | 读写可线性一致(quorum) |
| 会话/租约 | TTL,客户端续期 | session timeout,服务端心跳 | lease TTL,keepalive |
| 等待机制 | 轮询/阻塞命令 | watch 前驱(一次性) | watch 前驱(流) |
| fencing token 来源 | INCR(故障切换可能回退) | zxid / 节点编号 | revision |
| 运维成本 | 低 | 中 | 中 |
这张表不能直接推出「etcd 一定比 Redis 好」。etcd 的每次锁操作要走 quorum,延迟和运维成本更高;Redis 更简单,但故障切换时可能丢锁。选型的依据是「锁失效的后果」,不是「哪个系统更先进」。
9.5 选主:bully、Raft、lease
「选主」经常被当成「拿锁」的同义词:谁拿到锁,谁就是 leader。两者有交集,但不是一回事。
| 维度 | 锁 | 选主 |
|---|---|---|
| 目的 | 保护一段临界区 | 产生一个长期权威 |
| 持有时间 | 秒级到分钟级 | 天级到任期级 |
| 失败处理 | TTL 释放,重新竞争 | 重新选举,term 递增 |
| 正确性来源 | 锁服务 + 资源端 fence | quorum + term/epoch + 资源端 fence |
| 典型误用 | 用锁做 leader 选举 | 以为 leader 可以随便写外部资源 |
9.5.1 bully:教学算法,不是生产方案
bully 算法的规则很简单:每个节点有唯一 ID;检测到 coordinator 失联后,向所有 ID 更大的节点发 Election;收到 OK 就等待,收不到就自己当 coordinator 并广播 Coordinator。
A(1) 发现 coordinator 失联
├─ 向 B(2)、C(3)、D(4) 发 Election
├─ B、C 回 OK → A 等待
├─ B、C 继续向更高 ID 发 Election
├─ D 没有更高 ID 可问 → 广播 Coordinator(D)
└─ 所有节点接受 D 为 coordinator
bully 的假设是:消息延迟有上界、故障是 fail-stop、所有节点互相可达。网络分区时,两个分区可能各自选出 coordinator,形成双主。它不是为异步网络设计的,生产系统不用它做正确性保证;理解它的价值在于看清「选主 = 检测 + 竞争 + 宣告」这三步,以及每一步都依赖假设。
9.5.2 Raft:term + 多数派 + 日志限制
Raft 的选举比 bully 多三道保险:
- term:每届选举有一个递增的任期号;节点只接受不小于自己 term 的请求;同一 term 最多一个 leader。
- 多数派:候选人必须拿到超过半数节点的投票;两个多数派必然相交,所以同一 term 不会选出两个 leader。
- 日志限制:候选人必须拥有不落后于任何投票者的日志;这保证已提交的日志不会丢失。
但 Raft 只保证「同一 term 最多一个 leader 能提交日志」,不保证「旧 leader 立刻停止服务」。旧 leader 可能还在监听端口、还在返回读结果、还在接受客户端请求;它只是无法让写提交。第 5 章 5.9 已经拆过这个时间线:旧 leader 的写会超时/unknown,客户端重试到新 leader;如果旧 leader 还在执行外部副作用,就需要 fencing。
Raft 的 lease read 也有时钟假设:leader 在租约内直接读,依赖「租约到期前不会选出新 leader」和「检查租约到返回结果之间没有长暂停」。第 5 章 5.8 给出的条件是:
lease_duration + max_clock_drift < election_timeout
且(检查租约 → 返回结果)之间的进程暂停 < 剩余租约
第二个条件在 GC 停顿面前并不总是成立。所以 lease read 通常要配合 CheckQuorum(联系不到多数派就退位)和保守的租约长度;它不是「免费的线性一致读」。
9.5.3 lease 选主:外部租约 + 旧主自停
很多系统把选主做在外部存储上:候选者向 etcd/ZooKeeper/数据库申请一个带 TTL 的租约,续约成功就是 leader;续约失败就主动降级。典型流程:
1. 候选者 C 写 /leader/c 并带 lease,值为 (term, epoch)
2. 写成功且 revision 最小 → C 成为 leader
3. C 周期续约;续约失败 → C 停止服务,进入 follower
4. 其他候选者 watch /leader,发现租约消失后重新竞选
5. 存储端拒绝 epoch 小于已记录最大值的写
第 3 步的「停止服务」是行为变更,不是状态变更。很多实现只把角色改成 follower,但连接还在、缓存还在、定时任务还在跑;旧 leader 仍然可能写下游。要真正停止,需要:
- 关闭监听端口/拒绝新请求;
- 停止定时任务和后台线程;
- 撤销对下游的凭证;
- 在存储端用 epoch/fence 拒绝旧写;
- 必要时用 STONITH/断电做物理隔离。
| 手段 | 作用点 | 能挡住什么 | 盲区 |
|---|---|---|---|
| 旧主自停 | 进程 | 大多数旧主继续服务 | 进程暂停时无法执行自停 |
| 网络隔离/撤权 | 网络/凭证 | 旧主无法访问下游 | 本地缓存/本地盘仍可写 |
| STONITH/断电 | 物理机/虚拟机 | 彻底停止旧主 | 慢、可能误杀、云环境受限 |
| 存储 epoch/fence | 资源端 | 旧 epoch 的写 | 资源端不校验/旁路写 |
9.5.4 选主不是锁
把两者混为一谈,会导致两个错误:
- 用锁做选主:拿 Redis 锁当 leader 选举,锁过期后旧 leader 还在跑定时任务,新 leader 也跑,两个都写下游。锁只能保证「同一时刻一个持有者」,不能保证「旧持有者停止副作用」。
- 以为 leader 可以随便写:Raft 选出的 leader 对「日志提交」有权威,但对「外部资源」没有天然权威。外部资源只认 token/epoch,不认「我是 leader」这句话。
正确的组合是:选主产生 term/epoch;资源端校验 term/epoch;旧 leader 在失去 quorum 或租约后自停,但自停只是降低概率,fence 才是最后一道闸。
9.6 fencing token:把过期锁挡在资源外
fencing 的核心不是「让旧持有者不发请求」,而是「让旧持有者的请求在资源端无效」。这一节把 token 的来源、资源端的校验、以及常见的实现错误拆开。
9.6.1 token 从哪里来:必须单调
fencing token 必须满足两个条件:
- 单调:每次授予锁/提升 leader,token 严格递增;
- 全局可比较:同一个资源的所有写路径使用同一套 token 空间。
常见的 token 来源:
| 来源 | 单调性 | 跨故障切换 | 备注 |
|---|---|---|---|
| etcd revision | 集群内单调 | 是(除非从旧快照恢复) | 推荐;与锁操作天然绑定 |
| ZooKeeper zxid | 集群内单调 | 是(除非从旧快照恢复) | 需要读取节点 stat |
| Raft term + log index | 单调 | 是 | 适合 leader epoch |
| 数据库序列 | 单调(可能有空洞) | 取决于主从切换 | 异步复制可能回退 |
Redis INCR | 单实例单调 | 可能回退 | 故障切换后新主可能从旧值开始 |
| 时间戳 | 不保证 | 不保证 | 时钟回拨会重复/倒退 |
时间戳不能当 fence。 两台机器的时钟可能差几秒,NTP 可能回拨;同一毫秒内也可能有两个持有者。Redis 的 INCR 在单实例上是单调的,但主从切换后新主的计数器可能落后于旧主;如果旧主已经发过 fence=100,新主从 fence=90 开始,资源端会拒绝所有新 token(安全但不可用),或者更糟:如果资源端的最大值也丢了,旧 token 可能被接受。
从旧快照恢复也会让 revision/zxid 回退。如果 token 可能回退,资源端的「严格大于」会拒绝合法的新持有者,造成安全性保留、活性丧失。工程上要么保证 token 源不倒退(持久化 + 恢复协议),要么在 token 里加一个 epoch(集群 ID/恢复代数),让比较变成 (epoch, counter) 的字典序。
9.6.2 资源端怎么校验:同一个原子操作
下面是一个最小但正确的实现。表里保存每个账户的 fence(已接受的最大 token):
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
balance_cents INTEGER NOT NULL,
fence INTEGER NOT NULL DEFAULT 0
);
扣款时,把「检查 token」和「修改余额」放进同一条 UPDATE:
class StaleToken(Exception):
pass
def debit(conn, account_id: str, amount_cents: int, token: int) -> None:
with conn: # 事务:要么扣款并推进 fence,要么什么都不做
cur = conn.execute(
"""
UPDATE accounts
SET balance_cents = balance_cents - ?,
fence = ?
WHERE id = ?
AND fence < ?
AND balance_cents >= ?
""",
(amount_cents, token, account_id, token, amount_cents),
)
if cur.rowcount != 1:
# 可能是旧 token,也可能是余额不足;用只读查询区分,仅用于报错
row = conn.execute(
"SELECT fence, balance_cents FROM accounts WHERE id = ?",
(account_id,),
).fetchone()
raise StaleToken(f"rejected token={token}, row={row}")
关键点在 WHERE fence < ? 和 SET fence = ? 同属一条语句。如果写成:
row = conn.execute("SELECT fence FROM accounts WHERE id = ?", (account_id,)).fetchone()
if token > row[0]:
conn.execute("UPDATE accounts SET balance_cents = balance_cents - ? WHERE id = ?", ...)
就会有一个「检查后、写入前」的窗口:两个请求可能都通过检查,然后都写入。check-then-act 不是 fencing,只是把竞态窗口缩小了一点。
一次测试的输出:
B: debit(account=acct-1, amount=100, token=42) -> ok, fence=42
A: debit(account=acct-1, amount=100, token=41) -> StaleToken: rejected token=41, row=(42, 900)
B: debit(account=acct-1, amount=50, token=42) -> StaleToken: rejected token=42, row=(42, 900)
注意第三行:同一个持有者用相同 token 重试,fence < 42 不成立,会被拒绝。这是有意的:token 相同不代表请求相同。如果同一个持有者需要重试同一笔扣款,应该用幂等键去重,而不是重复使用 fence。fencing 防的是「旧持有者」,幂等防的是「同一个持有者的重复请求」,两者不能互相替代。
9.6.3 一个安全性小定理
设资源端对每个资源维护 max_fence,只接受满足 t > max_fence 的写,并在接受后令 max_fence = t;设 token 由单调递增的分配器产生。那么,一旦某个 token 为 t 的写被接受,任何 token t' < t 的写都不可能在此后提交。
接受 t 的写之后,max_fence >= t。此后任何写要提交,必须满足 t' > max_fence >= t,即 t' > t;因此 t' < t 的写不可能通过校验。若两个写并发到达,资源端的比较与更新在同一个原子操作中完成,它们会被串行化:低 token 先提交、高 token 后提交时两者都成功,但顺序正确;高 token 先提交时低 token 被拒。无论哪种,一旦高 token 提交,低 token 不可能在其后提交。归纳可得结论。
这个定理有两个容易被忽略的前提:
- 资源端必须持久化
max_fence。如果重启后max_fence回到 0,旧 token 就能再次通过。max_fence要和业务数据在同一个事务里落盘。 - 所有写路径都必须经过这个校验。只要有一条旁路(缓存直写、消息队列消费者、运维 SQL、另一个服务),定理就不适用。
9.6.4 fencing 与幂等:两把不同的锁
| 机制 | 防的是什么 | 典型实现 | 不防什么 |
|---|---|---|---|
| fencing token | 旧持有者在失去锁之后继续写 | 资源端 max_fence 比较 | 同一逻辑请求的重复(需要幂等键复用结果) |
| 幂等键 | 同一个逻辑请求被多次执行 | 唯一索引 + 结果复用 | 旧持有者用新键写入 |
| 唯一约束 | 重复记录 | UNIQUE(intent_id) | 不同 intent 的重复副作用 |
| 事务 | 部分提交 | ACID | 跨资源的重复 |
一个常见的错误是「加了唯一索引,所以不用 fence」。唯一索引只能挡住「同一个键」的第二次插入;如果旧持有者用了一个新键(例如新生成的退款 ID),唯一索引不会拦它。反过来,严格 > 会把同一个 token 的第二次写拒绝掉,但客户端拿不到第一次的结果;如果重试换了新 token(例如重新拿锁),fence 也挡不住。需要幂等键把同一逻辑请求收敛到一次结果。三者是叠加关系,不是替代关系。
9.6.5 资源端不配合怎么办
fencing 要求资源端能比较 token。现实中有三类资源做不到:
| 资源类型 | 能否 fence | 替代方案 |
|---|---|---|
| 自建数据库/存储 | 能 | 在表里加 fence 列,事务内比较 |
| 对象存储 | 部分能 | 条件写(ETag/If-Match)+ 元数据;或把 token 写进对象名/版本 |
| 第三方支付/邮件 API | 通常不能 | 用对方提供的幂等键/去重窗口;不能声称「绝对不重复」 |
| 消息队列 | 部分能 | 分区内顺序 + 消费者侧去重;跨分区不保证 |
如果资源端无法 fence,正确的说法不是「我们加了锁所以安全」,而是「我们把重复窗口缩小到了 X,并且接受剩余风险」。这句话必须写进设计文档,而不是藏在代码注释里。
任何「锁 + 资源」的设计,都要能回答一个问题:如果持有者在拿到锁之后暂停了 TTL 那么久,恢复后继续写,资源端会不会拒绝? 如果答案是「不会」,那这把锁只提供了效率,不提供正确性。
9.7 失败模式:GC 停顿导致锁过期与重复执行
9.1 开头的故事不是假设。把一次 Full GC 放进时间线,会看到两个持有者如何在谁都没「违规」的情况下同时存在。
t(s) 客户端 A 锁服务 客户端 B 资源 0 acquire(TTL=10s, fence=41) --▶ 授予 0.5 Full GC 开始(STW,整进程暂停) 10 (仍在 GC) 租约到期,删除 key 10.2 (仍在 GC) B acquire(fence=42) --▶ 授予 10.4 (仍在 GC) B 写(fence=42) --▶ 接受,max=42 12.5 GC 结束,恢复 12.51 若每次写前检查 deadline:已过期 → 停止(正确实现) 12.52 若只在进入临界区前检查过:继续写(fence=41) -------------------------------▶ 拒绝(max=42)
9.7.1 谁在什么时候知道什么
到 t=12.5 为止,五个参与者的认知并不相同:
| 主体 | 它认为 | 实际情况 |
|---|---|---|
| A 的业务线程 | 「我拿到过锁,任务还没跑完」 | 租约已过期约 2.5 秒 |
| A 的续期线程 | 被同一个 STW 暂停,无法续期 | 同上 |
| 锁服务 | 租约到期,key 已删除 | 正确 |
| B | 我拿到了锁,可以写 | 正确 |
| 资源 | 已接受的最大 token 是 42 | 正确 |
如果 A 在恢复后每次写资源前都检查本地 deadline,它会发现已经过期并停止;这是一个正确的实现。事故往往发生在下面几种「检查不完整」的写法里:
- 只在进入临界区前检查一次。后面的循环、批量 flush、异步回调都不再检查;GC 可以发生在任意两次写之间。
- check-then-act。在
t=9.99检查时还剩 0.01 秒,检查通过;紧接着又发生一次 200 ms 的暂停,写发生在t=10.19,此时租约已经过期。检查和写不是原子的。 - 写路径在另一个线程/协程里。主线程检查了 deadline,工作线程从队列里取出任务继续写;工作线程没有继承这个检查。
- 本地时钟没有前进。虚拟机被挂起、
CLOCK_MONOTONIC不包含 suspend 时间,恢复后 A 以为只过了 1 秒;它甚至不会发现租约已经过期。 - 续期成功但写超时。续期线程活着,锁还在;业务线程卡在下游调用上,等它返回时早已超过本地 deadline。锁没丢,但业务已经不该继续。
这五种写法里,前三种靠「更仔细地检查」可以缓解;第四种靠本地检查完全无效;第五种说明「锁还在」不等于「这次操作还该做」。fencing 能覆盖前四种:旧 token 会被拒绝。第五种需要业务层的 deadline/取消机制;如果锁仍然有效、没有新持有者,fencing 不会拒绝一个当前 token 的写。
9.7.2 加长 TTL 为什么不是解法
「把 TTL 从 10 秒加到 60 秒」是最常见的反应。它确实能把「12 秒停顿」这类事故的概率降下来,但代价和边界都很清楚:
| TTL | 12 秒停顿 | 60 秒停顿 | 崩溃后锁占用 | 适用性 |
|---|---|---|---|---|
| 10 s | 双持有者 | 双持有者 | 10 s | 短任务 |
| 60 s | 安全 | 双持有者 | 60 s | 中等任务 |
| 300 s | 安全 | 安全 | 5 min | 长任务,但故障恢复慢 |
TTL 越长,崩溃后锁被占用的时间越长,可用性越差;TTL 越短,暂停导致双持有者的概率越高。这是一个没有最优解的权衡,因为停顿的分布没有上界。 工程上能做的是:把 TTL 设在「临界区 p99.9 + 续期余量」附近,把续期做勤,把本地 deadline 检查做全,然后在资源端加 fence。四件事缺一不可。
A 的临界区是一个 5000 条的批量循环。代码在循环开始前检查了一次 deadline,然后每写 100 条 flush 一次。GC 在第 3200 条时发生,持续 12 秒;恢复后循环继续,第 3300 条到第 5000 条都写在租约过期之后。A 的日志里甚至有一行 lease_ok=true,因为那是 12 秒前打印的。
修复不是「把检查移到循环里」这么简单:检查仍然可能刚好发生在 flush 之前。正确做法是每次 flush 都带 fence,资源端拒绝旧 token;同时把「本地 deadline 已过」作为停止条件,减少无效写。
9.7.3 停顿不止 GC 一种
| 停顿来源 | 典型时长 | 本地时钟会前进吗 | 备注 |
|---|---|---|---|
| Full GC / STW | 百毫秒–数十秒 | 会 | 堆越大、对象越多,越难预测 |
| cgroup CPU 限流 | 十毫秒–数秒 | 会 | 容器被 throttle,线程拿不到 CPU |
| 虚拟机挂起/恢复 | 秒–分钟 | 可能不会 | CLOCK_MONOTONIC 不含 suspend |
| 虚拟机 live migration | 百毫秒–数秒 | 可能跳变 | 迁移期间 vCPU 暂停 |
SIGSTOP/调试器 | 任意 | 会 | 进程完全冻结 |
| 页错误/swap | 毫秒–秒 | 会 | 内存压力下的不可预测停顿 |
| 网络黑洞 | 秒–分钟 | 会 | 进程在跑,但收不到续期响应 |
前六种是「进程没有运行」,最后一种是「进程在运行但通信失败」。两者的共同点是:持有者都无法在租约到期时做出反应。区别在于,网络黑洞至少还可能通过超时发现;进程暂停连「发现」都做不到。
9.7.4 三道防线,最后一道不能省
| 防线 | 作用 | 失效场景 |
|---|---|---|
| 本地 deadline 检查 | 尽早停止,减少无效写 | 暂停、check-then-act、异步线程 |
| 续期 | 延长租约,覆盖长任务 | 同一进程暂停、sidecar 与业务脱节 |
| 资源端 fence | 拒绝旧 token 的写 | 资源端不校验、旁路写 |
前两道是效率:它们让系统在大多数时候不需要走到 fence,减少错误和重试。第三道是正确性:它不依赖进程是否运行、时钟是否准确、网络是否通畅。把三道防线当成「三选一」,是本章最常见的错误。
9.8 观测:lease TTL、锁等待、会话
锁的事故很难从业务日志里直接看出来:A 的日志说「我持锁」,B 的日志也说「我持锁」,两条都「没错」。要定位,必须看锁服务侧的租约状态、等待队列和应用侧的续期/拒绝指标。
9.8.1 etcd:看 lease 还剩多久、谁在排队
export ETCDCTL_API=3
LEASE=$(etcdctl lease grant 30 | awk '{print $2}')
etcdctl put /locks/recon/owner-a "host-a" --lease="$LEASE"
etcdctl lease timetolive "$LEASE" --keys
etcdctl get /locks/recon/ --prefix --keys-only
示例输出:
lease 694d7d8c2c4a4b3d granted with TTL(30s), remaining(21s)
/locks/recon/owner-a
再查锁前缀:
/locks/recon/owner-a
/locks/recon/owner-b
第一段是 timetolive 的输出:remaining(21s) 表示这个 lease 还能活 21 秒;下一行 --keys 列出挂在它下面的 key。第二段显示锁前缀下有两个 key:owner-a 是持有者,owner-b 是等待者。谁先谁后不由字母顺序决定,而由创建时的 revision 决定;可以用 etcdctl get /locks/recon/owner-b -w json 看 mod_revision。
怎么读:
| 观察 | 正常 | 异常 | 先看什么 |
|---|---|---|---|
remaining | 在 0 < r <= TTL 内递减,续期后回到接近 TTL | 一直等于 TTL(续期过频/重置 bug) | keepalive 间隔 |
remaining 到 0 后 key 消失 | 租约正常过期 | key 还在但 lease 报 not found | etcd 健康/时钟 |
| 锁前缀下 key 数 | 1 个持有者 + 少量等待者 | 长时间大于 10 | 临界区是否卡住、是否有人忘了释放 |
mod_revision | 单调递增 | 回退 | 是否从旧快照恢复 |
| keepalive 日志 | 周期性成功 | 连续失败/断流 | 网络、etcd leader 切换 |
remaining 是一个「剩余时间」,不是「已用时间」。看到它从 30 降到 21,说明已经过了 9 秒;看到它突然回到 30,说明刚刚续期成功。如果每次采样都看到 30,要么采样间隔刚好等于续期间隔,要么代码在反复「重设 TTL」而不是续期;后者会让锁永远不过期,崩溃后很难释放。
9.8.2 ZooKeeper:会话与临时节点
zkCli.sh -server zk1:2181
stat /locks/recon/lock-0000000001
# cZxid = 0x1a2b
# ephemeralOwner = 0x2d1a7c9f0e00001
# dataLength = 0
ephemeralOwner 非 0 表示这是临时节点,值就是创建它的会话 ID;为 0 表示持久节点,持有者崩溃后不会自动释放。读法:
| 观察 | 正常 | 异常 | 先看什么 |
|---|---|---|---|
ephemeralOwner | 非 0,且与当前客户端会话一致 | 为 0 | 节点创建方式是否漏了 -e |
| 节点是否存在 | 持锁期间存在 | 业务说持锁但节点消失 | 会话是否已被服务端过期 |
| 客户端日志 | 没有 SessionExpired | 出现后仍继续写 | 业务是否处理了会话过期事件 |
| 会话超时 | 接近配置的 sessionTimeout | 频繁重连/超时 | 网络、GC、ZK ensemble 健康 |
会话过期后临时节点会被删除;如果业务日志仍说「持锁」,说明它没有处理 SessionExpired,必须立刻停止并重新竞选,而不是继续写资源。
9.8.3 Redis:看复制、TTL 和驱逐
redis-cli INFO replication
redis-cli INFO keyspace
redis-cli INFO stats | grep -E 'expired_keys|evicted_keys'
redis-cli INFO commandstats | grep -E 'cmdstat_(eval|del)'
redis-cli TTL lock:refund:8842
redis-cli GET lock:refund:8842
redis-cli --scan --pattern 'lock:*' | wc -l
示例输出:
# Replication
role:master
connected_slaves:1
slave0:ip=10.0.3.12,port=6379,state=online,offset=91827364,lag=0
master_repl_offset:91827364
# Keyspace
db0:keys=18422,expires=37,avg_ttl=91234
# Stats
expired_keys:1204
evicted_keys:0
# Commandstats
cmdstat_eval:calls=18422,usec=91234,usec_per_call=4.95
cmdstat_del:calls=12,usec=88,usec_per_call=7.33
怎么读:
| 字段 | 正常 | 异常 | 后果 |
|---|---|---|---|
connected_slaves | 至少 1(如果依赖故障切换) | 0 | 主库崩溃后锁无处恢复,丢锁概率上升 |
slave0.lag | 0–1 秒 | 持续大于 5 秒 | 从库落后,切换后可能没有锁 key |
master_link_status(在从库上) | up | down | 从库数据陈旧,不能作为切换依据 |
db0.expires | 大于 0(有 TTL 的 key) | 0 但有锁 key | 锁没有 TTL,检查 TTL == -1 |
evicted_keys | 0 | 大于 0 | 锁可能被内存策略驱逐 |
expired_keys | 平稳 | 短时激增 | 大量锁在同一时间过期,临界区可能超时 |
cmdstat_eval | 有调用(如果用了 Lua) | 0 但业务有锁 | 解锁可能用了裸 DEL |
TTL 的读法要单独强调:
TTL lock:refund:8842
# 整数:剩余秒数,正常
# -1:key 存在但没有过期时间 —— 锁泄漏/续期丢 TTL,告警
# -2:key 不存在 —— 已过期、已释放,或根本没加上
正常的分界不是「TTL 大于 0」这么简单:TTL 应该小于等于配置的 TTL,并且在续期后回升。如果 TTL 大于配置值,说明有人用了更长的过期时间;如果 TTL 一直是 -1,说明锁永远不会自动释放;如果 TTL 是 -2 但业务日志说「持锁」,说明锁已经丢了而业务不知道。
9.8.4 应用侧:五个必须有的指标
锁服务只能告诉你「租约还在不在」,不能告诉你「业务是不是还在用一把已经过期的锁」。应用侧至少要记录:
| 指标 | 含义 | 异常信号 |
|---|---|---|
lock_acquire_total{result} | 获取成功/失败/超时 | 失败率突然升高,说明争抢或锁泄漏 |
lock_renew_fail_total | 续期失败次数 | 大于 0 就应告警;连续失败必须停止业务 |
lock_lost_total | 本地 deadline 已过但仍在执行的次数 | 大于 0 说明代码路径没有检查 deadline |
fence_reject_total | 资源端拒绝旧 token 的次数 | 偶发正常;突增说明有旧持有者在写 |
lock_hold_seconds | 临界区持有时长直方图 | p99 接近 TTL 时,必须调整 TTL 或拆分任务 |
一个实用的仪表盘顺序:先看 fence_reject_total(有没有旧持有者在写),再看 lock_renew_fail_total(租约是否健康),再看 lock_hold_seconds(临界区是否过长),最后才看获取失败率。拒绝数是「已经发生的危险」,续期失败是「即将发生的危险」,获取失败只是「拿不到锁」。
INFO 和 timetolive 能证明「租约在续」「key 在」「没有驱逐」,不能证明「同一时刻只有一个持有者在写资源」。后者只能由资源端的 fence 拒绝日志和业务对账来证明。不要把「锁服务看起来正常」当成「没有双写」。
9.9 真实案例:Redlock 辩论
2016 年 2 月 8 日,Martin Kleppmann 发表《How to do distributed locking》,对 Redis 的 Redlock 算法提出批评。随后 antirez 发表《Is Redlock safe?》回应。这场辩论是分布式锁领域最著名的一次公开技术争论,也是本章很多结论的来源。
9.9.1 事实:两篇文章在争什么
| 项目 | 内容 |
|---|---|
| 时间 | 2016-02-08(Kleppmann 文章);随后 antirez 回应 |
| 系统 | Redis 的 Redlock:5 个独立 master、多数派获取、按有效期使用 |
| 失败链 | 客户端获取锁 → GC/进程暂停 → 租约过期 → 另一个客户端获取 → 两个客户端都写资源 |
| 争点 | 时钟/进程暂停下的安全性;Redlock 能否作为正确性原语;fencing 是否必需 |
| 结论 | 没有统一判决;工程共识是「锁用于效率可以,用于正确性必须 fence 或共识」 |
Kleppmann 的论证不依赖 Redis 的实现细节,而是一个执行模型:进程可以在任意两条指令之间暂停任意长时间。只要这个模型成立,任何「拿到锁之后不再询问、直接写资源」的协议都不安全,因为暂停可能发生在「拿到锁」和「写资源」之间。Redlock 的有效期检查发生在获取锁时,无法约束获取之后的暂停。
antirez 的回应则强调假设:Redlock 不假设进程可以无限暂停;它假设时钟漂移有界、网络延迟有界,客户端在有效期内完成工作。如果客户端暂停超过有效期,那是客户端故障,应该由 fencing 或外部监督处理,而不是要求锁算法解决。
9.9.2 争点拆解
| 争点 | Kleppmann | antirez | 工程落点 |
|---|---|---|---|
| 进程暂停 | 无上界,必须防 | 是客户端故障,锁算法不负责 | 资源端 fence |
| 时钟 | 有效期依赖时间;时钟跳变/挂起会让判断失真 | 漂移有界,drift 项留了余量 | 单调时钟 + 余量,但仍不解决挂起 |
| 定位 | 不能作为正确性原语 | 在声明的假设内可安全发放租约;正确性需 fencing | 按后果选型 |
| fencing | 必需 | 需要绝对安全时必需 | 扣款/库存必须 |
| 5 个 master | 不增加正确性,只增加可用性 | 提高可用性,减少单点 | 可用性 ≠ 正确性 |
这张表说明,两人并不是在同一个问题上互相否定:一个在问「能不能拿它保证不双写」,另一个在答「在声明的假设内它能安全工作」。把辩论简化成「Redis 做锁不靠谱」或「Kleppmann 不懂 Redis」,都错过了可操作的结论。
9.9.3 三条可操作的教训
- 把「锁的用途」写清楚。如果只是避免重复劳动,单实例 Redis + TTL + token 解锁足够;如果要保护钱/库存,锁服务本身要有 quorum,资源端要有 fence。
- 把「假设」写进设计文档。TTL 多长、续期多久、最大暂停多少、时钟漂移多少、资源端是否校验 token;这些不是实现细节,是协议的一部分。
- 用故障注入验证,而不是用文章站队。在测试环境注入
SIGSTOP、时钟跳变、主从切换,观察是否出现双写;如果没有 fence,几乎一定能复现。
本节只引用两篇公开文章的核心论点、时间和系统背景,不复述原文长段。具体措辞、代码和后续讨论以原文为准:Kleppmann 的文章、antirez 的回应。Redis 官方文档的分布式锁页面也把 Redlock 作为一节讨论,可作为工程读本;具体结论以文档为准。
9.10 重试导致重复副作用:锁过期后的双执行
现在把 9.7 的停顿和 9.2 的过期拼成一个完整的业务事故。场景:退款 worker 用 Redis 锁保证「同一张退款单只处理一次」,锁 TTL 30 秒;下游支付网关(PSP)在高峰期一次退款调用要 45 秒。
9.10.1 失败链
| 时间 | Worker A | 锁 | PSP | Worker B | 账本 |
|---|---|---|---|---|---|
| t0 | 获取锁,TTL 30s | 授予 | — | — | — |
| t1 | 调用 refund(intent=R1) | — | 开始处理 | — | 无记录 |
| t30 | 仍在等 PSP | 过期,删除 key | 仍在处理 | — | 无记录 |
| t31 | 仍在等 PSP | — | — | 获取锁 | 无记录 |
| t32 | 仍在等 PSP | — | — | 读账本:无记录 | 无记录 |
| t33 | 仍在等 PSP | — | 收到第二笔 refund(R1) | 调用 PSP | 无记录 |
| t45 | PSP 返回成功 | — | 第一笔成功 | 仍在等 | — |
| t46 | 写账本 | — | — | 仍在等 | 写入 R1 |
| t47 | — | — | 第二笔成功 | 写账本 | 再写一条 R1 |
表里是「账本没有 fence」的版本。如果账本带 fence,A 在 t46 的写会因为 41 小于 42 被拒绝,账本只留一条;但 PSP 在 t33 已经受理的第二笔扣款仍然发生。fence 保护账本,不保护 PSP;第三方副作用要靠它自己的幂等键和对账。
这里有两个不同的问题叠加:
- 锁过期让 B 进入了临界区;如果没有 fence,A 和 B 都能写账本。
- PSP 调用没有幂等键,所以即使账本端用唯一索引挡住了第二条记录,PSP 那边的两笔扣款已经发生。账本可以回滚一条记录,但钱不会自动回来。
如果 A 在 t45 之后还因为「超时」重试一次,而重试又用了新的退款 ID,就是第三笔。锁过期是触发条件,重试是放大器,缺少幂等键是根因。
9.10.2 为什么「锁 + 唯一索引」还不够
很多团队会加一个 UNIQUE(intent_id),认为这样就不会重复。它确实能挡住「同一 intent 写两条账本」,但挡不住:
- PSP 侧的重复扣款:账本只记录一条,但 PSP 扣了两次;需要 PSP 的幂等键或对账冲正。
- 重试/新持有者用了不同的 intent:A 用 R1,重试时生成了 R2,唯一索引不拦。
- 旁路写:另一个服务直接写账本,不经过唯一约束对应的入口。
- 部分成功:PSP 成功、账本写入失败;重试时 PSP 又扣一次。
所以修复必须是多层的:
| 层 | 机制 | 挡住什么 | 挡不住什么 |
|---|---|---|---|
| 入口 | 分布式锁 | 正常并发下的同时进入 | 过期后的双持有者 |
| 账本 | UNIQUE(intent_id) | 同一 intent 的两条记录 | 不同 intent、PSP 侧扣款 |
| 账本 | fencing token | 旧持有者的写 | 新持有者的重复、PSP 侧扣款 |
| PSP | 幂等键 = intent_id | 同一 intent 的重复扣款 | 不同 intent、PSP 不支持幂等 |
| 对账 | 按 intent_id 核对 PSP 流水 | 已发生的重复/遗漏 | 实时性;对账本身可能漏 |
9.10.3 一个可用的退款状态机
import sqlite3
def refund_once(conn: sqlite3.Connection, psp, intent_id: str,
amount_cents: int, token: int) -> str:
# 1) 本地先占位:唯一约束保证同一 intent 只有一条
with conn:
conn.execute(
"INSERT OR IGNORE INTO refund_intents(intent_id, status) VALUES (?, 'pending')",
(intent_id,),
)
row = conn.execute(
"SELECT status, psp_ref FROM refund_intents WHERE intent_id = ?",
(intent_id,),
).fetchone()
if row[0] == "done":
return row[1] # 已经成功,直接复用结果
# 2) 调 PSP,幂等键用 intent_id;超时后查询,不要盲目重试
try:
resp = psp.refund(idempotency_key=intent_id, amount_cents=amount_cents)
except TimeoutError:
resp = psp.get_refund(idempotency_key=intent_id) # 查询而不是重发
if resp is None:
raise # 状态未知,交给对账/重试同一 intent
# 3) 账本落账,带 fence;旧 token 会被拒绝
with conn:
cur = conn.execute(
"""
UPDATE refund_intents
SET status = 'done', psp_ref = ?, fence = ?
WHERE intent_id = ? AND status = 'pending' AND fence < ?
""",
(resp.id, token, intent_id, token),
)
if cur.rowcount != 1:
raise RuntimeError("stale token or duplicate transition")
return resp.id
这段代码的重点不是语法,而是三个顺序:
- 先占位,再调 PSP。如果先调 PSP 再写本地,进程在两步之间崩溃,本地没有记录,重试会再扣一次;先占位至少留下「这个 intent 正在处理」的痕迹。
- PSP 超时后查询,不重发。查询也可能失败;失败就保持
pending,由后台对账按intent_id查 PSP,而不是生成新 ID 重试。 - 账本落账用 fence。旧持有者即使恢复,也不能把过期的结果写进账本;新持有者用更高的 token 完成落账。
这个状态机仍然不是「绝对一次」。它把重复窗口从「锁过期后的任意双写」缩小到「PSP 不支持幂等 + 对账失败」这类更窄、更可观测的路径。工程上要的是「可证明的边界 + 可对账的残留」,不是一句「我们加了锁」。
退款单 R1,金额 100 元。Worker A 拿到锁后调用 PSP,45 秒才返回;锁 30 秒过期。Worker B 拿到锁,看到账本没有 R1,调用 PSP;PSP 没有幂等键,扣了第二笔。A 的客户端在 50 秒时超时,SDK 自动重试,生成了新的退款单 R2,PSP 扣了第三笔。
修复后:PSP 幂等键固定为 R1,账本对 intent_id 建唯一索引,落账带 fence,超时只查询不重发。三次退款变成一次扣款 + 一次可对账的 pending,重复窗口从「每次重试」缩小到「PSP 幂等窗口之外」。
9.11 锁的使用边界
前面十节都在讲「怎么把锁做对」。这一节反过来问:这件事真的需要锁吗? 很多分布式锁事故的根因不是锁写错了,而是从一开始就不该用锁。
9.11.1 能不用就不用
| 问题 | 不一定需要锁 | 更简单的做法 |
|---|---|---|
| 防止重复插入 | 唯一索引 + INSERT ... ON CONFLICT | 数据库约束比锁可靠 |
| 防止重复扣款 | 幂等键 + 状态机 | 下游/账本去重 |
| 同一实体只允许一个写者 | 按 key 分区 + 单写者 | Kafka 分区、分片、actor |
| 并发更新冲突 | 乐观锁/版本号/CAS | UPDATE ... WHERE version = ? |
| 多步操作原子性 | 数据库事务 | 本地事务 + outbox |
| 任务只跑一次 | 队列 + 消费组 | 不需要全局锁 |
| 读多写少的配置 | 版本号 + 缓存 | 不需要锁 |
这些方案的共同点是:把「互斥」变成「冲突检测」或「单写者拓扑」。它们不依赖一个额外的锁服务,也就没有租约、续期、主从丢锁这些问题。能用唯一索引解决的,不要用分布式锁;能用单写者分区解决的,不要用全局锁。
9.11.2 什么时候确实需要锁
| 场景 | 为什么需要 | 仍然要做的 |
|---|---|---|
| 外部稀缺资源(打印机、机器人、部署槽位) | 资源本身不支持并发 | 租约 + 资源端状态检查 |
| 非幂等的第三方副作用 | 对方没有幂等键 | 尽量选支持幂等的下游;锁 + 对账/冲正,不能声称绝对一次 |
| 数据库迁移/DDL | 只能一个实例执行 | 锁 + 人工确认 + 超时 |
| 定时任务单实例 | 避免重复执行 | 锁 + 幂等 + 监控 |
| 多步骤人工审批的串行化 | 业务要求顺序 | 状态机 + 锁只做入口 |
即使在这些场景,锁也只是「入口的串行化」。临界区内部的每一步仍然要问:如果锁在这里过期,会发生什么?资源端能不能拒绝?能不能用幂等键兜底?
9.11.3 锁的代价
引入一把分布式锁,等于引入一个新的分布式系统:
- 依赖:锁服务挂了,业务是降级还是拒绝?拒绝会损失可用性,降级可能双写。
- 延迟:每次 acquire/renew 都是一次网络往返;跨地域锁的 RTT 可能比临界区本身还长。
- 复杂度:token、续期、超时、重连、watch、会话过期,每一项都要处理和测试。
- 运维:Redis 主从、etcd quorum、ZK ensemble,各自的故障模式不同。
- 观测:必须有续期失败、fence 拒绝、持有时长这些指标,否则出事时只能猜。
一把锁的代码可能只有 20 行,但它背后的故障模式有 20 种。不要为了省一个唯一索引,引入一套需要值班的锁基础设施。
9.11.4 锁评审的十二个问题
| # | 问题 | 答不上来的后果 |
|---|---|---|
| 1 | 锁保护的资源是什么?粒度是哪个 key? | 锁太粗,吞吐下降;锁太细,保护不到 |
| 2 | 锁失效(过期/丢失)的后果是什么? | 无法判断该用 Redis 还是共识系统 |
| 3 | 临界区最长可能多久?TTL 是多少? | TTL 太短导致双持有者,太长导致故障恢复慢 |
| 4 | 续期失败时,业务怎么停? | 继续写过期数据 |
| 5 | 资源端是否校验 fencing token? | 锁只是效率工具,不是正确性保证 |
| 6 | 所有写路径都经过校验吗? | 缓存/队列/运维脚本绕过 |
| 7 | token 从哪来?故障切换/恢复后会不会回退? | 旧 token 被接受,或新 token 被误拒 |
| 8 | 解锁是否比较 token? | 误删别人的锁 |
| 9 | 锁服务故障时,业务是降级还是拒绝? | 可用性与正确性没有明确取舍 |
| 10 | 等待锁的时间上限是多少? | 请求堆积、线程池耗尽 |
| 11 | 有没有幂等键/唯一约束兜底? | 锁失效时直接双写 |
| 12 | 观测指标和对账手段是什么? | 出事无法定位、无法补偿 |
如果时间只够问五个问题,问这五个:
- 锁过期后,旧持有者还能写吗?资源端会不会拒绝?
- 续期失败时,业务代码走哪条分支?会不会继续写?
- 锁服务主从切换/恢复后,token 会不会回退?
- 有没有一条写路径绕过了 fence?
- 如果明天锁服务全挂,业务是拒绝服务还是双写?
9.11.5 反模式
- 锁包住网络调用:临界区里调下游,下游慢导致锁过期,双持有者。把网络调用移到锁外,或者用状态机/异步。
- 没有 TTL 的锁:崩溃后永久占用;
TTL == -1必须告警。 - 有 TTL 但没有续期:长任务必然过期;要么续期,要么把任务拆短。
- 无条件
DEL解锁:误删别人的锁;必须 Lua 比较 token。 - 一把全局锁:所有租户/资源共用,吞吐和爆炸半径都不可控;按资源分 key。
- 把锁当队列:用
while not acquire(): sleep()排队,浪费连接和 CPU;用 etcd/ZK 的等待队列或消息队列。 - 用锁掩盖竞态:本该用唯一索引/幂等/事务的地方加锁,锁失效后问题依旧。
- 只监控 acquire 成功:不看续期失败和 fence 拒绝,等于只看「门有没有开」,不看「有没有人从窗户进来」。
9.12 本章陷阱清单
SET NX成功就以为安全:它只证明「写入时 key 不存在」,不证明「写资源时锁还有效」;暂停、主从切换、驱逐都会让锁失效。- 解锁直接
DEL:锁过期后可能删掉别人的锁;必须用 Lua 比较 token 再删。 - TTL 没有续期,或续期失败后继续写:长任务必然过期;续期失败必须停止业务,而不是重试到成功。
- 续期线程和业务共享同一个 GC:Full GC 会同时暂停两者;独立 sidecar 能续期,但可能掩盖业务卡死,仍需 fence。
- 以为主从复制不会丢锁:Redis 复制是异步的,
WAIT只缩小窗口;故障切换可能提升一个没有锁 key 的从库。 - 把 Redlock 当共识:多数派获取成功不等于线性一致;Redlock 买到的是可用性,不是正确性。
- 用时间戳或
INCR当 fence:时钟会回拨,Redis 主从切换会让计数器回退;token 必须跨故障切换单调。 - 资源端只检查 token 存在,不比较大小:等于没有 fence;必须记录
max_fence并拒绝更小的 token。 - check-then-act:先
SELECT fence再UPDATE,两个请求可能都通过检查;比较和写入必须在同一条语句/事务里。 - 只 fence 写路径:缓存、消息队列、运维 SQL、另一个服务都可能绕过;资源端必须是唯一写入口。
- TTL 小于下游 p99.9,或把网络调用放在锁内:锁在等下游时过期,双持有者;把慢调用移出临界区。
- 锁 key 粒度太粗/跨租户:一把全局锁让所有请求排队,爆炸半径不可控;按资源/租户分 key。
- 把锁当队列或重试机制:无限等待、无限重试会耗尽线程/连接;用等待队列、退避和超时。
- 只看 acquire 成功,不看 renew 失败/fence 拒绝:观测缺一半;没有拒绝日志,就无法证明 fencing 真的在工作。
9.13 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出锁的三层 | 能说出互斥/租约/fencing 各挡什么、各漏什么 | 9.1 |
| 写对 Redis 加锁/解锁 | SET NX PX 原子;Lua 比较 token 再删 | 9.2 |
| 解释主从丢锁 | 能画出「主库写成功、从库没收到、提升后双持有者」 | 9.2.4 |
| 解释 Redlock 的时钟假设 | 能说出进程暂停/挂起为什么破坏有效期 | 9.3 |
| 用 ZK/etcd 的租约 | 能说出临时节点/lease、watch 前驱、会话过期 | 9.4 |
| 区分选主与锁 | 能说出 term/epoch 与资源端 fence 的关系 | 9.5 |
| 写资源端 fence | 单条 UPDATE ... WHERE fence < ?,持久化 max_fence | 9.6 |
| 画出 GC 停顿时间线 | 标出 t0/t10/t12 与两个 token | 9.7 |
| 用命令看租约 | etcdctl lease timetolive 或 redis-cli INFO 能读出异常 | 9.8 |
| 说出 Redlock 辩论的结论 | 不是「谁赢」,而是「按后果选型 + fence」 | 9.9 |
| 拆一次双执行 | 能画出锁过期 + PSP 慢 + 重试的完整链 | 9.10 |
| 判断该不该用锁 | 能给出至少三种替代方案 | 9.11 |
Q1 Redis 锁 TTL 30 秒,业务执行 45 秒,续期线程在 GC 中暂停了 35 秒。描述从 t=0 到 t=50 的锁状态,并说明为什么「续期线程恢复后重试」不能保证安全。
Q2 redis-cli WAIT 1 100 返回 1,是否意味着主从切换后锁一定还在?为什么?
Q3 为什么不能用 time.time() 生成 fencing token?给出一个 token 回退的具体场景,并说明资源端会看到什么。
Q4 资源端校验 token 时,为什么不能先 SELECT fence 再 UPDATE?如果必须分两步,至少要加什么?
Q5 一个「每天 02:00 跑一次对账」的任务,除了分布式锁,还有哪些更简单的单实例方案?各有什么代价?
Q6 资源端已经用 fencing 拒绝了旧 token,为什么还需要幂等键?举一个 fence 挡不住的例子。
答案
A1 t=0 获取锁,TTL 到 t=30;续期线程从 t=1 到 t=36 被暂停,t=30 锁过期;t=31 另一个 worker 可以拿到锁;t=36 A 恢复,业务继续到 t=45,但 A 的锁早已不在。续期线程 t=36 恢复后「重试续期」也没用:如果锁已被 B 持有,Lua 比较 token 会失败;如果没人持有,Redis 里 key 已过期,续期也失败。更关键的是,即使续期成功,A 在 t=36 到 t=45 之间已经写了资源;安全不能靠「把 key 续回来」,只能靠资源端拒绝旧 token。
A2 不一定。WAIT 只表示至少一个从库在超时内确认了写;故障切换可能提升另一个没有收到这条写的从库,确认也可能只到达从库内存。异步复制 + 提升策略决定了「确认」不等于「切换后仍在」。要正确性,必须 fence 或使用有 quorum 提交的存储。
A3 墙上时钟可能被 NTP 回拨,也可能在虚拟机恢复后跳变;两台机器同一毫秒可能生成相同 token,或后生成的 token 更小。场景:A 在 12:00:00.900 拿到 fence=1000,时钟被回拨 2 秒,B 在 12:00:00.100 拿到 fence=999;资源端已经记录 1000,B 的写被拒(活性问题);如果资源端也重启丢了 max_fence,旧 token 又可能被接受。用 etcd revision、ZXID、Raft term+index 或 (epoch, counter)。
A4 因为 SELECT 和 UPDATE 之间有时间窗口:两个请求都读到 fence=41,都判断 42 > 41,然后都写入,max_fence 被覆盖成 42,但两笔副作用都发生了。必须把比较和写入放在同一条 UPDATE ... WHERE fence < ?(或同一个事务 + 行锁)里;如果业务必须分两步,至少要在事务里锁住行(支持 SELECT ... FOR UPDATE 的数据库用它;SQLite 可用 BEGIN IMMEDIATE 串行化写事务),并让所有写路径走同一事务。
A5 方案一:Kubernetes CronJob 的 concurrencyPolicy: Forbid(只保证同一 CronJob 内不并发,跨集群/跨命名空间不保证);方案二:队列 + 单消费者(吞吐低,但天然单写者);方案三:数据库唯一约束 + 任务执行记录(INSERT 抢占,失败即退出,不需要锁服务);方案四:按日期分片,每个分片一个写者。代价分别是:依赖调度器、吞吐、需要清理记录、分片设计复杂。
A6 fence 只保护「被 fence 的资源」。例如账本用 fence 拒绝了旧 worker 的写,但旧 worker 已经在第三方 PSP 上完成了一笔扣款;PSP 不认识你的 fence,只认它自己的幂等键。另一个例子:同一持有者重新拿锁后得到更大的 token,再把同一笔请求写一次,fence 会接受(token 更大);或者资源端用了 >=,同一 token 的重复写也会被接受。fence 只排序写者,不识别「同一逻辑请求」;需要幂等键/唯一约束。fence 防旧持有者,幂等防重复请求,两者叠加。
母本对应
| 母本 | 章节 | 本书落点 | 程度 | 说明 |
|---|---|---|---|---|
| 谢希仁《计算机网络》第 8 版 | 第 5/6 章 | 9.1、9.4 | 背景 | 只借用「分布式系统没有全局时钟/共享内存」的视角;不重写传输层与协议栈 |
| 张彦飞《深入理解 Linux 网络》 | 第 4–7 章 | 9.2、9.5 | 背景 | 连接、超时、阻塞只引用;不展开内核网络路径 |
| 《Python 网络编程》 | socket/HTTP/并发服务器 | 9.2、9.6、9.10 | 精选 | 借用工程风格;锁、fence、退款状态机代码为本书重写 |
| 《Linux 系统与内核》第 2/3 篇 | 进程、线程、调度、时钟 | 9.3、9.7 | 交叉引用 | 只引用 CLOCK_MONOTONIC/CLOCK_BOOTTIME、STW、cgroup 限流等事实;不重写内核实现 |
| 本书自编 + Kleppmann/antirez/Redis/etcd/ZooKeeper 文档 | — | 9.3、9.4、9.9 | 完整 | 只引用公开事实与算法思想,不复制文章/文档长段;辩论双方观点以原文为准 |
下一站:锁能挡住并发的写,但挡不住「你看不见它」。进入 10 可观测性:trace、指标、日志、回放,看一次请求的 span 怎么串、指标怎么聚合、日志怎么关联,以及为什么「有监控」不等于「能定位」。