单个写入者很简单;多个写入者才是 bug 的温床。锁与租约是两个经典答案,而分清它们,决定了你的系统能否在持有者崩溃后恢复。
锁:一个你放手才失去的权利 锁授予某一进程独占的写权,一直持有到它主动释放为止。在单个进程内这既简单又确定,但一旦跨越多机,就会出现一个致命的失效模式:持有者崩溃或卡死时,解锁代码永远不会执行,所有等待者只能永远等下去。普通锁连超时都没有,「失败之后由谁解开」必须由外部机制补上,否则一台死节点就能把共享资源拖下线。锁只能回答「现在谁能写」,却回答不了「可以写多久」。
租约:带时限的权利 租约授予同样的写权,但这次权利带一个时间上限:到期不续就会被回收,崩溃的持有者因此无法永远堵住别人。租约由协调者给出一个截止时间,持有者在过期前续期,一旦过期,其他写方即可接手。代价是一个极窄的竞态窗口——慢速或已被分区的持有者,会误以为租约还在自己手上,而另一个写方已经开始写入。这正是续签与 fencing(围栏)存在的意义:租约代码在每次写入之前,都要重新核实自己是否仍然有权。
哪个合适 单进程里用进程内锁就够,而且最正确——旁边没有另一个独立的持有者问你撞车。跨进程就要用租约,因为它把最坏等待时间限定住了。你真正要决定的,是「一台死节点来了怎么办」: 用普通锁就得自己加超时,否则永远卡死;用租约,这个上限是内置的。所以,当你可能遇到死节点、又希望自动恢复时,选租约,并把续签与 fencing 当作协议的第一类组件来设计。
续约决定真正的保证 租约不是取一次的时间戳,而是一个要不断自证的循环。存活时间决定恢复上限:十秒租约把一次崩溃的影响封顶在十秒左右,十分钟租约则把每次崩溃都变成十分钟等待。持有者必须在到期前续约,通常以三分之一存活时间为周期,丢一次心跳也还有重试机会。铁律只有一条:续约失败就停写。先暂停任务、取消在途写入,重新拿到租约再碰资源。每次写入都要同时复核持有者身份、截止时间和围栏号,因为上次续约之后租约可能已经易主。本文与 fencing-token 那篇分工不同:那边讲存储侧如何在交接后拒绝旧写者,这里讲交接何时可以发生,以及旧持有者到期后还欠系统什么动作。
坏掉的样子 过期任务仍在跑是最经典的租约故障。一次垃圾回收停顿,或者一个运行时间超过租约的任务,会让协调者把租约发给写者乙,而写者甲还以为自己持有租约。双方同时写入,最后写入者悄悄覆盖前者。对外表现为间歇性覆盖,却看不到任何锁争用指标。
时钟偏斜会在没有任何卡顿的情况下制造同一个双持有。当甲的时钟偏快,甲会在协调者已经过期并转授给乙之后,仍然认为自己的租约有效。授时台阶、虚拟机迁移、被冻结后恢复的容器都是常见诱因。对策是缩短租约并限定整个系统的时钟不确定度,而不是信任任何一方的本地时钟。
最安静的故障是悄悄死掉的续约循环。心跳线程崩溃,或者网络抖动让每次续约都失败,而主线程照常处理请求。日志看起来一切健康,但过期之后每一次写入都落在了别人的任期里。把续约失败都当作持有者的致命事件:停下任务,重新获取。
释放与时长取值收尾。不释放会把每次优雅交接拖成完整存活时间的等待,释放两次则可能把已经不属于自己的租约放掉。存活时间远小于续约延迟时,所有权会在做完有用工作之前来回抖动。
快速验证 把下面存成 lease.py,执行 python3 lease.py。它只用标准库,以整数模拟时钟,不安装任何东西,时间也不会抖动。一个 LeaseStore 实现 acquire、renew 与 release,write 同时校验过期与获取时下发的围栏号:
class LeaseLostError(RuntimeError): pass class LeaseStore: """极简租约协调者,时钟为手动推进的整数秒。""" def __init__(self): self.holder = None self.expiry = 0 self.fence = 0 self.data = None def acquire(self, owner, ttl, now): if self.holder is None or now >= self.expiry: self.holder = owner self.expiry = now + ttl self.fence += 1 return self.fence return None def renew(self, owner, ttl, now): if self.holder == owner and now < self.expiry: self.expiry = now + ttl return True return False def release(self, owner): if self.holder == owner: self.holder = None return True return False def write(self, owner, token, value, now): if self.holder != owner or now >= self.expiry: raise LeaseLostError(f"lease expired for {owner} at t={now}") if token != self.fence: raise LeaseLostError(f"stale token {token} != {self.fence}") self.data = value return True store = LeaseStore() token_a = store.acquire("A", ttl=5, now=0) assert token_a == 1, token_a assert store.write("A", token_a, "a-1", now=2) is True assert store.renew("A", ttl=5, now=4) is True # 截止时间从 5 移到 9 assert store.write("A", token_a, "a-2", now=8) is True try: store.write("A", token_a, "a-stale", now=10) except LeaseLostError as error: print(f"rejected expired write: {error}") else: raise AssertionError("expired lease write was accepted") assert store.renew("A", ttl=5, now=10) is False # 过期后再续约必然失败 token_b = store.acquire("B", ttl=5, now=10) assert token_b == 2, token_b assert store.write("B", token_b, "b-1", now=11) is True try: store.write("B", token_a, "b-wrong-token", now=12) except LeaseLostError as error: print(f"rejected wrong fencing token: {error}") else: raise AssertionError("wrong-token write was accepted") try: store.write("A", token_a, "a-older-term", now=12) except LeaseLostError as error: print(f"rejected stale holder: {error}") else: raise AssertionError("stale holder write was accepted") assert store.acquire("C", ttl=5, now=12) is None # 乙仍持有,丙拿不到 assert store.release("A") is False # 只有持有者能释放 assert store.release("B") is True token_c = store.acquire("C", ttl=5, now=13) assert token_c == 3, token_c assert store.write("C", token_c, "c-1", now=14) is True assert store.data == "c-1" print("PASS: renew extended the lease; expired, wrong-token, and stale writes rejected") 首轮写入持一号成功,续约把截止时间从五移到九,时刻十的写入因过期被拒。乙以二号接管并写入成功,错号与甲的迟到写入各被拒绝一次,乙释放后丙以三号干净接管。预期输出如下:
...