17 · 部分失败、时钟与过期持有者
目标:用确定性故障时序检验过期任务是否还能覆盖新结果。
还活着与还有权限是两件事
分布式系统的部分节点会慢、会暂停,网络会丢请求或丢响应。超时只能表示当前没有及时得到结果,不能区分远端已执行、未执行或仍在执行。TCP 的可靠字节流也不等于应用操作恰好执行一次。
墙上时钟描述日期时间,可能被校准或跳变;单调时钟适合测量同一进程环境中的经过时间,却不是跨机器共同时间轴。逻辑时钟追踪顺序而不表示真实时刻。把这些用途混在一起,会让租约和排序悄悄依赖错误假设。
假设日报任务 A 获得租约后长暂停,B 接任并写完,A 恢复后仍拿着旧授权。仅在 A 开始时检查租约不够:检查与写入之间仍可能暂停。Fencing token 为每代持有者分配递增令牌,由最终存储端拒绝旧代写入。检查必须发生在效果落地的一侧。
穷举两个任务的到达顺序
保存 failure_models.py。先预测新任务先到时,旧任务能否覆盖结果。
from itertools import permutations
def execute(deliveries, fenced):
highest = 0
value = None
for token, payload in deliveries:
if fenced and token < highest:
continue
highest = max(highest, token)
value = payload
return value
old = (7, "stale-report")
new = (8, "fresh-report")
assert execute([new, old], False) == "stale-report"
assert execute([new, old], True) == "fresh-report"
for schedule in permutations([old, new]):
assert execute(schedule, True) == "fresh-report"
print("without_fencing:", execute([new, old], False))
print("with_fencing:", execute([new, old], True))
print("schedules_checked:", 2)
uv run --python 3.12 --no-project failure_models.py
预期输出:
without_fencing: stale-report
with_fencing: fresh-report
schedules_checked: 2
令牌必须由可靠授权机制发放,最高已接收令牌必须在存储端可靠保存。代码允许同一令牌继续写,适合一代中的多次更新;同代操作的去重与顺序还要另行约束。
Fencing 也不是瞬间撤销通知:存储尚未看到令牌 8 时,令牌 7 可能仍被接受。它保证的是新一代效果落地之后,旧一代不能倒灌。若要求租约失效的瞬间所有旧写都禁止,需要额外的存储端授权协议。
引导练习:① 再加令牌 9 的 newest-report;② 对三个操作的全部排列执行同一检查;③ 应验证 6 个顺序,最终均为 newest-report。此时把打印的数量改为排列数而非固定 2。
测试的是安全性还是活性
“旧报告不能覆盖新报告”是安全性,违反时可展示一段坏时序;“最终生成报告”是活性,需要说明网络恢复、调度机会等条件。无限网络延迟下不能无条件承诺按时完成。崩溃故障与拜占庭故障也不同:后者允许节点发送错误甚至矛盾的信息,本课模型没有容忍恶意节点。
确定性模拟把消息、时钟和调度交给测试控制,失败就能重放;模型检查探索的是形式模型,故障注入则向运行系统施加故障。三者互补。枚举六个排列只验证这个小模型,不能证明真实服务正确。
独立练习:存储进程重启后把 highest 清零,再收到令牌 7。会发生什么,应该持久保存什么?
参考答案
旧写会再次被接受,保护失效。最高令牌应与受保护资源一起持久化并原子检查更新,不能只是进程内变量。若多个副本分别维护不同的最高令牌,还需保证写入路径不会绕过较新令牌;要检查整个复制协议,不只检查客户端。
结果偶发失败时先记录完整消息顺序,再固定重放;重启后才失败时优先查持久状态。在 design.md 为日报重建任务添加旧持有者恢复这一故障用例及预期行为。