Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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。

租约过期与 fencing token 客户端 A 在 GC 停顿时租约过期;B 获得 fence=2;A 恢复后带 fence=1 的写被资源拒绝。 租约过期与 fencing token 客户端 A 锁服务 资源端 acquire → lease=30s, fence=1 A 暂停(GC) 租约继续走 租约到期 B acquire → fence=2 接受 fence=2 A 恢复:write(fence=1) 拒绝:1 < 2 锁的正确性不只在锁服务,还在被保护资源是否校验 token。
读图:加长 TTL 不能解决停顿,只能降低概率;真正的解法是资源端记住见过的最大 token,拒绝所有更小的 token。锁服务只负责发号,资源端负责执行。

图里 A 在 GC 停顿时租约过期,B 拿到 fence=2 并写入资源;A 恢复后带着 fence=1 写,被资源端拒绝。注意,锁服务并没有阻止 A 发请求,它只是发了一个号;真正让 A 的写失效的是资源端的比较逻辑。这就是本章最重要的一句话:

锁服务只负责发号,资源端负责执行。

如果资源端不校验 token,或者存在一条绕过校验的写路径(缓存、消息队列、运维脚本、另一个服务直连数据库),fencing 就形同虚设。很多「我们加了锁还是重复」的事故,根因不是锁服务,而是资源端从来没有真正成为唯一的写入口。

定义 9.1.1 · 分布式锁协议

一个可用的分布式锁协议至少包含五步:

  1. acquire:客户端用唯一 token 尝试获取锁,锁服务返回租约期限和 fencing token;
  2. renew:在租约到期前续期;续期失败或本地 deadline 已过,立即停止工作;
  3. use:在临界区内执行副作用,每次写资源都携带 fencing token;
  4. release:用 token 做比较后删除锁,不能无条件 DEL;
  5. fence:资源端在同一个原子操作里比较 token 与已记录的最大值,拒绝旧 token。

五步里任何一步缺失,协议都不完整:没有 renew,崩溃后锁会占死;没有 release 的比较,会误删别人的锁;没有 fence,过期持有者仍能写。

9.1.4 三个时间,不要混着用

锁的讨论里经常出现三个「时间」,它们由不同的人维护,含义也不同:

时间谁维护用来回答典型错误
锁服务的 TTLRedis / 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 分钟只是把事故概率降低,不是消除。

例 9.2.1 · 没有 token 的解锁

客户端 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)

三个细节决定了这段代码是否可用:

  1. 续期间隔应小于 TTL 的 1/3,例如 TTL 30 秒、每 10 秒续一次;这样即使一次续期丢包,还有两次机会。
  2. renew() 返回 False 时,业务必须立刻停止写资源,而不是「再试一次」。续期失败可能意味着锁已经过期、被删除、或被别人持有;继续写就是过期写。
  3. 续期线程和业务线程共享同一个进程。Full GC 会同时暂停两者;cgroup 限流也一样。把续期放到独立 sidecar 可以避免「业务暂停导致续期停止」,但 sidecar 无法知道业务是否还活着,可能出现「锁一直续着,业务早已卡死」的另一种故障。
续期不是心跳

心跳的目的是「告诉别人我还活着」,续期的目的是「延长一份有期限的授权」。心跳丢了可以重连,续期失败必须停止工作。把续期写成「失败了就重试到成功」,等于把租约当成永久锁。

9.2.4 主从切换:锁 key 可能根本没到从库

Redis 主从复制默认是异步的。考虑下面这条时间线:

时间主库从库客户端 A客户端 B
t0SET 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 的安全性依赖三个假设:

  1. 网络延迟有上界:客户端能在一个已知的时间内完成多数派获取;
  2. 时钟漂移有上界:validity_ms 的减法不会因为时钟跳变而严重失真;
  3. 进程暂停有上界:客户端在拿到锁之后,不会暂停到超过 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开始执行,尚未写资源———
t2GC 暂停 12 秒租约到期——
t3—锁可被获取获取锁—
t4——写资源接受 B 的写
t5恢复,认为锁还有效——A 也写,重复/覆盖

Kleppmann 的结论是:Redlock 无法防止「客户端在拿到锁之后、写资源之前暂停」;要防住这个,资源端必须能拒绝旧持有者,也就是 fencing token。他还指出,如果只是为了效率(避免重复劳动),单实例 Redis 就够了,不需要 5 个 master;如果为了正确性,Redlock 不够,需要 fencing 或共识系统。

9.3.4 antirez 的回应:假设之内是安全的

antirez 随后发表《Is Redlock safe?》回应。他的论证大致有三点:

  1. Redlock 假设时钟漂移有界,不是任意跳变的墙上时钟;算法里的 drift 项就是为漂移留的余量。只要漂移在有界范围内,有效性判断是可靠的。
  2. 进程暂停是客户端故障,不是锁算法的责任。如果客户端暂停到超过租约,它已经失去了持有资格;一个「暂停后仍继续写」的客户端,任何基于租约的锁都救不了它。要救,就得在资源端做 fencing。
  3. Redlock 的定位是效率,不是强一致。它比单实例 Redis 更可用,但不是什么都能防;需要绝对安全时,应该用 fencing 或共识系统。

这场辩论没有一个「谁对谁错」的判决。两人其实在回答不同的问题:Kleppmann 问「Redlock 能不能作为正确性原语」,antirez 答「在它声明的假设内,它可以安全地发放租约;假设之外要靠 fencing」。如果把辩论读成「Redis 不能做锁」,就错过了真正的工程结论。

场景只用 RedlockRedlock + 资源端 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 的经典锁配方:

  1. 每个客户端在 /locks/recon 下创建一个临时顺序节点,例如 /locks/recon/lock-0000000001;
  2. 客户端 getChildren("/locks/recon"),如果自己的编号最小,获得锁;
  3. 否则,对前一个节点设置 exists watch,等待它被删除;
  4. 前驱消失后,重新读取子节点列表,确认自己是否最小;
  5. 释放时删除自己的节点;会话过期时,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 的读不一定是最新

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 顺序:

  1. 每个竞争者创建自己的 key(带 lease),etcd 返回一个全局递增的 revision;
  2. revision 最小的竞争者获得锁;
  3. 其他人 watch 自己的前一个 revision 对应的 key;
  4. 前驱被删除后,watch 触发,重新检查自己是否最小。

etcd 的 clientv3/concurrency 包把这套逻辑封装成 Mutex:NewSession 创建 lease,NewMutex 创建锁对象,Lock(ctx) 阻塞直到获得锁。它同样用「前驱 watch」避免羊群效应,并且 revision 天然单调,适合作为 fencing token 的来源。

9.4.3 临时节点/lease 的本质:服务端 TTL,不是客户端保险

ZooKeeper 的临时节点和 etcd 的 lease 都做同一件事:把「持有者是否还活着」的判断,变成「服务端在 TTL 内有没有收到续约」。这解决了崩溃后的释放,但也带来两个必须接受的边界:

边界ZooKeeperetcd后果
客户端暂停超过 TTL会话过期,临时节点删除lease 过期,key 删除旧持有者恢复后可能仍以为自己持锁
续约网络分区会话超时keepalive 失败锁被释放,业务被另一个持有者接管
服务端时钟/租约检查依赖 session tracker依赖 lease 过期队列服务端故障时可能延迟释放
客户端收到过期通知SessionExpiredkeepalive 返回失败通知可能晚于旧持有者的下一次写

这些系统能保证「锁的状态变更在服务端是线性一致的」,但不能保证「旧持有者在暂停后不会继续写外部资源」。第 5 章 5.11 引用的 Jepsen etcd 3.4.3 分析就指出:etcd 的 KV 操作在分区、暂停、时钟偏移下仍是严格可串行化的,但 lock API 不保证互斥;等待锁的路径存在 lease 有效性未复查的问题。这不是说 etcd 的实现「很差」,而是说 lease 模型的边界在 API 层也会显现。

9.4.4 watch 不是锁

watch 只是一个「状态变化通知」,不是「授权凭证」。它有三种常见的坑:

  1. ZooKeeper 的 watch 是一次性的:触发后必须重新注册;如果你在回调里做了一次耗时操作,期间的变化不会再有通知。
  2. etcd 的 watch 可能被取消或压缩:客户端太慢、历史 revision 被 compaction、网络断开,都会让 watch 中断;恢复后必须重新 list,不能假设「没收到事件就是没变化」。
  3. watch 事件不等于「轮到我」:收到前驱删除的通知后,必须重新读取当前子节点/revision 顺序,确认自己确实是最小;否则可能在一个已经过期的视图上进入临界区。
例 9.4.1 · watch 丢了,以为锁还在

客户端 B 在 etcd 上排队等锁,watch 自己的前驱。网络抖动导致 watch 流断开,B 的客户端库没有立即重连;前驱在这段时间被删除,B 没有收到事件。B 的应用层认为「还没轮到我」,一直等待,锁却已经空闲。另一种更危险的情况是:B 在断线前收到「前驱删除」的事件,但在重连后没有重新检查 revision,直接进入临界区;此时 C 可能已经用更小的 revision 拿到了锁。

正确做法:watch 断线后重新 get 当前锁前缀,按 revision 重新排序,再决定是否持有;不要把「曾经收到过事件」当成「现在持有锁」。

9.4.5 三者对比

维度RedisZooKeeperetcd
锁原语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 递增
正确性来源锁服务 + 资源端 fencequorum + 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 多三道保险:

  1. term:每届选举有一个递增的任期号;节点只接受不小于自己 term 的请求;同一 term 最多一个 leader。
  2. 多数派:候选人必须拿到超过半数节点的投票;两个多数派必然相交,所以同一 term 不会选出两个 leader。
  3. 日志限制:候选人必须拥有不落后于任何投票者的日志;这保证已提交的日志不会丢失。

但 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 必须满足两个条件:

  1. 单调:每次授予锁/提升 leader,token 严格递增;
  2. 全局可比较:同一个资源的所有写路径使用同一套 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 一个安全性小定理

定理 9.6.1 · fencing 的安全性

设资源端对每个资源维护 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,它会发现已经过期并停止;这是一个正确的实现。事故往往发生在下面几种「检查不完整」的写法里:

  1. 只在进入临界区前检查一次。后面的循环、批量 flush、异步回调都不再检查;GC 可以发生在任意两次写之间。
  2. check-then-act。在 t=9.99 检查时还剩 0.01 秒,检查通过;紧接着又发生一次 200 ms 的暂停,写发生在 t=10.19,此时租约已经过期。检查和写不是原子的。
  3. 写路径在另一个线程/协程里。主线程检查了 deadline,工作线程从队列里取出任务继续写;工作线程没有继承这个检查。
  4. 本地时钟没有前进。虚拟机被挂起、CLOCK_MONOTONIC 不包含 suspend 时间,恢复后 A 以为只过了 1 秒;它甚至不会发现租约已经过期。
  5. 续期成功但写超时。续期线程活着,锁还在;业务线程卡在下游调用上,等它返回时早已超过本地 deadline。锁没丢,但业务已经不该继续。

这五种写法里,前三种靠「更仔细地检查」可以缓解;第四种靠本地检查完全无效;第五种说明「锁还在」不等于「这次操作还该做」。fencing 能覆盖前四种:旧 token 会被拒绝。第五种需要业务层的 deadline/取消机制;如果锁仍然有效、没有新持有者,fencing 不会拒绝一个当前 token 的写。

9.7.2 加长 TTL 为什么不是解法

「把 TTL 从 10 秒加到 60 秒」是最常见的反应。它确实能把「12 秒停顿」这类事故的概率降下来,但代价和边界都很清楚:

TTL12 秒停顿60 秒停顿崩溃后锁占用适用性
10 s双持有者双持有者10 s短任务
60 s安全双持有者60 s中等任务
300 s安全安全5 min长任务,但故障恢复慢

TTL 越长,崩溃后锁被占用的时间越长,可用性越差;TTL 越短,暂停导致双持有者的概率越高。这是一个没有最优解的权衡,因为停顿的分布没有上界。 工程上能做的是:把 TTL 设在「临界区 p99.9 + 续期余量」附近,把续期做勤,把本地 deadline 检查做全,然后在资源端加 fence。四件事缺一不可。

例 9.7.1 · 一次「检查过」的过期写

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 foundetcd 健康/时钟
锁前缀下 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.lag0–1 秒持续大于 5 秒从库落后,切换后可能没有锁 key
master_link_status(在从库上)updown从库数据陈旧,不能作为切换依据
db0.expires大于 0(有 TTL 的 key)0 但有锁 key锁没有 TTL,检查 TTL == -1
evicted_keys0大于 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 争点拆解

争点Kleppmannantirez工程落点
进程暂停无上界,必须防是客户端故障,锁算法不负责资源端 fence
时钟有效期依赖时间;时钟跳变/挂起会让判断失真漂移有界,drift 项留了余量单调时钟 + 余量,但仍不解决挂起
定位不能作为正确性原语在声明的假设内可安全发放租约;正确性需 fencing按后果选型
fencing必需需要绝对安全时必需扣款/库存必须
5 个 master不增加正确性,只增加可用性提高可用性,减少单点可用性 ≠ 正确性

这张表说明,两人并不是在同一个问题上互相否定:一个在问「能不能拿它保证不双写」,另一个在答「在声明的假设内它能安全工作」。把辩论简化成「Redis 做锁不靠谱」或「Kleppmann 不懂 Redis」,都错过了可操作的结论。

9.9.3 三条可操作的教训

  1. 把「锁的用途」写清楚。如果只是避免重复劳动,单实例 Redis + TTL + token 解锁足够;如果要保护钱/库存,锁服务本身要有 quorum,资源端要有 fence。
  2. 把「假设」写进设计文档。TTL 多长、续期多久、最大暂停多少、时钟漂移多少、资源端是否校验 token;这些不是实现细节,是协议的一部分。
  3. 用故障注入验证,而不是用文章站队。在测试环境注入 SIGSTOP、时钟跳变、主从切换,观察是否出现双写;如果没有 fence,几乎一定能复现。
事实边界

本节只引用两篇公开文章的核心论点、时间和系统背景,不复述原文长段。具体措辞、代码和后续讨论以原文为准:Kleppmann 的文章、antirez 的回应。Redis 官方文档的分布式锁页面也把 Redlock 作为一节讨论,可作为工程读本;具体结论以文档为准。

9.10 重试导致重复副作用:锁过期后的双执行

现在把 9.7 的停顿和 9.2 的过期拼成一个完整的业务事故。场景:退款 worker 用 Redis 锁保证「同一张退款单只处理一次」,锁 TTL 30 秒;下游支付网关(PSP)在高峰期一次退款调用要 45 秒。

9.10.1 失败链

时间Worker A锁PSPWorker B账本
t0获取锁,TTL 30s授予———
t1调用 refund(intent=R1)—开始处理—无记录
t30仍在等 PSP过期,删除 key仍在处理—无记录
t31仍在等 PSP——获取锁无记录
t32仍在等 PSP——读账本:无记录无记录
t33仍在等 PSP—收到第二笔 refund(R1)调用 PSP无记录
t45PSP 返回成功—第一笔成功仍在等—
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 写两条账本」,但挡不住:

  1. PSP 侧的重复扣款:账本只记录一条,但 PSP 扣了两次;需要 PSP 的幂等键或对账冲正。
  2. 重试/新持有者用了不同的 intent:A 用 R1,重试时生成了 R2,唯一索引不拦。
  3. 旁路写:另一个服务直接写账本,不经过唯一约束对应的入口。
  4. 部分成功: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

这段代码的重点不是语法,而是三个顺序:

  1. 先占位,再调 PSP。如果先调 PSP 再写本地,进程在两步之间崩溃,本地没有记录,重试会再扣一次;先占位至少留下「这个 intent 正在处理」的痕迹。
  2. PSP 超时后查询,不重发。查询也可能失败;失败就保持 pending,由后台对账按 intent_id 查 PSP,而不是生成新 ID 重试。
  3. 账本落账用 fence。旧持有者即使恢复,也不能把过期的结果写进账本;新持有者用更高的 token 完成落账。

这个状态机仍然不是「绝对一次」。它把重复窗口从「锁过期后的任意双写」缩小到「PSP 不支持幂等 + 对账失败」这类更窄、更可观测的路径。工程上要的是「可证明的边界 + 可对账的残留」,不是一句「我们加了锁」。

例 9.10.1 · 重试导致重复副作用:三次退款

退款单 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
并发更新冲突乐观锁/版本号/CASUPDATE ... 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所有写路径都经过校验吗?缓存/队列/运维脚本绕过
7token 从哪来?故障切换/恢复后会不会回退?旧 token 被接受,或新 token 被误拒
8解锁是否比较 token?误删别人的锁
9锁服务故障时,业务是降级还是拒绝?可用性与正确性没有明确取舍
10等待锁的时间上限是多少?请求堆积、线程池耗尽
11有没有幂等键/唯一约束兜底?锁失效时直接双写
12观测指标和对账手段是什么?出事无法定位、无法补偿
锁评审的五分钟版

如果时间只够问五个问题,问这五个:

  1. 锁过期后,旧持有者还能写吗?资源端会不会拒绝?
  2. 续期失败时,业务代码走哪条分支?会不会继续写?
  3. 锁服务主从切换/恢复后,token 会不会回退?
  4. 有没有一条写路径绕过了 fence?
  5. 如果明天锁服务全挂,业务是拒绝服务还是双写?

9.11.5 反模式

  • 锁包住网络调用:临界区里调下游,下游慢导致锁过期,双持有者。把网络调用移到锁外,或者用状态机/异步。
  • 没有 TTL 的锁:崩溃后永久占用;TTL == -1 必须告警。
  • 有 TTL 但没有续期:长任务必然过期;要么续期,要么把任务拆短。
  • 无条件 DEL 解锁:误删别人的锁;必须 Lua 比较 token。
  • 一把全局锁:所有租户/资源共用,吞吐和爆炸半径都不可控;按资源分 key。
  • 把锁当队列:用 while not acquire(): sleep() 排队,浪费连接和 CPU;用 etcd/ZK 的等待队列或消息队列。
  • 用锁掩盖竞态:本该用唯一索引/幂等/事务的地方加锁,锁失效后问题依旧。
  • 只监控 acquire 成功:不看续期失败和 fence 拒绝,等于只看「门有没有开」,不看「有没有人从窗户进来」。

9.12 本章陷阱清单

14 条高频错误
  1. SET NX 成功就以为安全:它只证明「写入时 key 不存在」,不证明「写资源时锁还有效」;暂停、主从切换、驱逐都会让锁失效。
  2. 解锁直接 DEL:锁过期后可能删掉别人的锁;必须用 Lua 比较 token 再删。
  3. TTL 没有续期,或续期失败后继续写:长任务必然过期;续期失败必须停止业务,而不是重试到成功。
  4. 续期线程和业务共享同一个 GC:Full GC 会同时暂停两者;独立 sidecar 能续期,但可能掩盖业务卡死,仍需 fence。
  5. 以为主从复制不会丢锁:Redis 复制是异步的,WAIT 只缩小窗口;故障切换可能提升一个没有锁 key 的从库。
  6. 把 Redlock 当共识:多数派获取成功不等于线性一致;Redlock 买到的是可用性,不是正确性。
  7. 用时间戳或 INCR 当 fence:时钟会回拨,Redis 主从切换会让计数器回退;token 必须跨故障切换单调。
  8. 资源端只检查 token 存在,不比较大小:等于没有 fence;必须记录 max_fence 并拒绝更小的 token。
  9. check-then-act:先 SELECT fence 再 UPDATE,两个请求可能都通过检查;比较和写入必须在同一条语句/事务里。
  10. 只 fence 写路径:缓存、消息队列、运维 SQL、另一个服务都可能绕过;资源端必须是唯一写入口。
  11. TTL 小于下游 p99.9,或把网络调用放在锁内:锁在等下游时过期,双持有者;把慢调用移出临界区。
  12. 锁 key 粒度太粗/跨租户:一把全局锁让所有请求排队,爆炸半径不可控;按资源/租户分 key。
  13. 把锁当队列或重试机制:无限等待、无限重试会耗尽线程/连接;用等待队列、退避和超时。
  14. 只看 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_fence9.6
画出 GC 停顿时间线标出 t0/t10/t12 与两个 token9.7
用命令看租约etcdctl lease timetolive 或 redis-cli INFO 能读出异常9.8
说出 Redlock 辩论的结论不是「谁赢」,而是「按后果选型 + fence」9.9
拆一次双执行能画出锁过期 + PSP 慢 + 重试的完整链9.10
判断该不该用锁能给出至少三种替代方案9.11
即时自测 · 9

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 怎么串、指标怎么聚合、日志怎么关联,以及为什么「有监控」不等于「能定位」。