分布式系统 00:系统模型、故障时间线与先修自测
客户端发出 Put(x, 1),等待超时。服务端究竟有没有修改 x?只凭这条错误,无法判定。请求可能没到,也可能已经执行,只是回复没有回来。工程上需要重试、查询或人工核对,理论上需要先写清楚:哪些事件发生了,谁能观察到这些事件,协议允许哪些执行。
分布式系统的学习从这个区别开始。运行在不同进程里的代码通过消息合作,每个进程只掌握局部状态;一项业务动作可以跨越多个故障边界。正确性不能只根据正常路径上的最终输出判断。
这是系列的入口篇。验收目标是根据三个节点和客户端的时间线,区分确定发生、仍有可能和无法判定的结果;配套 Go 程序重放有限故障组合,不实现共识协议。
从课程结构到实验路径
MIT 6.5840 Spring 2026把 RPC、存储和复制实验逐步连起来;Stanford CS244B Spring 2024更突出原论文讨论与研究。两份课表覆盖事务、分布式计算和信任模型,学习范围远大于 Paxos、Raft、ZooKeeper 与 etcd。
系列按先修关系重新排列:先获得分析请求、状态和故障的语言,再实现任务调度、KV、复制与分片,随后讨论协调服务、事务、缓存、计算系统和拜占庭故障。00–07 的系统编程桥接是本系列自行设计的,不代表两校官方本科课程。代码使用 Go 标准库,题目独立设计,不复刻课程作业解答。
已有的 《AWS 的分布式系统相关挑战》 讨论请求与回复之间的不确定性;《数据密集型应用系统设计》读书笔记 提供更广的存储背景。本系列增加模型、推导和实验验收;旧文不作为未经复核的协议定义。
一个请求经过哪些状态
设客户端为 K,三个服务节点为 A、B、C,每个节点最初都有 x=0。K 向 A 发送唯一的一次 Put(x,1)。这里 A 只是指定的接收者,没有经过领导者选举;B、C 在观察期间收不到复制消息,仍可回答自己的本地读。
节点状态用两个变量表示:memory 是当前进程能读取的值,stable 是模型规定能跨重启保存的值。初始时两者均为 0。处理写请求只把 memory 改成 1;执行同步动作才把它复制到 stable。重启动作销毁易失状态,并从 stable 恢复 memory。
这是一个刻意简化的存储模型。没有写入撕裂、文件名、目录项、缓存层和设备损坏,也没有把某个真实系统调用认定为永远可靠。stable=1 是模型中的前提,稍后的实验只检验建立在这个前提上的程序行为。
1 | |
图中从上到下表示这次构造执行的顺序,不能把不同机器日志上的墙上时钟直接当成这样的全局顺序。节点内部的动作先后、消息发送先于接收,能提供因果约束;没有消息关联的两个事件未必可比较。第 05 篇会用偏序和逻辑时钟精确表示这种关系。
这里至少有四个不同事实:请求被处理,结果进入稳定存储,服务端发送成功回复,客户端收到成功回复。哪个事实足以返回业务成功,取决于 API 的承诺。单机持久化接口可以要求同步完成后再回复;复制服务还必须定义副本确认和恢复规则,不能把这四个阶段全部叫作“提交”。
故障模型决定允许讨论什么
进程停止与进程恢复
崩溃停止模型允许进程停止,之后不再执行动作。崩溃恢复模型允许它重新启动,但恢复时哪些状态保留必须说明。把投票、日志或请求去重记录只放在内存里,与持久化它们,会得到不同的协议性质。
本篇只使用第二种模型:A 最多发生一次恢复,磁盘抽象保持不变。B、C 没有自动接管职责。三个节点同时存在,并不会自动产生选举、复制或容错能力;代码必须实际实现这些机制。
实际故障可能相关。同一主机上的三个进程会共享宿主机停机风险,同一机房中的三台服务器也可能共享供电或网络故障。一个模型允许“最多一个节点崩溃”,并不意味着现实一定遵守这一限制。部署拓扑需要解释这个故障上限为何合理。
消息延迟与超时
异步模型不给处理速度和消息传输延迟设置已知上界。到某个有限时刻没有收到回复,既可能是节点停止,也可能是节点仍在执行或者消息仍在传输。延长超时能改变等待成本,不能凭空增加一个原本不存在的时延上界。
FLP 论文在其模型中保留可靠消息传递,仍然存在这类不可区分性。因此“异步”和“可以丢消息”必须分别声明;不能用丢包解释所有异步系统困难。本篇为了演示请求不确定性,额外允许丢弃请求或回复。它并不是 FLP 原模型的完整实现。Fischer、Lynch、Paterson,1985,模型部分
同步模型允许利用规定的处理和传输上界。部分同步可以假定:经过一个未知的稳定时刻后,系统开始满足既定时限。Dwork、Lynch、Stockmeyer,1988实际系统据此设计故障怀疑和恢复机制,但“超过经验上的 200 毫秒”与“违反已证明的模型上界”是两回事。第 08 篇会详细讨论部分同步与故障检测。
本篇程序没有真实计时器。TIMEOUT 表示在选定观察截止点之前没有成功回复;被丢弃和晚于截止点到达,在这段客户端观察中都可以表现为无回复。程序没有枚举截止点之后迟到请求的行为。
持久化边界
应用修改内存、操作系统接受写入、设备报告同步完成,是不同阶段。Linux 的 fsync(2)还明确区分文件同步与父目录项同步,并提供错误返回。这个 Linux 契约不能直接替代 macOS 的实现说明。
SQLite 的原子提交文档把底层同步行为列为所依赖的假设;ATC 2020 关于 fsync 失败恢复的研究则说明错误处理路径值得单独验证。真实持久化实验必须写明操作系统、文件系统、同步方式和故障类型。本机正常退出后重启成功,不能证明突然断电也能恢复。
后续 KV 实验会接入文件与真实进程。本篇只把持久化作为显式状态转换,用来观察“何时确认成功”对恢复结果的影响。
相同的客户端观察,不同的系统结果
请求未到与回复丢失
第一条执行中,K 发出的请求被丢弃。A 没有执行任何写入,三个节点都保留 0,K 等到超时。
第二条执行中,请求到达 A,A 修改内存并同步,回复被丢弃。A 的两个状态都是 1,B、C 仍是 0,K 同样等到超时。
如果只把 K 的调用和返回记录交给判断程序,两条执行具有相同的输入:一次写调用和一次超时。判断程序如果宣告“没有执行”,就在第二条执行中出错;如果宣告“已经执行”,就在第一条执行中出错。可靠的输出必须保留未知,或者获取能区分两种执行的额外证据。
这是一种不可区分性论证。它足以否定“超时意味着没有执行”,但还没有证明共识不可解,也没有涵盖所有可能的请求历史。MIT 的 RPC 讲义列出了同类失败路径;gRPC 的 DEADLINE_EXCEEDED 契约也明确允许状态修改已经成功后返回截止期限错误。
成功回复与重启后丢失
第三条执行更直接:A 修改内存后就回复,K 收到 OK;A 没有同步就重启,恢复出 0。若接口承诺“成功后同一磁盘上的进程重启不会丢写入”,这条有限执行已经违反承诺。
把确认条件改为“同步成功后才能发送 OK”,可以消除这个模型中的反例。证明只需追踪不变量:收到 OK 意味着先前已有 stable=1;之后没有其他写入,允许的重启动作只把 memory 设为 stable,所以恢复后仍为 1。
证明依赖三个限制:稳定状态不丢失、没有后续覆盖写、所有成功回复都经过同步条件。若增加磁盘损坏、第二个写者或者绕过条件的回复路径,就必须重新检查,不能沿用这个结论。
读到旧副本不能判定写入失败
即使第四条执行中 A 同步、回复、重启都成功,B、C 的本地值仍是 0。向 B 读取 0 并不能说明 A 没有执行写入,因为模型根本没有把更新传播给 B。
进一步,若 K 已经收到写成功,随后调用 B 的读,读却返回 0,这个读写历史不能满足一个初值为 0、没有其他写入的线性一致寄存器规格。线性一致要求每项操作能在调用与响应之间某处生效,同时保留不重叠操作的实时先后。已经完成的写必须排在随后开始的读之前。Herlihy 与 Wing,1990
这个反例只判定给定 API 组合不满足该规格。允许陈旧读取的接口本来就没有承诺这样的语义。为了改变承诺,系统可能需要等待传播、约束读目标或者运行读屏障;第 06、07 篇会分析各自成本。
安全性、活性和完成时限
安全性描述不允许发生的错误。一个成功确认的写在约定重启后消失,就是本篇持久性规格的违例。观察到这段有限历史,已经足以确认违例;后面再把值修回去也不能抹掉先前发生过的错误。
活性描述最终需要发生的进展,例如在相应环境假设下,每个有效请求最终得到处理。一个服务始终等待同步而从不回复,可以避免错误确认,却不能因此声称服务可用。Alpern 与 Schneider 的定义和 Lamport 的教程分别给出了形式化区分与停滞反例。
本篇第五条执行把这个取舍暴露出来:请求已被处理,但同步没有发生;即使回复通道畅通,要求持久化的策略也不产生 OK。在尚未重启丢失易失状态时,继续等待只有在同步最终完成、相关进程继续运行、回复最终可达等前提下才可能成功。第五条具体轨迹已经重启并丢失该写入,单纯等待不能补回请求,还需要重试等额外机制。把永远失败的设备换掉,属于额外的恢复设计。
“最终完成”也不等于“十秒内完成”。带上界的要求一旦错过截止点,就有可观察的有限违例;单纯的最终性不能靠等十秒就否定。工程 SLO、超时策略和协议活性应分别描述。
有限实验通常能有效找出安全性反例,却不能靠几次顺利运行证明无限执行中的活性。本篇自检也没有检查调度公平性或无穷次重试,不宣称完成活性证明。
运行确定性模型
累计代码位于仓库 examples/distributed-systems/,本篇入口是 model00/main.go。它只使用 Go 标准库,不联网、不创建服务、不修改系统配置。需要 Go 1.23 或更新版本;本次运行环境和原始输出放在随文的验证记录。
在博客仓库根目录执行:
1 | |
程序先打印五条构造执行,再执行自检。以下是本次实际输出,不是对真实集群的预测:
1 | |
executed 是模拟器的全局观察字段,客户端没有读取这个字段的接口。client 只表示收到 OK 或截止前没有成功回复。把调试输出中掌握的全部状态赋予客户端,会破坏前面的不可区分性论证。
枚举的四个布尔选择是:请求是否到达、是否执行同步、回复通道能否在截止前交付、之后是否重启。请求未到达时,不允许执行同步,也不把不存在的回复交付出去;16 种组合中留下 10 种。每种组合比较两种确认策略,统计已成功确认但最终内存值不为 1 的情况。
在持久化确认策略中,“回复通道可交付”只表示传输条件满足;若同步未发生,服务端根本不产生成功回复。因此第五行仍然超时。它与第三行使用同样的环境条件,仅确认策略不同。
这个有限枚举覆盖的是固定顺序的单次写入片段。它没有枚举消息重排、重复请求、并发写入、复制确认和多次重启;B、C 在所有片段里均保持隔离。输出中的 durable-ack-losses=0 仅表示这 10 种组合没有出现该类违例。
程序内置检查还要求:确实找到易失确认丢写的反例;确实存在执行与未执行两类超时;没有接收消息的 B、C 不能凭空变化。任一检查不满足,进程返回非零状态。删除同步赋值后应触发失败,可以用这个变异检查验证自检并非只会打印 PASS。
先修自测与练习
系统编程自测
解释一条 RPC 至少需要识别调用参数、发送、服务端处理、回复和客户端截止期限。解释一次恢复至少需要区分内存状态与被恢复的状态。解释并发程序还需要知道“读取后加一再写回”由多个动作构成,两个执行流可能都读取旧值。
如果这些动作还不能独立拆开,先完成 Go Tour 的并发章节,并用纸笔列出两个执行流对初值 0 做一次加一时,怎样得到最终值 1。互斥锁可以保护同进程内的临界区,却不能自动保护另一个节点上的副本。
数学自测是把“可能”写成集合。对仅观察到超时的 K,允许的执行至少包含请求丢失和回复丢失两种。判断“确定已执行”需要这个集合里的每条执行都已执行;证明“有可能执行”只需找到其中一条。把存在量词误当全称量词,是从单次实验推出协议保证时常见的错误。
练习一:补全三节点时间线
A、B、C 初值都是 0,B、C 在全过程中没有收到更新。K 发出一次写请求并超时,随后向 B 读取到 0。分别构造 A 从未执行和 A 已同步写入两条时间线,再回答:增加 B 的读结果是否消除了写入的不确定性?
参考推导:两条执行都能产生 TIMEOUT 和 B=0,因此仍不能区分。若要提供额外证据,必须说明查询对象、查询语义和请求身份;例如某个可靠的操作状态接口明确记录该请求已完成。随便读到 x=1 也未必能确认请求身份,在允许其他写者时尤其如此。
练习二:修改故障前提
保留“同步后才能确认”的策略,但把故障改成磁盘内容丢失,重启时 stable 也变回 0。写出最短的已确认丢写时间线,并指出前面归纳论证的哪条前提被破坏。再判断:只增加一个长期不可达的 B,是否足以恢复原承诺?
参考推导:处理、同步、收到 OK、磁盘丢失、重启,即得到反例。被破坏的是稳定状态跨故障保留的前提。B 如果没有收到并保留更新,就没有可恢复的数据;节点数量本身不能替代复制和恢复协议。若把成功条件改成必须等待 B,则还需要讨论 B 长期不可达时的进展。
第 01 篇将把单次调用扩展到重试:响应丢失后,如何识别同一个请求,怎样让并发重试不重复产生副作用,以及去重记录丢失后承诺如何变化。本篇尚未实现这些机制。
参考资料与证据层次
研究核验日期为 2026-09-19。两校课表用于教学结构;FLP、线性一致性与安全性/活性论文用于模型定义;gRPC、Linux 和 SQLite 文档用于说明接口边界。对应标题、版本或日期、论断及核验状态保存在本篇证据索引。
实验输出属于本地教学模型观察,没有运行 ZooKeeper、etcd 或真实三节点服务,没有进行文件系统故障注入或断电测试。原始论文的结论、接口声明与这份输出分别提供不同层次的证据,不能相互替代。
