锁与租约用于协调共享资源访问,但具体故障行为取决于实现。本文关注互斥写入,锁本身也可以有共享模式;租约则给有效权限增加期限。熟悉的名字或一个超时值,都不能单独证明延迟写入者在交接之后不会破坏数据。设计前应明确保护什么资源、由谁执行拒绝,以及失败之后如何结束所有权。

锁有明确的生命周期

持有者崩溃后,锁并不一定永远留下。PostgreSQL 会话级咨询锁会在会话结束时释放,事务级咨询锁会在事务结束时释放。远程连接已经坏掉,服务器却尚未发现,可能导致等待持续一段时间,但这与完全没有清理机制不同。等待获取锁的超时,也不同于已经取得的所有权自行到期,不能把两种时间限制混为一谈。

单进程内互斥锁可能很合适,但死锁和线程停顿仍然存在。跨进程操作也可能更适合操作系统锁或数据库锁,而不是应用租约。应根据资源、所有权范围与恢复语义选择,不能套用“只要分布式就必须有租约”的规则,也不能因为线程属于同一进程就假定持有者永远不会失去进展。

咨询锁只约束遵守协议的参与者。忽略锁的代码仍可能访问它本来有权限触碰的资源。数据库对数据操作强制执行的锁,与外部工作者约定的主身份,具有不同的执行点。需要明确究竟哪个组件阻止冲突操作,不能仅依赖每个调用方在出错、暂停和重启之后都继续自觉遵守规则。

租约允许稍后交接

协调者可以授予截止时间之前的权限、接受及时续约,并在过期后允许另一轮获取。这样能够在持有者消失后恢复,而不必等它主动释放。然而,过期不会停止已经暂停的进程、撤回已经发送的请求,也不会取消另一项服务正在执行的写入。权限失效与旧工作实际结束之间,仍然可能存在很长的间隔。

每次写入前检查所有权,仍留下检查与效果之间的窗口。围栏协议让资源端参与,拒绝过时的获取代次。如果独立资源只记住自己已经见过的最大令牌,那么旧令牌是在更新令牌到达该资源后才被识别为过时;这个机制本身不会在所有租约刚过期的瞬间就知道它已经无效。不能把收到新代次后的拒绝,夸大为实时租约校验。

续约和释放也需要携带每次获取独有的身份。拥有者名称可能复用:工作者 B 上一次获取留下的延迟释放,不能释放 B 后来取得的新租约。Redis 按值比较后删除的模式展示了这种获取身份检查,但随机所有权值并不自动具有大小顺序,也不能直接当成严格递增的围栏号。

续约存在不确定性

续约余量应根据调度延迟、网络延迟、暂停以及协议的时钟假设选择。每个 TTL 的三分之一续一次,可以是一项运维选择,却不是正确性定理。十秒租约也不保证服务一定在十秒内完全恢复;协调者不可用、重试、调度和资源恢复都会增加额外时间,应分别测量这些等待,而不是把 TTL 直接写成故障恢复上限。

续约超时意味着结果可能未知,不一定证明协调者拒绝续约。一旦无法建立安全权限,持有者应停止发起新的工作;但尝试取消,不证明已经在途的效果也停下了。重新获取必须取得新身份,资源端仍需执行相应的旧请求拒绝规则。否则客户端看起来已经恢复正常,却可能仍有旧工作迟到并覆盖结果。

坏掉的样子

旧任务可以在另一持有者开始后恢复执行,如果存储接受旧请求,它仍然可能覆盖新数据。客户端检查尤其怕发生在检查刚通过之后的暂停。实验应在真正执行效果的位置重现迟到写入,而不只是观察客户端计时器报过期,否则最重要的写入竞争仍未被覆盖。

时钟比较需要明确模型。墙钟跳变、走时速率差异与不同时间原点,都可能破坏截止时间计算。本地时钟偏快不必然延长租约;若双方比较同一个绝对截止值,偏快反而可能使它提前过期。单调经过时间可以避免墙钟跳变,却不自动消除所有协议时序假设。缩短 TTL 也不是普遍补救办法,还可能增加误判与反复交接。

续约循环静默失败、TTL 太短、遗漏释放和延迟重复释放影响不同状态转换,应分别测试。每轮获取都保存对应令牌,不要把可复用的拥有者字符串当作充分授权。应用取消与租约失效同样是两种操作,需要分别确认它们对已有工作的影响,而不能一条日志写着取消就宣布所有效果已经停止。

快速验证

保存为 lease.py,运行 python3 lease.py。顺序模型把协调者和资源合在一个对象里,使用手动推进、不可后退的时钟。在每次模型写入时,所有权、期限与代次和写入本身一起检查。这个契约故意强于只记录已收到令牌的独立资源,因此不能把模型的立即过期检查当成普通分布式围栏系统已经提供的功能。

class LeaseLostError(RuntimeError):
    pass


class LeaseStore:
    """Serial, co-located coordinator and resource; one manual clock."""
    def __init__(self):
        self.holder, self.expiry, self.fence, self.data = None, 0, 0, None
        self.clock = 0

    def observe(self, now):
        if now < self.clock:
            raise ValueError("clock moved backwards")
        self.clock = now

    def acquire(self, owner, ttl, now):
        self.observe(now)
        if ttl <= 0:
            raise ValueError("TTL must be positive")
        if self.holder is None or now >= self.expiry:
            self.holder, self.expiry = owner, now + ttl
            self.fence += 1
            return self.fence
        return None

    def valid(self, owner, token, now):
        return self.holder == owner and token == self.fence and now < self.expiry

    def renew(self, owner, token, ttl, now):
        self.observe(now)
        if ttl <= 0:
            raise ValueError("TTL must be positive")
        if not self.valid(owner, token, now):
            return False
        self.expiry = now + ttl
        return True

    def release(self, owner, token, now):
        self.observe(now)
        if not self.valid(owner, token, now):
            return False
        self.holder = None
        return True

    def write(self, owner, token, value, now):
        self.observe(now)
        if not self.valid(owner, token, now):
            raise LeaseLostError("expired, wrong owner, or stale acquisition")
        self.data = value


def rejected(store, owner, token, now):
    before = store.data
    try:
        store.write(owner, token, "forbidden", now)
    except LeaseLostError:
        assert store.data == before
    else:
        raise AssertionError("invalid write accepted")


store = LeaseStore()
a = store.acquire("A", 5, 0)
assert a == 1
store.write("A", a, "a-1", 2)
assert store.renew("A", a, 5, 4) and store.expiry == 9
store.write("A", a, "a-2", 8)
rejected(store, "A", a, 9)  # Equality is expired.
assert not store.renew("A", a, 5, 9)
b = store.acquire("B", 5, 10)
assert b == 2
store.write("B", b, "b-1", 11)
rejected(store, "B", a, 12)
rejected(store, "A", a, 12)
assert store.acquire("C", 5, 12) is None
assert not store.release("A", a, 12)
assert store.release("B", b, 12)
new_b = store.acquire("B", 5, 13)
assert new_b == 3
assert not store.release("B", b, 13)
assert not store.renew("B", b, 5, 13)
store.write("B", new_b, "new-b", 14)
assert store.release("B", new_b, 14)
c = store.acquire("C", 5, 14)
assert c == 4
store.write("C", c, "c-1", 15)
assert store.data == "c-1"
for operation in (lambda: store.acquire("D", 0, 15),
                  lambda: store.renew("C", c, -1, 15),
                  lambda: store.write("C", c, "backwards", 14)):
    try:
        operation()
    except ValueError:
        pass
    else:
        raise AssertionError("invalid TTL or backwards time accepted")
print("PASS: renewal, expiry, fencing, same-owner reacquisition, and input guards")

预期出现 PASS 行。续约把截止时间从五推进到九,恰好等于九就已过期;错误拥有者与旧令牌都不能改动数据。B 随后用新令牌重新获取,携带旧令牌的迟到释放和续约均失败。零或负 TTL、后退时间也被拒绝。这些负例明确检查了交接和同名重新获取,而不是只验证正常释放路径。

两种语言的修订示例均已在 Linux、Python 3.12.14 下通过。证据仅覆盖顺序模型状态转换,没有验证网络共识、持久围栏、真实时钟、PostgreSQL 锁、Redis 可用性或生产并发。应保留这些边界,避免将小型确定性模型的成功误写成整个跨机器协议的证明。

延伸阅读

来源