租约到期时,持有者可能只是暂停,并没有真正停止。旧进程恢复后,仍会发送迟到写入。存储服务需要一种方法拒绝已经被取代的所有权;围栏令牌提供有序的所有权世代编号,并随每次变更传递。保护来自资源侧实际检查,而不是锁服务仅仅发出了一个数字。理解这个执行位置,才能判断旧主是否真的被挡住。

光有锁还不够

客户端可以先检查租约,然后因垃圾回收暂停,等另一个客户端取得资源后才恢复。即使在发送前立即检查,也无法消除检查与使用之间的空隙。网络延迟同样可能让已经发送的请求很晚才到达。资源必须在操作真正到达时决定是否接受,不能只相信客户端记忆中的持有状态仍然有效。

租约超时是故障检测机制,不证明先前进程已经停止。旧进程的凭据可能仍能通过身份认证,但它的所有权已经过期。因此应区分身份认证、权限与所有权世代。围栏不替代访问控制,也不能允许恶意客户端随意声称一个极大的编号,把合法所有者永久挡在外面。合法编号的来源同样属于协议需要保证的边界。

把递增编号绑定到所有权

协调者在取得所有权时发出递增令牌。暂停的持有者仍只有旧编号,不能在没有重新取得所有权时,随便再申请一个新数字继续写。发号序列必须在故障后避免回退和复用;墙上时钟、分区节点各自维护的计数器,都不会自动具备这个契约。时间戳看起来更大,不等于它证明了有效的所有权交接。

被保护资源保存已接受的最大世代,拒绝更小编号,接受更大编号时推进高水位。比较和对应的数据变更必须在同一个原子、持久化边界内完成。如果先单独检查,再无条件写入,就重新制造了竞争窗口。复制资源还需要一致的执行边界,不能让每个副本独立比较、各自接受不同所有者,之后再声称全部操作已经被围栏保护。

相等编号需要单独定义策略

一个租约持有者通常会在同一世代里执行多次写入。如果拒绝所有等于高水位的编号,该持有者就只能成功写一次。常见的所有权世代规则拒绝较小值,相等值仍可按应用正常权限处理。每次操作都使用严格递增编号,是另一种协议,不能与每次取得所有权才发一枚令牌的机制混为一谈。

相等编号也不能说明某次操作是否重复。需要防止重试重复产生效果时,应使用操作标识、幂等更新或其他明确的顺序规则。围栏区分不同所有权世代,不会自动排列同一世代的全部操作,也不会自动去重,更不能单独保证业务效果恰好发生一次。把这些责任写清楚,可以避免用一个编号替代所有并发控制。

坏掉的样子

发出新令牌,不会立即通知所有被保护资源。在资源尚未见过新世代之前到达的旧写入,仍可能通过高水位检查。如果契约要求取得新所有权的一刻就立即撤销旧权限,需要在允许后续工作前,通过合适的协调步骤把新世代建立到资源侧。普通围栏不能描述成对全部在途请求的即时取消机制,测试应保留这个先后顺序边界。

计数器回退可能让新所有者拿到比旧持有者更小的编号。资源丢失持久化高水位,也可能重新接受旧世代。恢复流程必须同时保全发号序列与资源状态;只把一端恢复到旧备份,却不协调所有权,可能破坏原来的假设,即使两个组件都能正常启动。组件存活与协议仍安全,是不同判断。

缺失或格式错误的令牌不能悄悄绕过检查。检查每一条变更入口,包括维护任务和兼容客户端。不能执行令牌比较的文件服务或外部 API,需要另一种受支持的并发控制机制;仅在请求头中放一个数字,并不会保护实际副作用。比较必须位于真正拥有写入权的资源边界,而不是只存在于调用方日志中。

快速验证

把下面标准库 Python 示例保存为 fencing.py 后运行。它是顺序执行的内存模型,不是持久锁服务,也不是并发存储实现;没有外部服务器、系统时钟或真实凭据参与,所有状态都只存在于当前程序中。

class StaleFenceError(RuntimeError):
    pass

class FencingResource:
    """Sequential model: equal tokens permit more writes in the same epoch."""
    def __init__(self):
        self.last_seen = 0
        self.data = None

    def write(self, token, value):
        if type(token) is not int or token <= 0:
            raise StaleFenceError("missing or invalid fence token")
        if token < self.last_seen:
            raise StaleFenceError(f"stale token {token} < {self.last_seen}")
        self.last_seen = token
        self.data = value
        return True

class Issuer:
    """Test counter; a real issuer must bind durable epochs to ownership."""
    def __init__(self):
        self.counter = 0

    def acquire(self):
        self.counter += 1
        return self.counter

def refused(resource, token):
    before = (resource.last_seen, resource.data)
    try:
        resource.write(token, "must-not-land")
    except StaleFenceError as error:
        assert (resource.last_seen, resource.data) == before
        print("rejected:", error)
    else:
        raise AssertionError("invalid or stale write was accepted")

issuer = Issuer()
old_leader, new_leader = issuer.acquire(), issuer.acquire()
resource = FencingResource()
# Issuance alone has not informed this resource about epoch 2.
assert resource.write(old_leader, "arrived-before-new-epoch")
assert resource.write(new_leader, "from-new-leader")
refused(resource, old_leader)
assert resource.write(new_leader, "second-write-in-same-epoch")
assert resource.last_seen == 2
refused(resource, None)
refused(resource, "2")
refused(resource, True)
next_token = issuer.acquire()
assert resource.write(next_token, "newest")
refused(resource, new_leader)
assert (resource.last_seen, resource.data) == (3, "newest")
print("PASS: observed newer epochs fence older writes; equal epoch remains usable")

首次旧令牌写入成功,因为资源还没有观察到第二世代。第二世代写入后,第一世代被拒绝,已存数据和高水位都不改变。同一个第二世代随后还能再写,说明一位所有者可以合法进行多次操作。缺失值、字符串和布尔值被拒绝;第三世代到来后,第二世代也成为旧值,不能继续修改资源。

最后的 PASS 只确认这些比较分支,不验证持久化、原子事务、协调者授权、分区恢复或网络时序。把模型转为集成测试时,必须保留这些限制,并让生产资源在真正发生副作用的位置执行相同所有权边界。仅让演示断言通过,不能宣布一个尚未接受并发与恢复测试的分布式系统已经安全。

延伸阅读

来源