租约到期时,持有者可能只是暂停,并没有真正停止。旧进程恢复后,仍会发送迟到写入。存储服务需要一种方法拒绝已经被取代的所有权;围栏令牌提供有序的所有权世代编号,并随每次变更传递。保护来自资源侧实际检查,而不是锁服务仅仅发出了一个数字。理解这个执行位置,才能判断旧主是否真的被挡住。
光有锁还不够
客户端可以先检查租约,然后因垃圾回收暂停,等另一个客户端取得资源后才恢复。即使在发送前立即检查,也无法消除检查与使用之间的空隙。网络延迟同样可能让已经发送的请求很晚才到达。资源必须在操作真正到达时决定是否接受,不能只相信客户端记忆中的持有状态仍然有效。
租约超时是故障检测机制,不证明先前进程已经停止。旧进程的凭据可能仍能通过身份认证,但它的所有权已经过期。因此应区分身份认证、权限与所有权世代。围栏不替代访问控制,也不能允许恶意客户端随意声称一个极大的编号,把合法所有者永久挡在外面。合法编号的来源同样属于协议需要保证的边界。
把递增编号绑定到所有权
协调者在取得所有权时发出递增令牌。暂停的持有者仍只有旧编号,不能在没有重新取得所有权时,随便再申请一个新数字继续写。发号序列必须在故障后避免回退和复用;墙上时钟、分区节点各自维护的计数器,都不会自动具备这个契约。时间戳看起来更大,不等于它证明了有效的所有权交接。
被保护资源保存已接受的最大世代,拒绝更小编号,接受更大编号时推进高水位。比较和对应的数据变更必须在同一个原子、持久化边界内完成。如果先单独检查,再无条件写入,就重新制造了竞争窗口。复制资源还需要一致的执行边界,不能让每个副本独立比较、各自接受不同所有者,之后再声称全部操作已经被围栏保护。
相等编号需要单独定义策略
一个租约持有者通常会在同一世代里执行多次写入。如果拒绝所有等于高水位的编号,该持有者就只能成功写一次。常见的所有权世代规则拒绝较小值,相等值仍可按应用正常权限处理。每次操作都使用严格递增编号,是另一种协议,不能与每次取得所有权才发一枚令牌的机制混为一谈。
相等编号也不能说明某次操作是否重复。需要防止重试重复产生效果时,应使用操作标识、幂等更新或其他明确的顺序规则。围栏区分不同所有权世代,不会自动排列同一世代的全部操作,也不会自动去重,更不能单独保证业务效果恰好发生一次。把这些责任写清楚,可以避免用一个编号替代所有并发控制。
坏掉的样子
发出新令牌,不会立即通知所有被保护资源。在资源尚未见过新世代之前到达的旧写入,仍可能通过高水位检查。如果契约要求取得新所有权的一刻就立即撤销旧权限,需要在允许后续工作前,通过合适的协调步骤把新世代建立到资源侧。普通围栏不能描述成对全部在途请求的即时取消机制,测试应保留这个先后顺序边界。
计数器回退可能让新所有者拿到比旧持有者更小的编号。资源丢失持久化高水位,也可能重新接受旧世代。恢复流程必须同时保全发号序列与资源状态;只把一端恢复到旧备份,却不协调所有权,可能破坏原来的假设,即使两个组件都能正常启动。组件存活与协议仍安全,是不同判断。
缺失或格式错误的令牌不能悄悄绕过检查。检查每一条变更入口,包括维护任务和兼容客户端。不能执行令牌比较的文件服务或外部 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 只确认这些比较分支,不验证持久化、原子事务、协调者授权、分区恢复或网络时序。把模型转为集成测试时,必须保留这些限制,并让生产资源在真正发生副作用的位置执行相同所有权边界。仅让演示断言通过,不能宣布一个尚未接受并发与恢复测试的分布式系统已经安全。
延伸阅读
来源
- Redis 分布式锁,围栏建议与分布式锁限制,已核验 2026-10-15。
- 如何做分布式锁,取得所有权时递增的令牌与资源侧拒绝旧世代,已核验 2026-10-15。