Chapter 01
为什么分布式系统这么难
这套教程要回答一个问题:为什么一堆能独立工作的机器,连到一起反而变得难以推理?本章给出根本约束,后面每一章都是在这个约束下的应对。
本章你将建立的 schema
- partial failure 与单机故障的本质区别:为什么"部分失败"比"全部失败"更难处理
- timeout 能告诉你什么、不能告诉你什么:超时触发后系统仍处于不确定状态
- FLP 不可能性的精确含义与逃逸方式:纯异步模型下共识的理论上限,以及 Raft/Paxos 如何绕过它
- "判断节点死活"本质是带误差的猜测:故障检测器的两个性质及 phi-accrual 的改进思路
1.1从"全有或全无"到 partial failure
partial failure = 部分节点失败、其余继续运行,且失败无法被立即、确定地判定。
单机程序员习惯了"故障 = 进程崩溃 = 操作系统能检测并清理"这个隐式假设。一旦跨越网络边界,这个假设静默失效——工程师若不显式建立 partial failure 模型,就会在错误的假设上设计协议,写出在单机上运行完美、在分布式下随机出错的代码。
单机系统里,故障是全局可检测的。CPU 检测到硬件错误就 halt;所有进程共享同一份 RAM 和同一个时钟;内核可以强制杀死任何进程并释放其资源。结果是:任一进程要么在运行,要么已经死亡——OS 知道,其他进程也能查到。这是"全有或全无"(all-or-nothing)故障语义。
跨网络的系统没有共享状态。节点只能靠消息了解彼此。异步网络对消息投递时间没有上界——一个丢在交换机队列里的包、一个慢节点、一个崩溃节点、一个正在排长队等待 CPU 调度的节点,从发送方的视角完全一样:超时前收不到回复。
这带来一个直接的工程难题。假设节点 A 向节点 B 发出一个写请求:B 收到请求、开始写入磁盘,然后在回复 A 之前崩溃。A 等到超时,重试——此时 B 可能已经重启,也可能还在崩溃状态,也可能写成功了一半。A 不知道原写到底有没有提交,重试则可能重复执行。这条"请求已发出但结果未知"的不确定窗口,是整套教程的根因。后面章节的每一种机制——幂等设计、两阶段提交、Raft 日志复制——都是在这个约束下的取舍。
1.2不可靠网络:timeout 的真相
timeout 只能说明"窗口内没收到回复",不能区分请求丢失、响应丢失、对方崩溃、对方变慢这四种情形。
没有 timeout,等待响应的线程/协程会永久阻塞,耗尽连接池。有了 timeout,系统得到一个触发重试或 failover 的信号——但这个信号本身携带的信息远比大多数工程师预期的少。
网络延迟来自多个独立来源,且叠加。交换机把包放入 FIFO 队列——队列满时丢包,或在短暂流量突发时引入毫秒级排队延迟。操作系统调度器在 CPU 过载时将进程暂停数十甚至数百毫秒。TCP 接收缓冲满时内核暂停发送端。运行 JVM 的服务在 full GC 期间整个 Stop-the-World,暂停时间从几百毫秒到数秒不等。每一项都在随机时刻叠加,使端到端延迟呈现长尾分布——即 p99 远高于 p50,p999 又远高于 p99。
面对这个长尾,超时阈值的设置是一个无法最优化的权衡:
- 短超时(如 500ms):真故障检测快,但 GC 暂停、网络抖动等正常慢节点会被误判为死亡,触发不必要的 failover 和重复工作。
- 长超时(如 30s):误杀率低,但真故障的检测延迟拖长,用户端可见的请求失败窗口变大。
没有一个"正确"的超时值。生产系统通常通过自适应策略(如 Cassandra 的 phi-accrual,见 §1.5)或指数退避 + jitter 来逼近合理范围,而不是设置一个固定数字。
节点 B 发生 1.2s Full GC 暂停,节点 A 的超时阈值是 1s,A 判定 B 死亡并提升新 leader;B 的 GC 结束后恢复,仍自认 leader——此时系统出现两个都认为自己是 leader 的节点,即 split-brain(脑裂)。这个失败模式将在第 04 章(复制)和第 06 章(共识)中系统分析。
把超时阈值从 3s 调到 500ms,故障检测会更快——但代价是什么?在高 GC 压力或网络抖动的环境里,这个调整会产生哪些副作用?
展开答案(先停 10 秒)
误杀慢节点的概率上升:那些因 GC、网络抖动、CPU 抢占暂时变慢的节点会被判定死亡,触发 failover。failover 本身消耗资源(日志追赶、状态同步),在系统已经有压力的情况下进一步加剧负载,导致更多节点变慢,形成级联 failover。设计洞察:超时阈值应根据实测的 p99/p999 延迟来设定,而不是拍脑袋选一个"看起来够快"的值。
1.3八个分布式谬误
Peter Deutsch 1994 年归纳 7 条、James Gosling 补第 8 条:工程师在设计分布式系统时反复踩到的隐式错误假设。
这 8 条谬误的价值不在于它们新奇,而在于它们揭示了工程师从单机思维切换到分布式思维时最常发生的"隐式假设"。每一条被违反时,系统不会立刻崩溃,而是在某个边界场景下静默出错,排查成本极高。
| # | 谬误(错误假设) | 违反时工程师会撞上什么 |
|---|---|---|
| 1 | 网络可靠 | 包丢失、连接中断导致请求半途而废;未做重试的操作静默失败 |
| 2 | 延迟为零 | 在循环里同步调用远端 API,累积延迟使 p99 变成数秒;批量接口 vs 单条接口的设计被忽略 |
| 3 | 带宽无限 | 把大对象放在调用链路上序列化传输,网络成为瓶颈;序列化格式未压缩 |
| 4 | 网络安全 | 服务间通信未加密、未认证;内网被攻破后横向移动无阻 |
| 5 | 拓扑不变 | 硬编码 IP 地址;服务发现未做;扩缩容后路由失效 |
| 6 | 只有一个管理员 | 多团队独立变更基础设施,配置冲突;变更审计缺失 |
| 7 | 传输成本为零 | 序列化/反序列化 CPU 开销、跨 AZ 流量费用被忽略,上线后账单或 CPU 超预期 |
| 8 | 网络同构 | 不同节点、不同数据中心运行不同版本协议栈,兼容性问题在部分节点上触发;灰度发布时行为不一致 |
这 8 条谬误像飞行检查单——不是因为飞行员不知道,而是因为人在压力下会依赖肌肉记忆跳过隐式检查。分布式系统的"肌肉记忆"来自单机编程;检查单帮助工程师在设计评审时显式覆盖每一条。但检查单不能告诉你在哪条谬误上该花多少工程成本——那取决于具体业务的故障代价与实现成本的比较。
1.4FLP 不可能性 — "无法区分慢和死"的理论顶点
纯异步系统里只要有一个节点会崩溃,没有确定性算法能同时保证终止性、一致性与有效性。
FLP 定理(Fischer, Lynch, Paterson 1985)划定了分布式共识的理论边界。不理解这个边界的工程师,会试图设计一个"既在任何情况下都能达成共识、又能容忍故障"的协议,然后在实现过程中反复遇到无法解决的角落案例。理解 FLP 后,就能理解为什么 Raft 和 Paxos 选择放弃在极端情况下的终止性保证。
FLP 证明了以下精确命题:在一个纯异步模型(消息最终一定送达,但送达时间无上界)里,只要存在至少一个会发生 crash-stop 故障的节点,就不存在确定性算法能同时满足:
- 终止性(Termination):所有未崩溃节点最终都做出决定。
- 一致性(Agreement):所有做出决定的节点决定相同的值。
- 有效性(Validity):决定的值必须是某节点提议的值。
证明的核心机制是 bivalent(双值)论证。从一个两种决定都还能发生的初始配置(双值配置)出发,对手可以通过精心调度消息的延迟和送达顺序,始终把系统维持在"还没做出决定"的双值状态——只要对手愿意,这个状态可以无限延续。FLP 模型不假设消息会丢失;消息一定会送达,只是送达时间对手可以任意指定。这正是"无法区分慢和死"这句话的理论化身:如果消息延迟可以任意长,那么一个崩溃的节点和一个极慢的节点对其他节点完全不可区分。
FLP 不是说"实践中共识不可能"。它证明的是:存在一个最坏调度序列,使得某个确定性算法永远不终止。这是最坏情况的存在性证明,不是说每次运行都会卡死。生产系统有三条逃逸路径:①部分同步假设——假设网络延迟最终有界(即使界不已知),Paxos 和 Raft 就活在这里;②随机化——Raft 用随机选举超时打破对称,对手无法可靠地"总是把系统停在双值状态";③故障检测器——引入一个允许出错的 oracle,用它换取终止性(见 §1.5)。
etcd 和 ZooKeeper 都宣称提供强一致性的分布式协调服务。FLP 指出纯异步下共识不可能,这是否意味着这两个系统不可能工作?
展开答案(先停 10 秒)
不矛盾。etcd(Raft)和 ZooKeeper(ZAB)都不在纯异步模型下运行——它们都假设网络延迟最终有界(部分同步模型)。在这个假设下,FLP 的前提不成立。代价是:在网络分区或延迟超过预期时,系统选择阻塞而非做出错误决定(牺牲 Availability,保 Consistency)。CAP 定理(第 08 章)正是这个取舍的另一面。
1.5故障检测器:"它死了吗"本质是猜
故障检测器是每个节点查询、对"谁崩了"给出带误差的猜测的组件。
§1.2 讲了 timeout 的局限——它只给出"窗口内无回复"这个信号。故障检测器把这个信号组织成一个可以被协议层查询的抽象:"你认为节点 X 现在死了吗?"有了这个抽象,协议层可以和故障检测策略解耦,允许独立改进检测算法而不改变协议逻辑。
Chandra & Toueg(1996)给故障检测器定义了两个性质:
- Completeness(完备性):崩溃的节点最终会被所有未崩溃节点怀疑。
- Accuracy(精确性):未崩溃的节点不会被永久怀疑。
Perfect failure detector(P,强完备 + 强精确)在异步网络下不可实现——理由正是 §1.4 的 FLP 论证:无法区分慢和死。共识算法需要的最低故障检测器类型是 eventually-perfect(◇P):允许暂时误判活节点,但最终停止误判所有活节点。◇P 在部分同步网络下可以实现,Raft 的选举超时机制在语义上就是一个 ◇P 实现。
固定超时阈值的问题在于它对网络特性一无所知——同一个阈值在低延迟数据中心内表现良好,在跨 AZ 或有 GC 压力的环境里就会频繁误判。phi-accrual 故障检测器(Cassandra、Akka 采用)改变了问题的形式:不给出"死/活"二值,而是用滑动窗口收集最近若干次心跳到达的时间间隔,拟合一个分布(通常是指数分布或正态分布),然后计算"下一次心跳到现在的延迟,在观测分布下发生概率有多低",把这个概率的负对数输出为 φ(phi)值。φ 值越高,怀疑程度越深;φ → ∞ 表示几乎可以确定节点已死。
工程师设置的不再是一个固定超时时间,而是一个 φ 阈值(如 8 或 10)。当网络延迟整体升高时,观测分布的均值和方差都会变大,φ 的增长速度随之放缓——检测器自动适应了当前的网络环境,误判率不会因短暂抖动而急剧上升。
φ 阈值像地震震级而非"有没有地震"——给出的是量化的不确定性,而非二值判断。边界在于:phi-accrual 的分布拟合假设心跳间隔近似服从指数或正态分布;如果网络出现非稳态的双峰延迟(如跨 AZ 路由切换),拟合精度下降,φ 曲线会出现虚假的高值或低值。
章末自测
- 为什么单机的"进程崩溃"比分布式的"节点失联"容易处理?
- timeout 触发的那一刻,系统能确定对方崩溃了吗?原因是什么?
- FLP 精确证明了什么?它是否意味着 etcd / ZooKeeper 这类系统不可能工作?
- eventually-perfect(◇P)故障检测器允许什么行为、最终保证什么?
展开所有答案(建议先独立作答)
1. 单机共享同一份内核、RAM 和时钟——操作系统能全局检测进程状态,进程崩溃是全局可见事件,其他进程可立即得到确定性通知。分布式节点间没有共享状态,只有消息;消息延迟无上界,使节点失联与节点变慢对外部观察者完全不可区分——故障判定本质上是带误差的猜测。
2. 不能。timeout 只告诉观察者"在窗口期内没收到回复",无法区分请求丢失、响应丢失、对方崩溃、对方因 GC/排队变慢这四种情形。系统触发 timeout 后仍处于不确定状态,不知道对方是否执行了请求。
3. FLP 证明:在纯异步模型(消息最终送达但送达时间无上界)里,只要存在一个可能 crash 的节点,就不存在确定性算法同时满足终止性、一致性和有效性。etcd/ZooKeeper 正常工作——因为它们运行在部分同步假设下(网络延迟最终有界),FLP 的前提在生产环境中通常不成立。极端网络分区时这些系统选择阻塞(放弃 availability)而非违反一致性。
4. ◇P 允许暂时误判活节点为死亡(即允许 false positive),但最终保证:崩溃的节点都会被所有未崩溃节点怀疑(完备性),且活节点不再被永久怀疑(最终精确性)。它不提供"从不误判"的强精确性,只提供"最终不误判"的弱保证。
设计一个自适应故障检测策略
固定超时阈值的缺陷在于它对网络特性一无所知。设计一个故障检测策略,要求:既能快速发现真崩溃(低检测延迟),又能少误杀 GC 暂停的节点(低 false positive 率)。固定阈值为什么做不到?phi-accrual 用什么思路改善?如果心跳间隔分布是双峰的(正常心跳 10ms,偶发跨 AZ 延迟 200ms),phi-accrual 还有效吗?如何改进?
提示(卡住再展开)
核心思路:把"死/活"二值判断替换为"随时间上升的怀疑度",并让阈值适应观测到的心跳间隔方差。固定阈值的问题是它不区分"这个环境的正常 p99"和"超出正常范围"——两种情况下触发的条件是相同的固定数字。phi-accrual 用滑动窗口拟合分布:正常网络时均值小、方差小,φ 增长快但阈值同样按分布设定;网络整体变慢时均值和方差都变大,φ 增长放缓,误判率自动降低。双峰分布问题:单一分布拟合失准,一种改进是用混合高斯模型替换单分布,或对不同时间段(白天/夜间、跨 AZ/同 AZ)维护独立的滑动窗口,让每个窗口只拟合当前场景的延迟模式。