Lecture 26: Datacenter Disasters — 真实数据中心故障案例研究(Datacenter Disasters: Case Studies)

目录 · ← l25 · l27 →

Lecture 26: Datacenter Disasters — 真实数据中心故障案例研究(Datacenter Disasters: Case Studies)

讲义对应:CS 425 FA2026 Lecture 26。本章对应课程 Lecture 28「Datacenter Disasters」(2026-12-03,Real Behaviors 模块),原始讲义为 L28.FA25.pdf(45 页:数据中心宕机原因竞猜、人为错误案例集、四个真实事故案例(AWS 2011 / Facebook 2010 / The Planet 2008 / AWS 2025)、经验教训总结)。背景素材:Lecture 2「Introduction to Cloud Computing」L2.FA26.pdf:数据中心三层架构、机架/交换机/可用区/区域、故障域分层、云的经济学)与 Lecture 5-6「Failure Detection and Membership」L5-6.a.FA26.pdf:故障是常态而非例外、MTTF 随规模线性变差、Completeness 与 Accuracy 不可兼得、误判与 Suspicion 机制)。 教材对应:Coulouris 5th Ed. Ch. 1–2(分布式系统特征、挑战与体系结构模型)、Sec 2.4.2(故障模型)、Ch. 12(分布式文件系统与容错)、Ch. 18(复制与可用性);补充:Patterson 等 Recovery-Oriented Computing (ROC)(Berkeley, 2002)、Huang 等 Gray Failure: The Achilles’ Heel of Cloud-Scale Systems(Microsoft Research, HotOS 2017)、Basiri 等 Chaos Engineering(IEEE Software 2016)。 阅读材料:讲义引用的公开事故报告 —— AWS 2011-04-21 EBS 事故 post-mortem(aws.amazon.com/message/65648/)、Facebook 2010-09-23 宕机说明(Facebook Engineering Notes)、The Planet 2008-05-31 H1 Houston 机房爆炸事故分析(Availability Digestdata_center_outages-lessons.pdf)、AWS 2025-10-19/20 事故 post-mortem(aws.amazon.com/message/101925/)。本章中所有案例事实均来自上述讲义与讲义所引材料;讲义未覆盖、但为解释原理所需的通用概念,均以「补充」明确标注。

26.1 概述

前 25 讲我们一直在回答一个问题:如何设计一个”正确的”分布式系统——用复制获得容错,用 quorum 获得一致性,用逻辑时钟获得顺序,用 2PC 获得原子性,用故障检测获得”谁还活着”的近似答案。本章掉转方向,问一个更朴素、也更残酷的问题:真实的分布式系统为什么会失效?

讲义用一组真实事故回答它:一个工程师把某个路由器的流量切到了低容量的备用网络上,最后演变成持续 3.5 天、丢失 0.07% 数据的 AWS EBS 大规模故障;一次配置值写错,让 Facebook 的缓存服务器集体去猛查数据库,把整个网站逼下线;一次变压器短路起火,让 The Planet 的 9 千台服务器在 3 天内无法恢复;2025 年 10 月,两个自动化 DNS 程序之间的一次竞态,把 dynamodb.us-east-1.amazonaws.com 的 DNS 记录写成了,进而让 EC2 的租约系统”充血性崩溃”了一整天。讲义开篇的课堂竞猜给出了一个数字:数据中心宕机的首要原因是人为错误,约占 70%

本讲在整门课中的位置是反向收束:前面每一讲都在建立”故障模型—机制—正确性论证”的正向链条(crash-stop、Byzantine、分区、FLP、quorum、fencing),本章则把这些抽象放回真实机房,看它们在配置错误、软件 bug、竞态、静默数据损坏、运维失误、相关性故障面前如何失效。它的结论不是”理论没用”,而恰恰相反:真实事故几乎总是”某个理论假设被悄悄违反”的结果,而理解了假设,才能在事故报告的细节里看见根因。

本章的黄金法则(贯穿全章,并在 26.6 再次点题):

真实系统的问题几乎从不是”崩溃”,而是”变慢”、”部分失败”和”配置错误”。所以设计的重点不是让系统永不故障,而是让故障的影响可隔离、可快速恢复、可被观测到——减少 MTTR 比增加 MTTF 更有效,而控制爆炸半径比重试更有效。

26.2 核心概念与分布式机制图解

26.2.1 为什么要研究真实故障(Why Study Outages)

  • 定义与目的事故复盘(postmortem / incident review)是对一次真实故障的系统性事后分析:时间线、影响面、根因、促成因素、以及防止复发的行动项。研究故障的目的不是追责,而是从真实系统中提取”设计假设在哪一环被打破”的知识,进而改进设计。

  • 直观解释(”它是什么?”):讲义把研究宕机的理由说得很直白——既”有趣”(幸灾乐祸式的围观),也真的有用:它让我们了解系统在真实世界里的实际行为,从而在未来设计出更好的系统。讲义特别强调两点态度:第一,目的不是评判哪家公司更差;事实上,经历过宕机的公司之后往往会建设出更稳健的基础设施——”杀不死你的会让你更强”(”What doesn’t kill you makes you stronger”,讲义引 Calvin 的爸爸的话:”It builds character”)。第二,宕机是不可避免的,因此值得研究的不是”如何不发生”,而是”发生后如何更快恢复、更少波及”。

  • 核心论点(必须讲透)

    1. 理论上的故障模型是抽象,真实故障往往是这些模型的错误组合。讲义中的四次事故没有一次是干净的 crash-stop:AWS 2011 里既有网络分区(primary 网络断开、secondary 网络被压垮)又有人为误操作又有竞态条件;Facebook 2010 里是配置错误 + 重试风暴;The Planet 2008 里是物理事故 + 电源/冷却容量不足;AWS 2025 里是并发控制缺失导致的元数据覆盖。真实故障是”分布式系统课程里所有故障类型的叠加态”。

    2. “理论上正确”不等于”实践中可靠”。任何正确性证明都建立在一组假设上:故障检测器不误判、时钟足够同步、消息不会损坏、代码没有 bug、配置是对的、没有人在凌晨三点敲错命令。这些假设在真实环境里迟早会被违反——而且违反的方式往往不在模型之内(配置错误、静默数据损坏、运维失误都属于”抽象之外的故障”)。Lecture 5-6 已经证明:在丢失消息的网络上,故障检测器的 Completeness(不漏报)与 Accuracy(不误报)不可兼得;一旦承认误判,凡是依赖”故障检测结果”的决策(切主、摘除节点、触发重平衡)就都可能做错。

    3. 故障是常态而非例外。Lecture 5-6 给出了数量级:若一台机器的故障率是平均 10 年一次(120 个月),那么 120 台服务器的数据中心里”下一台机器故障”的平均间隔就降到 1 个月,12000 台时降到 约 7.2 小时——而且软故障(soft crash / 性能退化)比硬故障更频繁。Lecture 2 又告诉我们,现代云是商品化硬件 + 大规模 + 按需租用的组合。二者相乘的结论只有一个:容错不是可选的加分项,而是设计的基本要求

  • 机制图解:从”抽象故障模型”到”真实故障”的落差。

   课程前 25 讲的故障模型                真实数据中心里实际发生的事
   ─────────────────────────            ──────────────────────────────────────
   crash-stop(进程干净地停)      <--    进程还活着, 但心跳还在、吞吐掉到 1/10  (灰色故障)
   Byzantine(任意错误行为)       <--    不是恶意, 而是"磁盘说写成功但数据是坏的"(静默损坏)
   network partition(网络分区)   <--    分区 + 非对称 + 抖动 + DNS 解析失败 + 健康检查抖动
   fail-recover(崩溃后可恢复)    <--    重启后缓存/配置/时钟/租约全都处于不一致状态
   独立故障假设(副本互不影响)    <--    同机架/同交换机/同电源/同版本/同配置 ==> 相关性故障
   无 bug 的代码                <--    只在"高并发 + 高负载"下才触发的竞态(AWS 2011 / 2025)
   正确的配置                   <--    配置文件写错一个值(Facebook 2010 / 1300 万德国网站)
  • 关键假设与系统模型:本讲隐含的系统模型比前面任何一讲都”更弱”:故障可以是部分失败(partial failure)间歇性(intermittent)有相关性(correlated)的;系统管理员、自动化程序(agent/Enactor)、第三方依赖(DNS、认证、云 API)都是系统的一部分——它们也会失效。把这一条写进设计里,就是本章后半部分所有工程实践(ROC、混沌工程、渐进式发布、熔断、舱壁、可观测性)的共同出发点。

26.2.2 真实故障的分类学(A Taxonomy of Real-World Failures)

  • 定义与目的:把真实故障按来源组件分类,是为了给每一类故障配上对应的检测手段防护机制。讲义自身没有给出一张完整的分类表(它用案例来展示),下面这张分类树是把讲义四个案例、人为错误案例集与讲义”其他故障来源”(DoS、互联网中断、计划内停机)系统化整理的结果,并用【补充】标出课程外但必要的通用概念。

  • 机制图解:故障分类树。

                                真实数据中心的故障
                                       |
   +-----------+-----------+-----------+-----------+-----------+-----------+
   |           |           |           |           |           |           |
 硬件        网络        软件       人为/运维    容量/负载     安全        依赖
   |           |           |           |           |           |           |
   |           |           |           |           |           |           |
 磁盘故障    链路中断    代码 bug    误删/误配   容量规划不足  DoS/DDoS    DNS
 静默损坏    网络分区    (边界/竞态) (讲义案例集) 热点/倾斜    内部威胁    认证服务
 内存位翻转  非对称分区  配置错误    流程缺陷    惊群/突增    供应链      云厂商 API
 CPU/网卡    丢包/乱序   版本回归    变更管理缺失 负载重分配   勒索/入侵   CDN
 电源故障    抖动/jitter 内存泄漏    监控盲区     次生过载                第三方 SaaS
 冷却故障    DNS 故障    死锁/活锁
 机架/机柜   BGP 泄露    重试风暴
 U PS/电池   路由劫持    级联失败
 火灾/水浸               控制面故障
                         (元数据/租约)
   |           |           |           |           |           |           |
   +-----------+-----------+-----------+-----------+-----------+-----------+
                                       |
                              共同的放大器: 相关性 + 重试 + 自动化 + 缺乏超时
  • 分类要点与应对(每一类给出特征、真实例证与防护重点):
类别典型特征讲义中的例证防护重点
硬件单点失效概率低但基数巨大;静默数据损坏不会报错The Planet 的变压器短路起火、UPS 电池酸雾爆炸;”警报声震坏了磁盘(包括虚拟备份盘)”冗余 + 端到端校验和 + 定期巡检与更换
网络分区、非对称、抖动、DNS 解析失败AWS 2011:primary 网络断开、secondary 网络被压垮 → 分区引发”重镜像风暴”;AWS 2025:DNS 记录被写成空独立数据面/控制面路径、DNS 多源冗余、分区感知的重平衡退避
软件只在特定负载/时序下触发的竞态;边界条件AWS 2011 的竞态(只在高请求率下触发);AWS 2025 的 Enactor 竞态;Facebook 的缓存/配置校验逻辑并发控制、幂等、灰度发布、单元/故障注入测试
人为/运维占比最高(讲义竞猜:70%),常是事故起点误删数据库并删掉备份、误拉控制器、误上传空的 zone file(1300 万德国网站)、UPS 断路器操作顺序错误……变更审批与审计、双人复核、最小权限、可回滚
容量/负载慢故障:逐步饱和,最后雪崩AWS 2011 的”重镜像风暴用光了空闲网络容量”;Facebook 的”数据库被每秒十万级查询压垮”容量规划、限流、负载丢弃、热点打散(见 26.3.3)
安全讲义列为独立故障来源讲义”其他故障来源”:DoS 抗性、政府通过 DNS 屏蔽互联网连接 Lecture 25 安全章:限流、清洗、任播、多 DNS
依赖云厂商自身、DNS、认证、CDN、第三方 APIAWS 2025:DynamoDB 的 DNS 端点为空 → 依赖它的 EC2 租约系统崩;The Planet:其他机房受限于电力与冷却依赖分级、降级、舱壁、跨提供商/跨区域冗余(”Sky Computing”)
  • 【补充】静默数据损坏(Silent Data Corruption):最阴险的一类硬件/软件故障——磁盘报告”写成功”,但读回来的数据是错的;内存的位翻转(bit rot、宇宙射线、Rowhammer)也会让校验逻辑或元数据悄悄变错。讲义本章未展开,Lecture 22 的分布式文件系统(GFS 等)正是用 chunk 级 checksum + 端到端校验 来对抗它:写入时算校验和,读取时验证,发现不一致就用其他副本修复;校验必须端到端(从客户端到磁盘),否则中间的任何一跳都可能悄悄改坏数据而无人发现。这是”崩溃可检测、损坏不可检测”的典型对照。

26.2.3 失效模式(Failure Modes):从”崩溃”到”灰色故障”

  • 定义与目的:把故障按表现形式(而不是按来源)分类,才能设计出正确的检测策略。关键认识是:越”干净”的故障越好处理,而真实世界的故障恰恰越来越”脏”

  • 对比表(必须掌握)

失效模式表现可检测性对系统的影响代表机制/防护
崩溃故障(Crash / fail-stop)进程停止、心跳中断容易:心跳超时即可判定明确、可自动切换故障检测器 + 副本切换(Lecture 5-6)
灰色故障(Gray Failure)不彻底失败:只变慢、只对部分请求失败很难:进程活着、心跳正常、自身指标”看起来正常”流量继续打进来,把上游资源耗尽 → 级联退化从客户端视角测量、跨层关联、主动探测
相关性故障(Correlated Failure)多个”独立”副本因同一根因同时失效需要拓扑/变更感知直接击穿冗余假设故障域隔离、反亲和、多样化、渐进式发布
拜占庭/静默故障(Byzantine / silent)返回错误结果但不报错需要交叉校验可能产生错误决策或数据损坏$3f+1$ 副本、端到端校验(Lecture 15、Lecture 22)
失败放大(Failure Amplification)小故障被系统机制放大成大故障需要在聚合指标里看把”部分降级”变成”全线崩溃”有界重试 + 退避 + 抖动 + 重试预算、限流、熔断
  • 灰色故障(Gray Failure)——必须讲透的一类【补充:概念来自 Microsoft Research 的 Gray Failure,讲义用”亚马逊控制面线程池被慢操作占满”“EC2 API 错误率与延迟升高”等现象印证了同类问题】:
    • 定义与目的:灰色故障指组件既不算”活着”也不算”死了”的状态:它能接受连接、能回心跳、CPU/内存指标都正常,但对一部分客户端表现为持续失败或极慢。它介于 crash(全黑)与健康(全白)之间的”灰色地带”。
    • 直观解释(”它是什么?”):把它想成一个说话很清楚、但只会回答一部分问题的客服。电话永远能打通(心跳正常),但有的顾客等 30 秒得到答案,有的顾客永远被转接。监控室看到的是”在线率 100%”,顾客感受到的是”根本不能用”。
    • 三个致命性质
      1. 相对性(relativity):同一台服务器对不同客户端可能分别是”故障”和”正常”——因为故障藏在某条链路、某个交换机、某个副本对上(网络抖动、单条链路丢包、只对某些分片失败的磁盘)。讲义中 AWS 2011 的”多个 EBS primary 副本认为自己没有 backup”、AWS 2025 的”健康检查结果在 failed/healthy 之间来回翻转”都是这种相对性的体现。
      2. 难以检测(undetectability from inside):服务器自己的指标(CPU、内存、进程存活、心跳)都正常,只有从客户端视角测量才看得见。这正是 AWS 2025 中 NLB 健康检查”翻转”却无法给出稳定判断的原因。
      3. 引发错误决策:由于故障检测器不认为它故障,流量会继续打到它身上——慢请求占住调用方的线程/连接,最终把调用方也拖垮(级联退化)。Lecture 5-6 里 gossip 故障检测的 Suspicion 机制(先”疑似”再”判死”)正是为了缓解”误判/漏判”的两难,但灰色故障连”疑似”都很难触发。
    • 机制图解:服务器视角 vs 客户端视角。
       服务器自己的视角 (What the server sees)          客户端视角 (What clients see)
   ┌──────────────────────────────────────────┐   ┌──────────────────────────────────┐
   │  进程存活        : OK                    │   │  客户端 A: 120 ms   (正常)        │
   │  心跳            : 每 100 ms 一次  OK    │   │  客户端 B: 3.2 s    (超时重试中)  │
   │  CPU / 内存      : 41% / 55%     OK      │   │  客户端 C: 8.0 s    (超时)        │
   │  自测健康检查    : PASS (端口能连)       │   │  客户端 D: 200 ms   (正常)        │
   │  "我很好"                                 │   │  客户端 E: 9.5 s    (超时)        │
   └──────────────────────────────────────────┘   └──────────────────────────────────┘
              |                                                  |
              |  监控面板上 "一切正常"                           |  真实的 p99 已经爆炸
              v                                                  v
   ┌───────────────────────────────────────────────────────────────────────────────┐
   │  后果: 负载均衡继续把流量打给这台机器 -> 慢请求占满调用方线程池 -> 级联失败   │
   │  对策: (1) 从客户端视角定义 SLI  (2) 跨层关联指标  (3) 主动合成探测/canary    │
   │        (4) 端到端延迟直方图(而不是平均值)  (5) 快速失败 + 熔断 + 舱壁          │
   └───────────────────────────────────────────────────────────────────────────────┘
  • 检测手段【补充】:(a)从客户端视角测量——把 SLI 定义在”调用方看到的成功率/延迟”上,而不是被调用方自己报的健康度;(b)跨层关联——把网关、服务、数据库、网络的指标放在同一条时间轴上对齐,寻找”某一跳的延迟注入”;(c)主动探测(synthetic probe / canary request)——用真实业务路径的合成请求持续探测,而不是只探 TCP 端口;(d)慢请求采样与追踪——用分布式追踪记录每个慢请求卡在哪一跳。

26.2.4 相关性故障与故障域(Correlated Failure and Fault Domains)

  • 定义与目的相关性故障指多个”本应独立”的副本因同一根因同时失效;故障域(fault domain)指”会一起失效的一组组件”(同一机架、同一交换机、同一电源域、同一可用区、同一软件版本、同一次配置推送)。冗余的有效性完全取决于副本是否真的落在不同的故障域里

  • 直观解释(”它是什么?”):Lecture 2 讲过数据中心的物理层次:服务器 → 机架 → 数据中心 → 可用区(AZ) → 区域(Region) → 全局,而且讲义明确指出”AZ 被设计为彼此独立的故障域“。相关性故障就是”你以为买了 3 份保险,其实 3 份保单是同一家保险公司开的、而且它们在同一次灾难里一起赔付”。

  • 机制图解:同一批副本,三种放置方式,三种爆炸半径。

   场景 1: 3 副本塞在同一机架 (常见错误: 按"最小化网络跳数"或按编号贪心放置)
   ┌──────────────── 机架 R1 ────────────────┐  ┌──── 机架 R2 ────┐  ┌── R3 ──┐
   │   [副本 A]   [副本 B]   [副本 C]        │  │   (空)          │  │  (空)  │
   └─────────────────────────────────────────┘  └─────────────────┘  └────────┘
   一次机架断电/交换机重启  ==>  3/3 副本全丢  ==>  数据永久丢失 (爆炸半径 = 100%)

   场景 2: 3 副本跨机架, 但同一个可用区 (AZ-aware 缺失)
   ┌─────────────────────────── 可用区 us-east-1a ───────────────────────────┐
   │  ┌── R1 ──┐        ┌── R2 ──┐        ┌── R3 ──┐                          │
   │  │ [副本A]│        │ [副本B]│        │ [副本C]│                          │
   │  └────────┘        └────────┘        └────────┘                          │
   └──────────────────────────────────────────────────────────────────────────┘
   单机架故障 ==> 只丢 1/3 (仍有多数派, 可用)      ✅
   单可用区故障 ==> 3/3 全丢 ==> 数据永久丢失      ❌ (爆炸半径 = 整个 AZ)

   场景 3: 3 副本跨可用区 (正确的故障域隔离)
   ┌── AZ 1a ──┐        ┌── AZ 1b ──┐        ┌── AZ 1c ──┐
   │ [副本 A]  │        │ [副本 B]  │        │ [副本 C]  │
   └───────────┘        └───────────┘        └───────────┘
   单机架故障 ==> 丢 0~1/3     ✅        单可用区故障 ==> 只丢 1/3, 仍有多数派  ✅

   ┌───────────────────────────────────────────────────────────────────────────┐
   │ 相关性故障(同一软件版本 / 同一次配置推送 / 同一时间触发的定时任务 / 闰秒): │
   │   3 个副本, 3 个可用区, 3 套硬件 —— 全部同时失效。                        │
   │   ==> 任何"放置策略"都救不了, 唯一缓解是"多样化(diversity)"。             │
   └───────────────────────────────────────────────────────────────────────────┘
  • 破除独立性假设的后果与防御
    1. “有 3 个副本所以可靠”是幻觉:可靠性公式 $P(\text{全部故障}) = \prod_i P_i$ 只在”故障独立”时成立;一旦相关,乘积退化成一个共同故障概率。讲义里 AWS 2011 的 EBS 副本”每个卷有一个 primary 副本,若失步或节点故障就激进地重镜像“,在分区之后集体触发重镜像——这就是行为层面的相关性(同一个触发器 + 同一段代码 + 同一种策略 = 同时动作)。
    2. 防御手段:故障域隔离(跨机架/跨 AZ/跨区域)、反亲和性约束(anti-affinity)多样化(版本、实现、供应商、网络路径异构)渐进式发布(不让一个坏版本同时上所有副本)、抖动(jitter)(不让所有定时任务在同一毫秒触发)、重平衡退避
    3. 代价:跨 AZ 复制带来更高的写延迟与跨区流量成本(Lecture 2 的云经济学:跨区数据传输是要付费的);多样化带来运维复杂度。这是”可靠性 vs 成本/复杂度”的经典权衡——26.5 会给出定量分析。

26.2.5 失败放大(Failure Amplification):小故障如何变成大事故

  • 定义与目的失败放大指系统自身的容错/运维机制(重试、心跳、重平衡、冷启动、缓存刷新)在故障发生时反过来增加负载,把一个”部分降级”放大成”全线崩溃”。讲义反复出现的”Cascading failures(级联失败)“、”重镜像风暴(re-mirroring storm)“、”数据库被每秒十万级查询压垮“、”重试没有退避(No back off)导致 rinse-n-repeat”都是失败放大。

  • 直观解释(”它是什么?”)超市收银台的类比——平时 5 个收银台开 2 个也够用。某个收银员动作变慢(灰色故障),他后面的队伍开始变长,长队伍堵住了通道,于是想去其他收银台的顾客也走不过去了,最后整个超市结账区瘫痪。如果此时经理的处理方式是”让每个顾客都去所有队伍各排一次”(重试),超市会彻底瘫痪。

  • 机制图解:级联失败的时间线(本章最重要的图)。

 t = 0 ms    ┌────────────────────────────────────────────────────────────────┐
             │ 触发: db 变慢 (p99 10ms -> 200ms); 容量 1000/s -> 50/s          │
             └────────────────────────────────────────────────────────────────┘
                 |
 t ≈ +50 ms      v   调用方开始"等连接": 每个请求同步阻塞, 直到拿到 DB 连接
             ┌────────────────────────────────────────────────────────────────┐
             │ db 连接池(10 个) 全部被占用; 等待队列开始堆积: 10 -> 30 -> 50  │
             └────────────────────────────────────────────────────────────────┘
                 |
 t ≈ +250 ms     v   ★ 关键一步: 等连接的请求"占着"它的 svc 工作线程不放
             ┌────────────────────────────────────────────────────────────────┐
             │ svc 线程池(60 个) 被"正在等 db"的请求占满 -> 100% 占用          │
             │ 后果: 与 db 无关的健康请求(aux)也拿不到线程 —— 横向扩散         │
             └────────────────────────────────────────────────────────────────┘
                 |
 t ≈ +1 s        v   上游继续等: api 处理线程(120 个) 也被占满
             ┌────────────────────────────────────────────────────────────────┐
             │ api 池 100% 占用 -> 新请求只能在入口排队(积压 1 万级)          │
             │ 客户端开始超时 -> 用户看到大面积失败                            │
             └────────────────────────────────────────────────────────────────┘
                 |
 t ≈ +3~5 s      v   放大阶段: 客户端/网关重试 + 健康检查摘除 + 负载重分配
             ┌────────────────────────────────────────────────────────────────┐
             │ (a) 无退避重试: 入口负载 x2~x4, 下游调用放大 4 倍               │
             │ (b) 健康检查失败 -> 把实例从 LB 摘掉 -> 幸存者承受全部流量      │
             │ (c) 分区/失联的副本集体"激进重镜像" -> 重镜像风暴(占满网络)     │
             │ (d) 冷启动风暴: 大量实例同时重启, 同时抢配置/缓存/连接          │
             └────────────────────────────────────────────────────────────────┘
                 |
 t ≈ 分钟级      v   稳态灾难: 成功率跌到个位数, 延迟无界增长, 恢复依赖人工介入
             ┌────────────────────────────────────────────────────────────────┐
             │ 注意尺度: 从"一个依赖慢了 20 倍"到"全线不可用"只用了秒级时间。   │
             │ ==> 人工响应根本来不及, 必须靠自动化(超时/熔断/限流/丢弃)。      │
             └────────────────────────────────────────────────────────────────┘
  • 重试放大的数学(讲义在 Facebook 案例里称之为”没有退避的重试、rinse-n-repeat”,并在经验教训中给出 Exponential backoff 作为解法):
  情形 1: 客户端每个请求重试 r 次(失败就立刻重试)  ==> 下游负载放大 (1 + r) 倍
          1 个用户请求 ──> [尝试1]──失败──>[尝试2]──失败──>[尝试3]──失败──>[尝试4]
          下游看到的调用:  1         + 1        + 1        + 1       = 4 倍 (r = 3)

  情形 2: 每一层都重试 r 次 (L 层调用链)          ==> 放大 (1 + r)^L 倍
          L = 3 层, r = 3:  4 x 4 x 4 = 64 倍
          ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐
   用户 ──>│  网关    │──>│  服务    │──>│  存储    │──>│  数据库  │   64x 到达最底层
          └──────────┘   └──────────┘   └──────────┘   └──────────┘
               4x             4x             4x           (最脆弱的一环)

  情形 3: 无抖动 + 同步超时 ==> 惊群(thundering herd)
          所有客户端在同一时刻超时 ==> 所有客户端在同一时刻重试
          时间轴:  |---- 超时 1s ----|######## 重试洪峰 ########|---- 超时 ----|####...
          有抖动:  |---- 超时 1s ----|# #  #   #    #  #   #  # |--- 超时 ---|# # #
                   (重试被均匀打散, 峰值负载下降 ~ sqrt(客户端数) 量级)

  自我强化的循环:  下游变慢 -> 调用方超时 -> 调用方重试 -> 下游更慢 -> 更多超时
                  ==> 这就是讲义中 "rinse-n-repeat" 的机理
  • 关键假设与系统模型:失败放大的前提是”系统有能力接收这些额外负载“。真正的危险在于:故障时刻系统的可用容量通常已经在下降(慢依赖占住了线程、重平衡占住了网络、重启中的节点不服务流量)。因此”重试”在容量充足时是提高成功率的手段,在容量不足时就变成分布式拒绝服务攻击(自己打自己)。这正是 26.2.15 里”重试必须有界 + 退避 + 抖动 + 预算”的原因。

26.2.6 关键指标:MTTF / MTTR / RPO / RTO / SLA / 爆炸半径

  • 定义与目的:事故复盘与可靠性设计都需要一套可量化的语言。下面这张术语表是全章的度量基础,也是 26.3.6 定量论证的出发点。
术语英文含义备注
MTTFMean Time To Failure平均无故障运行时间(从修好到下次故障)与规模强相关:$n$ 个组件时系统 $\text{MTTF} \approx \text{MTTF}_{\text{单件}} / n$(Lecture 5-6)
MTBFMean Time Between Failures平均两次故障之间的间隔(= MTTF + MTTR,修复时间可忽略时二者等同)工程口语里常与 MTTF 混用
MTTRMean Time To Repair/Recover平均恢复时间(检测 + 诊断 + 修复 + 验证恢复到服务)ROC 的核心优化目标;包含”检测时间”,而检测往往最慢
可用性Availability$A = \dfrac{\text{MTTF}}{\text{MTTF} + \text{MTTR}}$等价于 $\dfrac{1}{1 + \text{MTTR}/\text{MTTF}}$:只取决于比值
RPORecovery Point Objective能容忍丢多少数据(时间维度:最近多久的数据可以丢)决定复制方式:同步复制 RPO≈0,异步复制 RPO>0
RTORecovery Time Objective能容忍不可用多久决定故障转移的自动化程度与冗余度(冷备/热备/多活)
SLI / SLO / SLAIndicator / Objective / Agreement指标(实测) / 目标(内部) / 合同(对外,含赔偿)讲义提到 AWS 在 2011 事故后给客户多日服务抵扣(service credit)
爆炸半径Blast Radius一次故障影响的范围(多少用户/多少分片/多少区域)与故障域大小成正比;控制爆炸半径 = 控制故障域
故障域Fault Domain一起失效的一组组件机架 / 电源域 / 交换机 / AZ / 版本 / 配置批次
错误预算Error Budget$1 - \text{SLO}$ 允许的失败额度预算耗尽则冻结变更(26.2.14)
  • “几个 9”的直观换算表(按一年 8760 小时 / 一个月 730 小时计):
可用性俗称每年停机每月停机现实含义【补充】
99%两个 93.65 天7.3 小时单机、无自动故障转移
99.9%三个 98.76 小时43.8 分钟有冗余,但恢复主要靠人
99.99%四个 952.6 分钟4.4 分钟需要自动故障转移 + 严格变更管理
99.999%五个 95.26 分钟26.3 秒多副本 + 秒级自动切换 + 变更零失误
99.9999%六个 931.5 秒2.6 秒极少数专门设计的系统

要点:从三个 9 到五个 9,停机时间要缩小 100 倍(8.76 小时 → 5.26 分钟)。这 100 倍要么来自 MTTF 提高 100 倍,要么来自 MTTR 缩小 100 倍——26.3.6 会证明这两条路在数学上完全等价,而在工程上极不对称

  • 机制图解:可用性 $A=\frac{\text{MTTF}}{\text{MTTF}+\text{MTTR}}$ 的两条改进路径(纵轴为”9 的个数”,横轴为改进倍数 $k$)。
   9 的个数
     6 |                                        .......=============== 提高 MTTF k 倍(path B)
     5 |                            ....=======                       降低 MTTR k 倍(path A)
     4 |                  ....======
     3 |          ....====
     2 |....======
     1 |
       +------+------+------+------+------+------+------+------+----> 改进倍数 k
              2x     4x     8x    16x    32x    64x   128x   256x
   两条曲线完全重合(A = 1/(1 + MTTR/MTTF) 只取决于比值) ——
   但现实中: MTTF 受物理与规模限制(规模 x10 ==> 系统 MTTF /10),
             而 MTTR 是"设计变量": 自动切换、快速重启、可回滚变更、限流熔断。
  • 关键假设与系统模型:这套指标隐含”服务是稳态的“假设——MTTF/MTTR 用长期均值刻画系统。真实事故往往是非稳态的(故障期间系统行为与平时完全不同,重试、重平衡、冷启动使系统进入另一种工作点),因此平均可用性 99.99% 与”某一次事故连续不可用 8 小时”完全可以并存:前者是统计意义上的,后者是用户真正记住的。讲义的 The Planet 案例就是典型:年度可用性数字可能还”过得去”,但一次 3 天的不可用足以让 7500 家企业客户流失。

26.2.7 案例一:AWS 2011-04-21 EBS/EC2 大规模故障(讲义案例 I)

(1)发生了什么(依据讲义与 AWS 事后报告)

  • 事故发生在 2011 年 4 月 21 日,AWS 事后发布了 post-mortem;受影响的不只是 AWS 自己:Reddit、FourSquare 等大量使用 EC2 的公司同时下线,AWS 的状态面板显示 EC2 与其他存储服务异常。事故至少持续 3.5 天,并造成部分数据丢失
  • 背景(讲义给出的系统结构):AWS 的 Region(区域) 是彼此独立的数据中心(如 us-east-1、us-west-1),每个 Region 由若干 可用区(AZ) 组成,同一 Region 内可以自动跨 AZ 复制数据——但并非所有客户都这么做EBS(Elastic Block Storage)是挂载给 EC2 实例的存储卷;一个 EBS 卷运行在某个 AZ 内部;EBS 有两套网络:primary 网络承载 EC2 与 EBS 控制面流量,secondary 网络用于溢出,容量更低;控制信息跨 AZ 复制以保证可用性;每个卷有一个 primary 副本,若副本失步或节点故障,副本被设计为激进地重镜像(aggressive re-mirroring)数据。
  • 时间线(讲义口径)
    • 12:47 AM:US East 某 AZ 进行例行的 primary 网络容量升级,把若干 primary 路由器的流量切到其他 primary 路由器。关键错误:有人把其中一个路由器的流量切到了 secondary 网络的路由器上。 结果:多个 EBS 卷失去了可用的 primary 网络——primary 网络断开,而 secondary 网络容量低、瞬间被压垮;许多 primary 副本因此没有可用的备份
    • 团队发现该错误并回滚。但由于网络分区,许多 primary 副本仍”认为自己没有 backup”,于是自动开始激进地重镜像:所有卷同时行动,空闲网络容量被瞬间用尽,副本陷入循环——重镜像风暴,波及 13% 的 EBS 卷
    • 控制面(Control Plane)网络不可用:无法响应 EBS 的”创建卷(create volume)”API 请求;控制面操作的超时时间很长,请求开始积压;当线程池被填满后,控制面开始拒绝创建卷的请求。
    • 2:40 AM:团队禁用所有新的”创建卷”API 请求2:50 AM:EBS API 的错误率与延迟开始恢复。
    • 但有两个因素让情况更糟:(a)寻找潜在副本的 primary 副本不做退避(did not back off)(b)EBS 代码中存在一个竞态条件,只有高请求率才能触发——此时被触发了,导致更多节点失败。
    • 5:30 AM:错误率与延迟再次上升。重镜像本质上是 EC2 节点、EBS 节点与 EBS 控制面之间的协商(目的是保证”只有一个 primary”);由于竞态条件,EBS 节点开始失败,协商速率上升,进而导致更多节点失败——如此循环,EBS API 功能进入“半死(brown-out)”状态
    • 8:20 AM:团队开始切断受影响 AZ 内 EBS 集群与 EBS 控制面之间的所有通信;该 AZ 仍然不可用,但控制面开始缓慢恢复。
    • 11:30 AM:团队找到阻止该 AZ 内 EBS 服务器做无效重镜像的方法,受影响 AZ 开始缓慢恢复。
    • 客户直到中午仍在遭遇”新建 EBS 支撑的 EC2 实例”高错误率——因为另一个新上线的 EBS 控制面 API(用于把新 EC2 实例挂载到卷)的错误率被新错误”掩盖”了。中午:不再有新的卷卡住;但13% 的卷仍处于卡死状态
    • 恢复的长尾:到 4 月 24 日中午,除 1.04% 之外的卷全部恢复;最终有 0.07% 的卷永远无法恢复、数据永久丢失。这次故障还影响了单 AZ 部署的 RDS(关系数据库服务)

(2)根因分析

层次根因性质
触发运维人员把 primary 网络路由器的流量切到了低容量的 secondary 网络人为错误(讲义总教训:大故障常始于人为错误)
结构化网络设计允许”把 primary 流量导入容量不足的 secondary 网络”,没有容量护栏变更缺少验证与审计
放大 1分区使副本”以为没有 backup”,同时触发激进重镜像行为相关性 + 无退避
放大 2控制面操作超时过长 → 线程池填满 → 拒绝服务资源耗尽(控制面)
放大 3竞态条件只在高负载下触发 → 节点失败 → 协商加剧 → 更多失败未在高负载下验证的代码
数据损失0.07% 的卷永久丢失副本被同时破坏,无法重建

(3)它违反了哪个理论假设

  • “分区是良性的”:分区不仅让节点无法通信,还让节点基于不完整信息自主做出激进决策(重镜像)。理论模型中分区只影响可用性;这里分区改变了系统的行为,产生了新的负载。
  • “副本故障是独立的”:13% 的卷同时进入重镜像,是共同的触发器 + 共同的策略造成的相关性故障。
  • “故障检测是准确的”(Lecture 5-6):副本把”联系不上”解释为”对方没有备份”,即误判(false positive),并据此采取了破坏性动作。Completeness 得到了保证,Accuracy 被牺牲,代价是数据重镜像。
  • “超时只是延迟问题”:控制面长超时把”慢”变成了”资源耗尽”(线程池被占满),直接导致拒绝服务——这正是 26.1 黄金法则里”问题往往不是崩溃而是变慢”的教科书例子。讲义在具体教训里明确写了”控制面操作超时很长,开始积压,线程池填满后开始拒绝请求“。

(4)暴露了哪些设计缺陷

  1. 没有变更护栏(guardrail):允许把生产流量切到容量低一个数量级的网络路径上,且没有自动化校验。
  2. 重平衡/重镜像没有退避与限速:讲义的具体教训是”防止重镜像风暴:退避而不是激进重试“。
  3. 没有隔离控制面与数据面:控制面线程池被慢操作占满后,连”创建卷”这种基本操作都无法服务;最终只能靠物理切断 AZ 与控制面的通信(8:20 AM)来止血——这说明系统缺少优雅降级开关
  4. 高负载路径上的竞态未被测试:竞态”只在高请求率下触发”,而日常测试从未跑到那个负载。
  5. 恢复缺少长尾处理:13% 卡死的卷、最终 0.07% 永久丢失,说明数据修复路径(重建副本)本身也依赖已经受损的机制。

(5)教训与防御措施(讲义列出的具体改进)

  • 审计网络配置变更流程,为升级制定一步步的协议(step-by-step protocol)
  • 提高 secondary 网络的容量
  • 防止重镜像风暴:退避(backing off)而不是激进重试
  • 修复竞态条件
  • 使用了多 AZ 的客户不受影响(讲义原话:把代码写成利用同一 Region 内多个 AZ 的客户没有受影响)——这是”故障域隔离有效”的正面证据。
  • 更好的沟通工具与健康状态面板、给客户多日服务抵扣

(6)从分布式系统原理角度应该如何防范

原理/机制在本案例中的应用
故障检测的 Accuracy(Lecture 5-6 / Ch.7)把”联系不上”当作”没有备份”是危险的误判;重镜像前应先做确认式探测(多路径 ping、间接探测)、并引入 Suspicion 而非直接判死
分区处理与 quorum(Ch.16/Ch.17)分区期间禁止自动重配置(no automatic reconfiguration in minority),或要求 quorum 才能决定”谁是 primary”,避免脑裂式决策
退避与抖动(Ch.18 / 本章 26.3.2)所有重试/重平衡/重镜像必须有指数退避 + 抖动 + 上限;把”重镜像”改为速率受控的修复队列
超时与资源隔离(Ch.18)控制面调用必须有短超时;控制面与数据面的线程池/连接池必须隔离(舱壁,26.3.4),避免慢操作吃掉全部线程
幂等与 fencing(Ch.20)“保证只有一个 primary”的协商必须有 fencing token / epoch 防止旧的 primary 复活写入(避免脑裂)
端到端校验与副本修复(Ch.22)数据修复要有校验和验证降速重建;修复流量必须与前台流量隔离 QoS
变更管理(本章 26.2.14)网络升级需要金丝雀 + 自动回滚 + 容量校验,而不是人工逐台操作

26.2.8 案例二:Facebook 2010-09-23 全站宕机(讲义案例 II)

(1)发生了什么(依据讲义与 Facebook 事后说明)

  • 事故发生在 2010 年 9 月 23 日,Facebook 不可访问 2.5 小时(讲义称这是”过去 4 年最严重的一次”),Facebook 发布了 post-mortem。
  • 背景:数据存在持久化存储(persistent store)缓存(cache)中;持久化存储是很多台服务器,缓存是很多台服务器上运行的分布式缓存系统;缓存里也包含配置数据(configuration data)。Facebook 有一套自动化系统用于校验缓存中的配置值,并用存储中的新值替换无效值
  • 时间线
    • 9 月 23 日,Facebook 对持久化副本中的某个配置做了一次修改——这个修改是无效的(invalid)
    • 所有客户端(Facebook 的缓存服务器)都看到了这个无效值,于是全部尝试修复它全部去查询数据库集群——数据库很快被每秒十万级的查询压垮
    • 团队修复了那个无效配置。(但——”结束了吗?”)
    • 当客户端从数据库收到错误时,它把错误也解释为”值无效”,于是删掉缓存条目;数据库无法响应时,客户端制造更多查询没有退避(No back off);如此反复循环(rinse-n-repeat)——即级联失败
    • Facebook 的解法关掉整个 Facebook 网站切断所有到数据库集群的流量,数据库恢复;然后缓慢地允许用户回来(让客户端慢慢重建缓存);直到当天更晚整个站点才完全恢复。

(2)根因分析

  • 直接根因:一次配置变更写入了无效值,而所有缓存客户端都被设计成”发现无效值就自动去源头修复”。这是一个”好的设计“(自愈)与”错误的触发条件“(配置错误)相乘的经典事故。
  • 放大机制(三条)
    1. 惊群(thundering herd):所有客户端同时发现同一个无效值,同时去查数据库——负载从 0 瞬间变成每秒十万级。
    2. 语义混用:客户端把”数据库返回错误”也当成”缓存值无效”,于是继续删除缓存条目并继续查询——错误处理逻辑本身制造了更多负载。
    3. 无退避重试:讲义明确指出”没有退避”,形成”查询失败 → 再查询 → 更失败”的正反馈。
  • 止血手段牺牲可用性换取恢复——关站、切断数据库流量、再缓慢放量。这是 ROC 思想(先恢复、再优化)在极端场景下的应用:在”彻底不可用”与”部分可用但永远无法恢复”之间,Facebook 选择了前者

(3)它违反了哪个理论假设

  • “配置是正确的”:所有正确性证明、所有复制协议、所有一致性模型都假定输入是合法的。配置是系统的一部分,但通常没人给它做形式化验证——本章讲义把配置错误当作真实故障的头号来源之一。
  • “客户端彼此独立”:所有客户端共享同一份配置、执行同一段代码、在同一时刻被触发 ⇒ 完美的相关性同步性,等价于一次针对自己数据库的分布式拒绝服务
  • “错误处理是安全的”:客户端的降级/修复逻辑(删缓存 + 重查)在故障时变成负载放大器。理论上的”重试”是提高成功率的手段;这里它成了故障的燃料。
  • “缓存能挡住后端压力”:缓存确实挡住了正常流量,但缓存失效时的”缓存击穿”把全部压力一次性导入后端(惊群)。

(4)暴露了哪些设计缺陷

  1. 配置变更没有校验与灰度:一个无效值被写入持久化副本,没有”先小范围验证再全量”的流程。
  2. “自动修复”缺少协调:所有客户端各自独立地做”修复”,没有协调机制(应由少数节点修复、其余节点退避等待)。
  3. 失败处理没有退避、没有上限、没有熔断:把下游错误当作”可以继续猛打”的信号。
  4. 配置与服务共用同一套依赖:修复配置需要访问数据库,而数据库又被这股修复流量压垮——用故障中的资源去修复故障
  5. 缺少全局开关:最终只能靠”关掉整个站点”这种最粗的开关来止血。

(5)教训与防御措施(讲义明确给出)

  • 新的配置系统设计(Facebook 重做了配置系统)。
  • 当无法访问资源时:不要激进重试,而要退避——”每次请求失败,就等待上一次的两倍时间“,这就是指数退避(Exponential Backoff)
  • 讲义特别提到:指数退避在 802.11 等网络协议中就被用来避免拥塞——即”这是分布式系统的通用手法,不是某个公司的技巧”。

(6)从分布式系统原理角度应该如何防范

原理/机制应用
指数退避 + 抖动(Ch.18 / 26.3.2)缓存失效刷新必须有抖动,避免所有客户端同刻行动;失败重试退避并设上限
惊群抑制 / 单飞(single-flight)同一个 key 只允许一个客户端去后端刷新,其余等待结果(请求合并/去重)
熔断与负载丢弃(26.3.1 / 26.3.3)后端错误率超阈值就快速失败,保护数据库;入口按优先级丢弃
配置的版本化与校验(Ch.19 事务思想)配置写入走事务化流程(校验 → 灰度 → 全量),并把”配置”当作需要审计的数据
幂等与错误语义(Ch.18)严格区分”值无效(可修复)”与”后端故障(应退避)”——错误分类错误本身就是 bug
降级(graceful degradation)拿不到配置时使用上一版有效配置 + 保守默认值,而不是无限重试

26.2.9 案例三:The Planet 2008-05-31 H1 Houston 机房爆炸(讲义案例 III)

(1)发生了什么(依据讲义与 Availability Digest 分析)

  • 事故发生在 2008 年 5 月 31 日The Planet 是当时第四大网络托管公司,支持 2.2 万个网站,拥有 6 个数据中心(休斯顿 2 个、达拉斯 4 个)。这次事故打掉 9000 台服务器、影响 7500 家企业
  • 时间线
    • 5:55 PM:休斯顿 H1 机房发生爆炸——变压器短路起火,进而引燃 UPS 备用电池组、喷出电池酸雾并发生爆炸(讲义标注:级联失败),炸穿了第一层的三面墙
    • 没有任何服务器被损坏,但 9000 台服务器被停机。消防部门疏散大楼,并禁止开启备用发电机(出于消防风险);由于存在火灾危险,工作人员直到晚上 10 点才被允许进入
    • 据报道,The Planet 的员工不得不用皮卡把一些关键服务器物理搬运到其他机房——但又受限于其他机房的电力和冷却能力
    • 6 月 2 日下午 5 点:二层恢复供电;6 月 4 日:一层的服务器逐个机架恢复。整个过程中,The Planet 每 15 分钟到 1 小时向客户通报进展。

(2)根因分析

  • 物理层根因:变压器短路引发火灾,UPS 电池的化学物质参与二次爆炸——供电系统的单点故障演变成了物理灾难
  • 放大因素(关键,且不是软件问题)
    1. 消防处置与设备恢复相冲突:断电是为了消防,但也意味着备用发电机不能启动,冗余电力路径被”安全流程”切断。
    2. 人员无法进场:从 5:55 PM 到 22:00 的物理不可达,使 MTTR 直接以小时计——这是 MTTR 中”检测之外的物理时间“的极端例子。
    3. 容量受限的异地接管:把服务器搬到其他机房是正确思路,但其他机房的电力与冷却余量不足——即”业务连续性计划”缺少容量验证(这正是讲义”总体教训”中说的:测试很重要——American Eagle 在灾难中才发现无法切换到备份机房)。
    4. 同一园区的两个休斯顿机房:从故障域角度看,同一城市、可能同一电力/网络入口的两个机房未必是独立故障域

(3)它违反了哪个理论假设

  • “副本/备份是安全的”:如果备份机房在同一电力域、同一城市、或容量不足以接管,那么”冗余”只是账面上的。
  • “故障检测 + 自动切换就能恢复”:当故障是物理的、需要人进现场的,任何自动化都无济于事——MTTR 的下界由物理过程决定(消防、冷却、供电恢复、逐机架上电)。
  • “故障域 = 网络拓扑”:真实故障域还包括电力、冷却、消防分区、人员可达性、供应商——比机架/AZ 的抽象更宽。

(4)暴露了哪些设计缺陷

  1. 单一供应商边界内做冗余:讲义的经验教训直接点出:数据与服务应当跨数据中心备份,甚至跨不同提供商;但接着问了两个尖锐的问题:“业务连续性计划(Business Continuity Plans)”是谁的责任?是提供商还是客户? 讲义承认对客户而言更难,因为要额外工作、并且存在跨提供商的数据锁定(data lock-in)问题——并提到 “Sky Computing”(UC Berkeley) 作为可能的解法方向。
  2. 成本与保险的类比:讲义指出这类跨机房/跨提供商冗余会让客户花更多钱,并类比为保险费(insurance premiums)——可靠性是要付费购买的。
  3. 容量余量未验证:接管机房的电力/冷却不足,等同于”备份存在但无法挂载”。

(5)教训与防御措施

  • 把备份与业务连续性计划当作可测试的对象:定期做真实切换演练(讲义:American Eagle 在灾难中才发现无法切换到备份机房;”测试很重要“)。
  • 明确责任边界:提供商保证什么(机房、电力、网络)、客户保证什么(跨机房/跨提供商复制、RTO/RPO 目标),并写进合同与 SLA。
  • 容量预留:为接管场景预留电力/冷却/机架空间,而不是”先到先得”。
  • 沟通即产品:The Planet 每 15–60 分钟更新进展保住了客户信任——讲义在多处强调”频繁更新 + 事后公开 post-mortem + 补偿”能增强客户信心

(6)从分布式系统原理角度应该如何防范

原理/机制应用
故障域分层(Ch.2 / 26.2.4)把”电力域、冷却域、消防分区、城市、供应商”都建模为故障域,副本跨最顶层故障域分布
跨区域复制与 quorum(Ch.17/Ch.20)RPO/RTO 由复制模式决定:同步复制 RPO≈0 但要付延迟代价,异步复制要接受数据丢失窗口
静态稳定性(static stability)设计”控制面失效时数据面仍可工作”,避免灾难时刻还要依赖控制面做决策(26.5)
演练与故障注入(26.2.13)混沌工程/演练要覆盖”整个 AZ/机房不可用”这类大事件(讲义中的 Chaos Gorilla 类比)
容量规划(26.5)用 $N+1$、$N+2$ 的容量余量支撑接管场景

26.2.10 案例四:AWS 2025-10-19/20 DNS 空记录与 EC2 租约雪崩(讲义案例 IV)

这是讲义中最新、也是与”元数据/控制面故障”最贴近的案例,它的根因只有一页,但后果横跨 DynamoDB、EC2、网络与负载均衡。

(1)发生了什么(依据讲义与 AWS 事后报告)

  • 事故发生在 2025 年 10 月 19–20 日,大量 AWS 服务受影响:Netflix、Starbucks、United Airlines 下线,某些”智能床”夜里无法工作,Coursera 与 Piazza 也下线。AWS 发布了 post-mortem 与防止复发的措施。
  • 背景DNS 是服务的”目录”(把名字解析到端点/文件等);DynamoDB 是 AWS 的 NoSQL 存储,它用 DNS 解析端点。本次事故中,服务区域端点 dynamodb.us-east-1.amazonaws.com 的 DNS 记录变成了空
  • 时间线
    • 10 月 19 日 11:48 PM PDT:北弗吉尼亚(us-east-1)所有通过 DynamoDB 连接的系统陷入恐慌状态,无法连接 us-east-1 的”副本表(Replica tables)”(其他区域正常)。
    • 10 月 20 日 12:38 AM:工程师定位问题,1:15 AM 施以修复。
    • 2:25 AM:DNS 信息全部恢复,并在 2:32 AM 前复制到 3 个可用区。(”结束了吗?”——没有。)
    • EC2 的次生灾难10 月 19 日 11:48 PM 到 10 月 20 日 1:50 PM,EC2 API 的错误率与延迟上升,新建实例返回 “request limit exceeded”“insufficient capacity”。背景是 DropletWorkflow Manager(DWFM)管理”droplets”即 EC2 实例,而 DWFM 的检查依赖 DynamoDB,因此在 11:48 PM 起失效;11:48 PM–2:24 AM 期间 DWFM 与 droplet 之间的租约(lease)开始超时
    • 2:25 AM:DynamoDB 恢复,但 DWFM 不认为已过期的租约可以被续约——于是需要为 us-east-1 的所有 droplet 重新建立租约,租约系统与 DWFM 被压垮,讲义称之为 “DWFM 的充血性崩溃(Congestive Collapse of DWFM)”
    • 4:14 AM:工程师开始限流(throttle)进来的请求并做选择性重启5:28 AM:DWFM 恢复。
    • 5:28 AM新 droplet 租约的洪流开始传播,压垮了 EC2 的 “Network Manager”6:21 AM:Network Manager 观测到延迟上升,拖慢了 EC2 实例10:36 AM:延迟恢复正常;11:23 AM:工程师开始逐级放松此前施加的限流;1:50 PM:EC2(现有实例与新建实例)恢复正常。
    • 负载均衡器的次生灾难5:30 AM–2:09 PM,新 EC2 实例的网络状态传播延迟影响了部分客户的 NLB(Network Load Balancer)。NLB 是自动扩缩的架构,把流量路由到端点(EC2 实例),并使用健康检查系统。健康检查发现 EC2 实例失败,于是结果在 “failed” 与 “healthy” 之间来回翻转(flip-flopping):判失败就把实例从 DNS 摘掉(增加 DNS 负载),判健康就加回 DNS(又增加 DNS 负载)。连多 AZ 负载均衡器也受影响(延迟升高)9:36 AM:工程师禁用了自动化健康检查系统2:09 PM:EC2 恢复后重新启用。
  • 根因(讲义给出的”空 DNS 记录”六步竞态):加快 DNS 更新的自动化程序名叫 Enactor(每个 AZ 一个)。讲义用”老师让两个学生合写实验记录本”的比喻解释:学生 1 昏昏欲睡、被 TikTok 分心,偶尔醒来猛写一阵;学生 2 勤快、总想”修正”学生 1 写得慢的内容,于是删掉/重写学生 1 之前写的东西;等老师来检查时,本子是空的
  步骤 1: 慢的 DNS Enactor 读取当前 plan 的时间戳         --> 看到的是 plan_old
  步骤 2: 它开始应用 plan_new
  步骤 3: ...与此同时, 快的 DNS Enactor 创建了 plan_newest
  步骤 4: ...快的 Enactor 用 plan_newest 替换了一切
  步骤 5: ...快的 Enactor 删除 plan_new, 因为它比 plan_newest 旧
  步骤 6: 慢的 Enactor 用 plan_new 覆盖 plan_newest
          (此时 plan_new 已因步骤 5 被删除 ==> 内容是空的),
          因为它只在步骤 1 检查过一次 plan 的新鲜度
  结果: DNS 记录为空 ==> 依赖它的服务全部解析失败
  • 讲义总结的根因假设你的程序”跑得足够快”、不会发生并发、因此不需要并发控制。讲义的语气很重:这是分布式系统课上教的最基本的东西——”asynchrony is a way of life(异步是一种生活方式)“,”更多构建分布式系统的工程师真的应该去上一门分布式系统的课“。

(2)根因分析(分层)

层次根因
触发两个 Enactor 并发读写同一份 plan 数据,没有并发控制;慢者基于过期读做覆盖写
数据模型plan 的”新鲜度检查”与”应用”不是原子的(check-then-act 竞态),且删除语义允许”用空内容覆盖”
放大 1DNS 是全局元数据依赖:DynamoDB 解析失败 → 所有依赖它的系统同时失败
放大 2租约批量过期(11:48 PM–2:24 AM)在恢复瞬间变成批量重签(租约风暴)→ DWFM 充血性崩溃
放大 3租约恢复后的洪流压垮 EC2 Network Manager → 网络状态传播慢 → EC2 实例整体变慢
放大 4健康检查在 failed/healthy 之间翻转 → 反复修改 DNS → 进一步加重 DNS 负载;连多 AZ 的 LB 也被拖累

(3)它违反了哪个理论假设

  • “程序执行足够快,不会并发”(最直接的违反):这正是”异步系统中不存在全局时间”(Ch.11)与”check-then-act 必须原子”(Ch.19 并发控制)的直接后果。慢 Enactor 的过期读等价于一个陈旧的快照,它随后把陈旧数据写回,覆盖了更新的版本——丢失更新(lost update)问题。
  • “元数据/控制面故障的影响是局部的”:DNS 是本案例中的全局隐藏单点。讲义反复强调的”控制面一崩,整个数据面瘫痪“,在这里表现为:DynamoDB 的 DNS 记录为空 ⇒ 依赖它的 EC2 租约系统崩溃 ⇒ EC2 API 报 “insufficient capacity” ⇒ NLB 健康检查抖动。
  • “故障恢复后系统会自然回到正常”恢复本身成了新的故障源(租约风暴、状态传播洪流、健康检查翻转)。这是”恢复风暴”(thundering herd on recovery)的经典形态,与 Lecture 3 里 MapReduce 的 straggler、Ch.5 的冷启动风暴同源。
  • “健康检查反映真实健康”:健康检查在 EC2 抖动期间自身振荡,并通过修改 DNS 放大故障——监控/自动化系统在这里成了放大器而非稳定器。

(4)暴露了哪些设计缺陷

  1. 并发控制缺失:对共享的 plan 数据没有版本/锁/CAS(compare-and-swap),也没有”新鲜度在写入前重新校验”。
  2. 自动化缺少安全护栏(guardrails):讲义明确指出:随着自动化(”agents”)增加,故障根因正在从人(70%)转向自动化程序,因此需要给与云基础设施交互的 agent 加安全检查和护栏
  3. 租约设计对”批量过期”不友好:恢复时所有租约必须重建,形成尖峰;应支持分批/错峰续约惰性续约
  4. 健康检查与 DNS 更新耦合:健康检查结果直接改写 DNS,形成”抖动 → 更多 DNS 写入”的正反馈;应加去抖(debounce)/迟滞(hysteresis)每分钟变更上限
  5. 限流是最后手段:讲义时间线显示,真正止血靠 throttle + 选择性重启(4:14 AM)与禁用健康检查自动化(9:36 AM)——即人工接管自动化。这提示我们:自动化必须有”总开关”(kill switch)。

(5)教训与防御措施(讲义的四条总结)

  1. 根因是”假设程序跑得够快、没有并发、因而不需要并发控制“;”asynchrony is a way of life“;更多工程师应当真正学过分布式系统。
  2. “级联的原因让故障一直持续”(Cascading causes keep the outage going) ⇒ 需要新的原则来打断依赖链,并类比”死锁检测已经是一个被解决的问题“——即我们应当为”依赖链/因果链”建立类似的理论与工具(【补充】今天的对应物是爆炸半径控制、依赖图分析、cell 架构与断路器等)。
  3. 数据中心容错类似于今天的医学最常见的病症(崩溃)已经被解决,但罕见病例(意外宕机)依然可怕——即”常见故障有成熟处方,罕见故障仍是疑难杂症”。
  4. 自动化程度提高 ⇒ 根因从人转向 agent需要给 agent 加安全检查与护栏

(6)从分布式系统原理角度应该如何防范

原理/机制应用
异步系统与竞态(Ch.11 / Ch.19)对共享状态一律使用版本号 + CAS 条件写:”只有当我的读版本仍是当前版本时才写入”;或使用 lease + fencing token
单写者/领导者(Ch.16/Ch.17)每个 AZ 的 Enactor 应通过共识/领导者选举保证同一时刻只有一个写入者(或用分区键天然隔离)
幂等与版本化(Ch.20)plan 写入必须幂等且带单调版本,删除操作要区分”逻辑删除”与”物理删除”(避免”用空内容覆盖”)
租约与超时(Ch.18)租约续期错峰(jitter)与批量惰性恢复;租约过期后采用渐进式重建而非一次性全量重建
元数据高可用(Ch.22)DNS/元数据服务要多副本、多路径(多 DNS 提供商、多解析器)——DNS 是所有系统隐藏的单点
健康检查的正确设计(Ch.7)健康判定要有迟滞 + 去抖 + 变更速率限制;”从客户端视角”衡量(灰色故障检测,26.2.3)
爆炸半径控制(26.5)cell 架构把依赖链切短;自动化必须有 kill switch限流闸门

26.2.11 人为错误案例集与其他故障来源(讲义案例 0 与收尾)

讲义用两页幻灯片列出了一批人为错误(Human error)案例,并在课堂竞猜中给出结论:数据中心宕机的首要原因是人为错误,约占 70%。这些案例的共同教训是:再好的冗余设计也可能被”人的一次动作”同时破坏两个副本/两条路径

讲义列出的人为错误案例违反的直觉
一位系统操作员误删了 380 亿美元规模的 Alaska Permanent Fund 数据库,随后又删掉了它的备份备份与生产环境权限未隔离:同一个”删除”动作能同时毁掉两份数据
一家维护承包商的失误关停了 Oakland 空中交通管制中心外包运维的变更管理缺失
弗吉尼亚州的一名技术员拔错了控制器,弄崩了一套此前已经坏了一个控制器的冗余 SAN冗余系统在降级状态下操作,等于无冗余
DBS 银行的一名技术员对冗余 SAN 做了未授权的维修,结果两侧全挂未授权变更 + 双控制器共享同一脆弱点
一位系统管理员在一个 active/active 对中的一台服务器上关闭所有应用以做升级,然后把正在工作的那台也关掉了升级流程缺少状态校验与双人复核
一名测试技术员在测试消防灭火系统前没有禁用火灾报警执行器演练流程缺陷 → 物理停机
警报声(siren)震坏了好几块磁盘,包括虚拟备份磁盘物理环境风险;备份介质与主介质处于同一物理环境
(hosting.com)服务供应商执行了错误的断路器操作顺序,导致 UPS 关机、网站离线 1–5 小时电力操作顺序是强约束流程,却被当作普通操作
1300 万德国网站变黑,因为一名操作员误上传了一个空的 zone fileDNS 变更没有校验与灰度(与 AWS 2025 的”空 DNS 记录”遥相呼应)

方法论提醒:讲义强调”还有很多很多(And many more!)“。研究这些案例不是为了嘲笑谁——事实上经历过事故的公司之后往往建设得更稳健;真正的目的是把”人一定会犯错”作为设计前提,从而把系统设计成”让人难以犯下致命错误“(例如:危险操作需要双人确认、删除要有回收站与延迟生效、变更要能一键回滚、DNS 发布要能自动校验语法与内容完整性)。

此外,讲义在收尾部分列出了其他故障来源与”企业开放程度”的对比:

  • 不是所有公司都像前三家那样透明RIM 2007 年 4 月发生持续一整天的宕机,没有公布任何细节Hostway 2007 年 7 月通知客户要把机房从迈阿密搬到坦帕、停机 12 小时实际停了 3–7 天
  • 测试很重要American Eagle 在灾难中才发现自己无法切换到备份数据中心
  • 升级失败是常见的宕机原因 ⇒ 必须有回退计划(fallback plan)
  • 数据可用性与恢复:BCP(业务连续性计划)、灾难容错、跨机房复制(由提供商做或由客户自己做)
  • 文档一致性:一次 Google AppEngine 故障被拖长,原因是运维人员不知道恢复时该用哪个版本文档;Google 的修复办法是把旧文档显式标记为 “deprecated”
  • 宕机永远是级联的一系列失败需要更多打断链条的方法
  • 其他故障来源抗 DoS 能力互联网中断——海底光缆被切断、DNS 故障、政府通过 DNS 屏蔽互联网(讲义给的解法是备用 DNS 服务)。
  • 很多故障是意外的,但也有计划内停机(例如内核升级):计划内停机必须计划好、步骤文档化并严格执行、回退方案到位

26.2.12 工程实践之一:面向恢复的计算(Recovery-Oriented Computing, ROC)【补充框架,讲义精神一致】

  • 定义与目的面向恢复的计算(ROC)主张把”快速恢复“而不是”永不故障“作为第一设计目标。讲义虽然没有出现 “ROC” 这个词,但它的每一处教训都指向同一件事:宕机不可避免(”Outages are inevitable”)减少恢复时间快速重建而不是精细修复(Facebook 关站再缓慢放量、AWS 2011 最终只能”物理切断 + 逐个恢复”、The Planet 逐个机架上电)。

  • 直观解释(”它是什么?”):传统容错像”给玻璃杯加厚“——期望它不碎;ROC 像”给杯子配一个拖把和一块备用杯“——承认它一定会碎,于是把”碎之后多久能继续喝水”做短。讲义中 The Planet 一边恢复一边每 15–60 分钟通报客户、AWS 事后主动给客户多日抵扣,都是 ROC 的运维面:恢复过程本身要可沟通、可预期

  • ROC 的设计原则
    1. 快速重启优于复杂的状态恢复(”reboot is a good first step”):绝大多数故障源于状态污染(内存损坏、连接泄漏、配置漂移),干净重启往往比原地诊断更快、更可靠。讲义中 AWS 2025 的止血手段之一正是”选择性重启(selective restarts)“。
    2. 减少 MTTR 比增加 MTTF 更有效:见 26.3.6 的定量论证。
    3. 假设每个组件都会失败,并为恢复而设计(design for recovery):包括可丢弃的中间状态幂等重放快速重建缓存(Facebook 恢复时让客户端慢慢重建缓存,正是”缓存可丢弃”的体现)。
    4. 隔离与分区(isolation):把系统切成互不影响的单元,限制爆炸半径(26.5 的 cell 架构)。
    5. 系统化重启优于部分手工修复:部分修复会留下”半坏”状态,导致后续更难诊断(讲义中 “13% 的卷卡死”与”0.07% 永久丢失”就是长尾手工修复的代价)。
    6. 主动故障注入(26.2.13):让恢复路径在平时就被反复走通
  • ROC vs 传统容错的对照表
维度传统容错(Fault Tolerance)面向恢复的计算(ROC)
目标故障时继续正确服务(masks faults)故障后尽快恢复服务(recovers fast)
主要手段复制、冗余、共识、检查点快速重启、隔离、重建、可回滚
优化指标MTTF(提高无故障时间)MTTR(缩短恢复时间)
对”人”的假设人不太犯错人一定会犯错,系统要兜住
对”状态”的态度状态宝贵,需小心保存状态多数可重建/可丢弃,数据才宝贵
典型工程Paxos/Raft 复制组、RAID、双机热备Kubernetes 重启策略、自动故障转移、混沌工程、蓝绿/金丝雀回滚
失效场景遇到”模型外故障”(配置错误、灰故障)即失效天然覆盖模型外故障(因为不依赖故障模型)

26.2.13 工程实践之二:混沌工程(Chaos Engineering)【补充:Netflix 提出;讲义中”测试很重要”与”计划内停机要演练”的思想一致】

  • 定义与目的混沌工程是”在生产环境中主动、持续地注入故障,以发现系统性弱点“的学科。它把”等故障自己暴露”(被动)变成”主动做实验”(主动),因为真实系统的失效模式无法靠阅读代码或单元测试推断——讲义的四个案例里,没有一个弱点是通过代码审查提前发现的。

  • 核心原则(必须列出)
    1. 定义”稳态(steady state)”假设:先用可量化的业务指标(成功率、p99 延迟、吞吐)描述”正常是什么样”,而不是用”CPU 使用率”这类内部指标。
    2. 在真实流量上做实验:用生产流量验证假设,因为测试环境的流量分布永远与生产不同(讲义中 AWS 2011 的竞态”只在高请求率下触发“,就是测试环境跑不出生产的负载)。
    3. 在生产环境做实验,但控制爆炸半径:可以先从小范围的金丝雀/单个实例开始,再逐步扩大;必须有随时终止实验(abort)的开关
    4. 自动化、持续运行:一次性的演练会随时间失效(系统在演进),因此要把实验做成常态化的流水线
    5. 最小化影响范围:实验造成的业务损失必须可接受且可度量(有错误预算支撑)。
  • 典型实验(实践谱系)
    • Chaos Monkey随机关闭生产环境中的实例——验证”单个实例消失时系统是否无感”。对应讲义中”12000 台机器时平均 7.2 小时就有一台故障”的常态。
    • Chaos Gorilla模拟整个可用区(AZ)故障——对应 The Planet / AWS 2011 这类”整机房”事故,验证跨 AZ 冗余与接管容量(对应讲义”American Eagle 才发现无法切换备份机房”的教训)。
    • 延迟注入(latency injection):人为给某个依赖加 200ms/2s 延迟——这是灰色故障最有效的复现手段,也是对本章代码实验 1 的直接工程化。
    • 网络分区注入:切断/丢弃两个 AZ 之间的部分流量,验证非对称分区下的行为(AWS 2011 的分区是真实发生的)。
    • 磁盘填满、CPU 饥饿、内存压力、DNS 故障、证书过期:覆盖资源类与配置类故障。
  • 与”故障注入(fault injection)”的区别:故障注入通常是测试环境中的确定性注入(为了验证某个已知机制,例如”杀掉主节点看是否切换”);混沌工程强调在生产环境、基于稳态假设、持续随机地实验,目的是发现未知的未知(unknown unknowns)。讲义中 AWS 2025 的教训”随着自动化增加,根因正在从人转向 agent“,正是混沌工程需要覆盖的新领域。

  • 前置条件(否则混沌工程就是自毁)必须已经具备基本的容错能力——自动故障转移、限流、熔断、幂等、可观测性。如果系统还没有这些就注入故障,只会把”已知会崩”变成”真的崩”。工程上通用的准则是:先能观测、再能止血、最后才敢主动破坏

26.2.14 工程实践之三:渐进式发布与变更管理(Progressive Delivery & Change Management)

  • 定义与目的变更(配置、代码、网络、内核)是事故最常见的触发器:讲义的经验教训里明确写着”升级失败是常见的宕机原因 ⇒ 需要回退计划“,并提到 Google 因”不知道该用哪版恢复文档”而延长故障1300 万德国网站因误传空 zone file 而消失AWS 2011 因一次网络流量调整而开始 3.5 天的故障渐进式发布就是把”一次性全量变更”改成”小步、可观测、可回滚”的变更。

  • 主要手段

手段机制优点适用与注意
金丝雀发布(Canary Release)先把新版本给小比例流量(1%、5%),观察指标无异常再逐步扩大用真实流量验证;出错时影响面小需要按用户/请求维度的指标对比(不能只看整体平均)
蓝绿部署(Blue-Green)维护两套完整环境,切换流量回滚只需切回,秒级资源成本翻倍;要处理切换瞬间的状态/连接问题
滚动更新(Rolling Update)分批替换实例,配 maxSurge / maxUnavailable 限速不需要双倍资源必须限速:一次替换太多实例等于自减容量
特性开关(Feature Flag)代码部署与功能启用解耦,用开关控制是否走新逻辑可以瞬间关闭出问题的功能而无需重新部署开关本身要有审计与清理机制,否则成为”技术债 + 新故障源”
快速回滚回滚必须比发布更快、更可靠,并且经常演练是 MTTR 的主要压缩手段回滚也可能失败(数据格式不兼容),因此要向前兼容
变更冻结(Change Freeze)大促/节假日/关键期禁止非紧急变更消除最大的一类风险需要配套的紧急变更通道与审批
变更审计记录谁、何时、改了什么、如何回滚事故复盘的基础;讲义中 AWS 2011 的具体教训第一条就是审计网络配置变更流程要与配置即代码(IaC)结合
  • 机制图解:金丝雀发布 + 快速回滚的时间线(错误率随发布进度的变化)。
   错误率
     ^
 5%  |                                                    ***  <-- 若继续扩大就会全站爆发
     |                                                  **
 2%  |                                                **
     |                                               *
 1%  |                                              *
     |                                             *
0.5% |                                         ****
     |                                     ****
 0.1%|***************************** *******
     +------+--------+--------+--------+--------+--------+--------+-------> 时间
          T0       T1       T2       T3       T4       T5       T6
          |        |        |        |        |        |
       部署 1%  观察 10min 扩到 5%  扩到 25% 指标越界  自动/人工回滚
                        (指标平稳)         (错误率 0.5%->2%)   |
                                                             v
                                                    100% 流量回到旧版本
                                            总影响面: 全站的 5%, 持续 ~4 分钟
   ★ 关键: (1) 每一步都要有"自动刹车"的判据(错误率/延迟/饱和度);
          (2) 回滚时间 << 扩量时间;  (3) 配置/开关变更与代码变更一样走这套流程。
  • 配置变更为什么是最危险的一类:代码变更通常有测试、有 review、有编译检查;配置变更往往直接生效、没有类型检查、没有回滚、且影响所有实例。讲义的两个案例(Facebook 的无效配置值、德国 1300 万网站的空 zone file)都是”一次配置写入 = 一次全站故障”。因此工程上把配置当作代码:版本化、评审、灰度、可回滚、有 schema 校验。

26.2.15 工程实践之四:限流、熔断、舱壁、超时、重试与降级

这是本章最实用的一组机制。它们共同回答一个问题:当某个依赖变慢或不可用时,如何让”故障”停留在局部,而不是吃掉整个系统?

(a)限流与负载丢弃(Rate Limiting / Load Shedding)
  • 定义与目的主动限制进入系统的请求量(或丢弃一部分请求),以保证被服务的请求获得可接受的延迟。核心认识是:过载时”对所有人变慢”比”给一部分人快速失败”更糟——前者会让所有人超时,后者至少保住了一部分用户。
  • 实践要点
    • 按优先级丢弃:优先丢低价值/可重试/后台类流量,保住支付、登录等核心路径(26.3.3 给出伪代码)。
    • 过载信号的选择:入口排队长度、连接池占用率、p99 延迟,比 CPU 利用率更早、更准(CPU 在”线程全在阻塞等 IO”时反而不高)。
    • 配合背压(backpressure)与队列上限:无界队列只是把过载推迟成”内存耗尽 + 延迟无界”。
  • 讲义印证:AWS 2025 中工程师”开始限流进来的请求“是恢复的关键一步;AWS 2011 中”禁用所有新的创建卷 API 请求“是最有效的止血动作之一(2:40 AM 禁用,2:50 AM 错误率开始恢复)。
(b)熔断器(Circuit Breaker)
  • 定义与目的:当下游错误率超过阈值时,快速失败(不再调用下游),从而保护调用方自己的线程/连接等资源;经过冷却后试探性放行少量流量,成功则恢复。三种状态:Closed(正常放行)→ Open(快速失败)→ Half-Open(试探)
  • 直观解释(”它是什么?”):家里的空气开关。电路短路时它跳闸(Open),不是因为它能修电路,而是因为继续通电会烧掉整栋房子;过一会儿你手动合闸试试(Half-Open),如果还短路就继续跳闸。26.3.1 给出完整伪代码与正确性论证。
  • 讲义印证:AWS 2011 中”控制面线程池被填满后开始拒绝请求“是一种”被动熔断”(没有主动熔断器,只能等资源耗尽);AWS 2025 中工程师”禁用自动化健康检查系统“实质上是在人工执行熔断。
(c)舱壁隔离(Bulkhead)
  • 定义与目的:为不同的下游依赖分配独立的资源池(线程池/连接池/队列),使一个依赖的故障不能耗尽所有资源
  • 直观解释(”它是什么?”)轮船的隔水舱——一个舱进水,整条船还能浮着。软件上就是”不要用同一个线程池调用所有下游”。
   反例: 共享线程池 (一个慢依赖吃掉全部资源)          正例: 舱壁隔离 (每个依赖各自一个池)
   ┌───────────────────────────────────────┐     ┌──────────────┐  ┌──────────────┐
   │            共享线程池 (60)            │     │  池 A (42)   │  │  池 B (18)   │
   │ ┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐ │     │ ┌────┐┌────┐ │  │ ┌────┐┌────┐ │
   │ │DB  ││DB  ││DB  ││DB  ││DB  ││DB  │ │     │ │DB  ││DB  │ │  │ │AUX ││AUX │ │
   │ └────┘└────┘└────┘└────┘└────┘└────┘ │     │ └────┘└────┘ │  │ └────┘└────┘ │
   │  ▲ DB 变慢 ==> 全部线程被 DB 调用占住 │     │  慢依赖只占  │  │  健康依赖完全 │
   │  健康依赖(aux)拿不到任何线程 ==> 全灭 │     │  自己的池子   │  │  不受影响     │
   └───────────────────────────────────────┘     └──────────────┘  └──────────────┘
   讲义印证: AWS 2011 "控制面线程池被填满" ==> 连"创建卷"都无法服务;
             AWS 2025 "DWFM 的检查依赖 DynamoDB" ==> DWFM 被拖垮(充血性崩溃)。
  • 代价与陷阱:分池会降低资源利用率(每个池都要留余量),并引入”池大小调优”问题;分得太细会导致资源碎片化。实践上常用信号量(semaphore)限并发而不是物理分池。
(d)超时(Timeout)必须设置,而且必须传播
  • 定义与目的没有任何一次远程调用可以不设超时。超时的作用是给资源占用设上界:只要每个调用都能在有限时间内返回(成功或失败),线程/连接就能被回收,系统就不会被”慢”击垮。
  • 讲义印证:AWS 2011 的根因链里,”控制面操作的超时时间很长“直接导致线程池被填满——如果超时合理,慢操作会被及时放弃,线程池不会被占满。因此超时不是”有没有”的问题,而是”设多长”的问题(默认值往往过长:本章实验 1 中”库默认 3 秒”与”容量规划后的 250 毫秒”造成了天壤之别)。
  • 实践要点:超时要沿调用链传播(上游的剩余预算必须传给下游,即 deadline propagation),否则下游可能在”上游已经放弃”的情况下继续消耗资源;超时值要略大于 p99 延迟,并对不同重要级别的请求使用不同预算。
(e)重试(Retry)必须有界、退避、抖动,并有预算
  • 定义与目的:重试能掩盖瞬时故障(偶发丢包、leader 切换的短暂不可用),但会放大持续故障的负载。因此在”故障”场景下,重试是双刃剑
  • 必须同时满足的四条
    1. 有界:最多重试 $r$ 次(讲义 Facebook 案例的解法:不要激进重试)。
    2. 指数退避:讲义原话——”每次请求失败,就等待上一次的两倍时间“(Exponential Backoff,802.11 等协议就是这么做的)。
    3. 抖动(jitter):避免所有客户端在同一时刻重试(惊群),26.3.2 给出数学分析。
    4. 重试预算(retry budget):给重试流量设总量上限(例如”重试不得超过正常请求量的 10%”)或”下游错误率超过某阈值就不再重试”(本讲义代码实验 1 的 S6 场景即如此)。
  • 额外要求只重试幂等操作(Lecture 18:at-least-once 语义 + 幂等才能安全重试;讲义提到 Sun RPC 采用 at-least-once 语义,而在故障下保证 exactly-once 很困难)。
(f)降级(Graceful Degradation)
  • 定义与目的:在依赖不可用时返回部分结果、缓存结果、默认值或简化功能,而不是完全失败。
  • 讲义印证:Facebook 恢复期”慢慢允许用户回来、让客户端慢慢重建缓存“,本质是渐进式降级恢复;讲义最后也提到”很多故障是意外的“,因此系统需要有”降级态”的设计(例如:只读模式、关闭个性化推荐、返回稍旧的数据)。
  • 要点:降级要显式设计并定期演练(否则第一次使用降级路径就是在真实事故中);降级结果要对调用方可区分(不能把降级的旧数据当成最新数据,否则会破坏一致性假设)。

26.2.16 工程实践之五:可观测性(Observability)与监控

  • 定义与目的可观测性指”仅通过系统对外输出的数据,就能推断系统内部状态”的能力。事故复盘的第一步永远是把时间线还原出来——讲义对每个案例都给出了精确到分钟的时间线,这本身就是可观测性的产物。

  • 三大支柱
    1. 指标(Metrics):随时间聚合的数值(QPS、错误率、延迟分位数、饱和度)。必须看分位数而不是平均值——平均值会把”1% 的用户等 10 秒”掩盖成”平均 100ms”。
    2. 日志(Logs):带结构化字段的事件记录;事故时用于定位第一个异常。讲义中”某台 primary 副本认为没有 backup”这类判断,只能从日志里看出来。
    3. 分布式追踪(Traces):记录一个请求在整条调用链上的因果路径与每跳耗时。【补充】这本质上是 Lecture 12 全局快照(Chandy-Lamport)思想的工程化:把”请求的因果链”记录下来,让”卡在哪一跳”变成可查询的事实,而不是靠猜。这也是检测灰色故障的关键手段。
  • 黄金信号(Golden Signals)【补充:Google SRE】延迟(Latency)流量(Traffic)错误(Errors)饱和度(Saturation)。四个信号一起看,才能在”变慢但不报错”的灰色故障中发现问题。

  • 告警质量
    • 避免告警疲劳:告警必须可行动(actionable),否则值班工程师会忽略真正的告警。
    • 基于 SLO 的错误预算告警:不问”CPU 是否超过 80%”,而问”错误预算是否在被快速消耗“——因为前者与用户体验无关,后者直接对应业务损失。
    • 从客户端视角测量(26.2.3 灰色故障):服务器自报的健康度会骗人,用户视角的成功率与延迟不会
  • 讲义印证:讲义在收尾时特别提到,AWS、Facebook、The Planet 都在事故中持续向用户通报进展,并运行实时状态面板(Google Apps status dashboard、AWS dashboard);AWS 2011 的具体教训之一是”更好的沟通工具与健康状态(AWS Dashboard)“。可观测性同时服务于对内止血对外沟通两个目的。

26.2.17 工程实践之六:冗余的正确做法(破除常见误解)

  • 误解一:”有 3 个副本所以可靠。” —— 若 3 个副本在同一机架/同一 AZ/同一电源域,一次机房级事件就全灭(26.2.4)。副本数不等于可靠性,故障域数量才等于可靠性。
  • 误解二:”加了冗余就没有单点。” —— 冗余本身会引入新的相关性:同一个配置管理系统、同一次发布、同一个 DNS、同一个认证服务、同一个打包镜像、同一个自动化 agent(AWS 2025 的 Enactor 就是”每 AZ 一个”的自动化冗余,却共享同一个 plan 数据)。要沿着”变更路径”和”控制面路径”找相关性,而不只是沿着网络拓扑找。
  • 误解三:”冗余能防软件 bug。” —— 同一个 bug 会在所有副本上同时发作(同版本、同配置、同输入)。这正是 26.4.2 实验要说明的:任何放置策略都无法防御相关故障,唯一缓解是多样化(diversity)——版本异构、实现异构、供应商异构、路径异构。
  • 冗余的代价(必须算清楚):3 副本意味着 3 倍存储成本约 3 倍的写入带宽/网络开销(讲义在 Lecture 2 的云经济学与 Lecture 22 的复制讨论中都涉及成本);跨 AZ/跨区域复制还要付跨区流量费更高写延迟。因此工程上常做分级:核心元数据 3 副本跨 AZ,冷数据用纠删码,缓存不做持久冗余。
  • 主动-主动 vs 主动-被动
    • 主动-主动(active-active):所有副本都服务流量,故障时无切换时间(RTO≈0),但需要解决冲突处理(并发写同一份数据)与一致性模型选择(Lecture 10/Ch.10 的一致性模型谱系)。
    • 主动-被动(active-passive):备用副本平时不服务,故障时切换;实现简单、无冲突,但 RTO 取决于检测 + 切换时间(这正是 MTTR 中最大的一块)。
  • 正确做法清单:定义故障域层级 → 用反亲和性约束把副本铺到最顶层故障域 → 为每个故障域预留接管容量($N+1$/$N+2$)→ 定期演练真实切换 → 对变更路径做多样化与灰度。

26.2.18 工程实践之七:事后分析(Postmortem)文化

  • 定义与目的事后分析是把一次事故转化为组织记忆的流程。讲义要求的动作很具体:公开的事后报告 + 频繁更新 + 对客户的补偿,并明确指出这些”都增强了客户信心“——事故处理得好,反而能提升信任。

  • 无指责(blameless)原则关注系统与流程的缺陷,而不是个人的过失。理由是工程性的,而不是道德性的:人一定会犯错(讲义竞猜:人为错误占 70%),如果复盘变成追责,信息就会被隐藏,组织就学不到东西。正确的问法不是”按错了按钮”,而是”为什么系统允许一次按钮操作造成全站故障“,以及”为什么这个错误没有被护栏拦住、没有被监控发现、没有被快速回滚“。

  • 事后报告的内容清单
    1. 时间线(精确到分钟,包含”我们当时以为发生了什么”);
    2. 影响面(多少用户/多少请求/多少数据,以及没有受影响的范围);
    3. 根因(root cause)促成因素(contributing factors)——通常是多个,讲义反复强调”宕机永远是级联的一系列失败“;
    4. 哪些”侥幸”避免了更坏的结果(near misses),这一项最容易被忽略,却最有价值;
    5. 哪些机制起了作用(例如 AWS 2011:使用了多 AZ 的客户没有受影响——这是对”故障域隔离有效”的实证);
    6. 行动项(action items):必须具体、可验证、有优先级、有负责人与截止日期(”加强安全意识”不是行动项,”把控制面超时从 30s 降到 2s 并在 3 月 1 日前完成灰度”才是)。
  • 共享与学习:讲义的四个案例之所以能进入课堂,正是因为 AWS、Facebook 公开了 post-mortem;讲义同时批评了不透明的公司(RIM 2007 宕机一天不公布细节Hostway 承诺 12 小时实际停 3–7 天)。公开复盘是行业级的学习机制
  • 制度化:把”故障演练 + 复盘 + 行动项跟踪 + 错误预算”连成一个闭环:复盘产出的行动项必须进入待办并被执行,否则事故会以同样的形式再来一次(讲义:”宕机永远是级联的一系列失败 ⇒ 需要更多打断链条的方法”)。

26.3 算法伪代码与正确性分析

本节给出五份防护机制的伪代码与正确性论证,外加一份”用可用性公式做定量决策”的数学论证。它们共同构成上一节工程实践的可执行骨架:熔断器回答”何时不再尝试”,退避重试回答”如何尝试”,负载丢弃回答”放弃谁”,舱壁回答”资源怎么分”,放置算法回答”副本放哪里”,而 26.3.6 回答”为什么这些机制的价值主要不在提高 MTTF,而在压缩 MTTR“。

算法 26.3.1:熔断器(Circuit Breaker)

假设与系统模型

  • 环境:异步系统。调用方 $C$ 通过远程调用访问依赖 $D$;一次调用可能成功返回错误、或超时(超时按错误处理)。
  • 故障模型:$D$ 可能变慢(灰色故障)部分失败完全不可用;$C$ 无法区分”$D$ 慢”与”$D$ 坏”
  • 通道假设:消息可能丢失、延迟、乱序(因此超时与错误必须同等对待)。
  • 目标保护调用方自身的资源(线程/连接),而不是修复 $D$;即把”$D$ 的故障”限制在”$D$ 自己的流量”范围内。
  • 参数:统计窗口 $W$、最少样本数 $N_{\min}$、失败率阈值 $\theta$、冷却时间 $T_{\text{cool}}$、半开试探数 $K$。

机制图解:三状态机与转移条件。

                         窗口内样本数 >= N_min  且  失败率 > θ
        +-------------------+  (下游连续失败, 且被算作失败的超时也计入)  +-------------------+
        |                   |------------------------------------------>|                   |
        |      CLOSED       |                                           |       OPEN        |
        |  (正常: 放行全部)  |                                           | (保护: 立即失败)   |
        |                   |<------------------------------------------|                   |
        +-------------------+        半开期间 K 次试探全部成功           +-------------------+
                 ^                        (且无任何一次失败)                     |
                 |                                                               | 经过 T_cool
                 |                                                               | (不需要外部信号)
                 |                                                               v
                 |              +-----------------------------------+
                 +--------------|            HALF_OPEN              |
                   全部成功      |  (试探: 只放行 K 个, 其余立即失败) |
                                +-----------------------------------+
                                                 |
                                                 | 试探中只要有 1 次失败
                                                 v
                                        (回到 OPEN, 且重置冷却计时)
   副作用对照:  每次 CLOSED 失败 --> 写窗口   每次 OPEN 请求 --> O(1) 失败, 零网络流量
               每次试探调用   --> 超时按失败处理(否则会卡在 HALF_OPEN)

伪代码

变量(调用方 C 本地状态):
    state ∈ {CLOSED, OPEN, HALF_OPEN}   ← CLOSED
    window: 环形缓冲, 记录最近 W 秒内每次调用的 (时刻, 结果)     ← 空
    opened_at: 时间戳                                            ← ⊥
    trials_left: 半开状态剩余试探配额                            ← 0
    trial_inflight: 已放行但未回报结果的试探数                   ← 0
    trial_failed: 半开期间是否已出现失败                         ← false

upon event ⟨request, op⟩ at C:
    if state = CLOSED:
        send ⟨call, op⟩ to D ;  return PENDING
    if state = OPEN:
        if now - opened_at ≥ T_cool:
            state ← HALF_OPEN ;  trials_left ← K
            trial_inflight ← 0 ;  trial_failed ← false
            # 落到下面的 HALF_OPEN 分支处理本次请求
        else:
            return FAIL_FAST            # 不产生任何网络流量, O(1) 返回
    if state = HALF_OPEN:
        if trials_left > 0:
            trials_left ← trials_left - 1
            trial_inflight ← trial_inflight + 1
            send ⟨call, op⟩ to D ;  return PENDING
        else:
            return FAIL_FAST

upon event ⟨reply, result⟩ at C:        # result ∈ {OK, ERROR}
    failed ← (result = ERROR)
    if state = HALF_OPEN:
        trial_inflight ← trial_inflight - 1
        if failed:
            trial_failed ← true ;  state ← OPEN ;  opened_at ← now
        else if trial_inflight = 0 and not trial_failed:
            state ← CLOSED ;  window ← 空
    else if state = CLOSED:
        window.append((now, failed))
        丢弃 window 中所有时刻 < now - W 的项
        if |window| ≥ N_min and failure_ratio(window) > θ:
            state ← OPEN ;  opened_at ← now
    return result to 调用方

upon event ⟨timeout, op⟩ at C:          # ★ 关键: 慢 = 失败
    treat as ⟨reply, ERROR⟩

算法逻辑解说

设 $\theta=0.5$、$N_{\min}=20$、$W=2\,\text{s}$、$T_{\text{cool}}=5\,\text{s}$、$K=3$。走一遍数值例子:

  1. $t=0$ 起 $D$ 变慢,2 秒内发生了 20 次调用,其中 12 次超时(失败率 $0.6>0.5$,样本数 $20\ge N_{\min}$)⇒ 状态转为 OPEN,记录 opened_at
  2. $t=2\,\text{s}\sim 7\,\text{s}$:所有请求走 FAIL_FAST 分支,零网络流量、微秒级返回。调用方的线程/连接被立刻释放——上游因此不会跟着一起慢(这就是”打断级联链”)。
  3. $t=7\,\text{s}$:冷却期满,转 HALF_OPENtrials_left=3。此刻恰好来了一批请求:前 3 个被放行去试探(trial_inflight=3),其余全部 FAIL_FAST
  4. 3 个试探中 2 个成功、1 个超时 ⇒ trial_failed=true ⇒ 回到 OPENopened_at 重置为现在——冷却重新开始,试探流量被限制在”每 5 秒最多 3 个调用”。
  5. 若 $t=20\,\text{s}$ 时 $D$ 真的恢复了:3 个试探全部成功,trial_inflight 归零且 trial_failed=false ⇒ 状态回 CLOSED,窗口清空,恢复正常放行。

正确性论证

  • 安全性(Safety)——熔断器能防止级联失败

    引理 1(在途调用数有上界):在 $D$ 持续故障期间,$C$ 对 $D$ 的在途调用数不超过 $K$证明:状态机中只有 CLOSED 与 HALF_OPEN 会执行 send。在 HALF_OPEN 下,trials_left 单调递减且只在进入 HALF_OPEN 时被重置为 $K$;而进入 HALF_OPEN 必须经过至少 $T_{\text{cool}}$ 的 OPEN 期。因此在任一长度为 $T_{\text{cool}}$ 的窗口内,放行的试探调用数 ≤ $K$,且同时在途数 ≤ $K$。$\square$ 推论:$D$ 慢下来时,被 $D$ 吞噬的调用方资源与请求到达率解耦——这正是级联失败的反面。对比没有熔断器的情况:每个请求都要等到超时才释放,在途调用数 $\approx \lambda \cdot \tau_{\text{timeout}}$(随到达率线性增长),线程池必然被填满。

    引理 2(快速失败保证调用方可用):OPEN 状态下每次请求的额外耗时是 $O(1)$(一次状态判断),不占用网络连接与下游超时预算。因此调用方对其他依赖的服务能力不受 $D$ 影响——这是”故障横向扩散”被阻断的形式化表述。 依赖的假设:(a)阈值 $\theta<1$ 且窗口样本足够,否则不会触发;(b)超时必须被计入失败,否则灰色故障(”全部成功但都很慢”)永远不会触发熔断。

    反例(阈值假设不可缺):若只统计”显式错误”而不统计超时,则一个”接受了所有请求但每个都 30 秒才回”的依赖可以让熔断器长期停留在 CLOSED,调用方线程池被占满——即讲义中 AWS 2011 “控制面操作超时很长 ⇒ 线程池填满 ⇒ 开始拒绝请求”的机制。

  • 活性(Liveness)——半开状态的必要性

    定理 1(不会永久打开):若状态为 OPEN,则当 $now - opened\at \ge T{\text{cool}}$ 时,下一次请求必然把它推进到 HALF_OPEN。该转移不需要任何外部事件(不依赖 $D$ 主动通知、不依赖运维介入),因此熔断器必然会在有限时间内重新试探。$\square$ 为什么必须有半开状态:如果没有 HALF_OPEN(只允许 CLOSED/OPEN 两态),那么一旦打开就只能靠外部信号(人工重置或健康检查通过)才能关闭;而”依赖恢复”这件事恰恰无法从调用方内部观测——于是熔断器要么永久打开(可用性永久损失),要么被迫用固定周期盲目全量重开(把恢复瞬间的流量一次性打回仍未稳定的依赖,制造二次故障)。HALF_OPEN 用”小额、限速、可失败“的试探同时解决了这两个问题。

    定理 2(恢复条件明确):若 $D$ 已恢复,$K$ 次试探将全部成功 ⇒ trial_inflight=0trial_failed=false ⇒ 转 CLOSED;若 $D$ 未恢复,至少一次试探失败 ⇒ 转 OPEN 并重置冷却 ⇒ 试探流量被限制在每 $T_{\text{cool}}$ 至多 $K$ 次。$\square$

    实现的隐含约束:若试探调用悬挂(既不成功也不失败),trial_inflight 永不归零,熔断器会卡在 HALF_OPEN。因此试探调用必须有超时(伪代码中”超时按 ERROR 处理”正是关闭该漏洞的必要条件)。

  • 代价(诚实说明):熔断器会放弃一部分本来可能成功的请求。本章 26.4.1 的实验数据显示:加了熔断器的场景中,”故障依赖这一类请求”的成功率从 16.8% 降到 13.8%——熔断器用”牺牲故障依赖的流量”换取”调用方与健康依赖的存活”(健康依赖的成功率从 32.2% 升到 56.8%,P95 从无熔断时的”长时间排队”变成快速失败)。这正是设计者需要明确接受的取舍。

复杂度

  • 时间:每次调用 $O(1)$(窗口为环形缓冲,摊销 $O(1)$;滑动窗口也可用计数器分桶实现)。
  • 空间:$O(N_{\min})$(窗口)+ $O(1)$(状态)。
  • 网络开销:CLOSED 时与业务调用相同;OPEN 时 0;HALF_OPEN 时每 $T_{\text{cool}}$ 至多 $K$ 次调用。相对”无熔断 + 无限重试”,故障期间发往 $D$ 的流量从 $\Theta(\lambda)$ 降到 $O(K/T_{\text{cool}})$。

算法 26.3.2:带抖动的指数退避重试(Exponential Backoff with Jitter)+ 重试预算

假设与系统模型

  • 环境:异步系统;$n$ 个相互独立的客户端并发访问同一服务 $S$。
  • 故障模型:$S$ 可能返回瞬时错误(可重试)或持续错误/过载(重试有害)。
  • 通道假设:消息可能丢失、重复、延迟——因此重试必须假设”上次调用可能已经执行”(需要幂等或去重)。
  • 目标:在瞬时故障下提高成功率;在持续故障下不放大负载。
  • 参数:最大重试次数 $r_{\max}$、退避基数 $b$、单次退避上限 $b_{\max}$、整请求截止时间 $D$、重试预算比例 $B$、预算窗口 $W_b$。

伪代码

参数: r_max, b, b_max, D(整请求截止时间), B(重试预算比例), W_b(预算窗口)

变量(客户端 i 本地):
    budget_used ← 0                # 窗口 W_b 内已用掉的重试次数
    attempt ← 0

upon event ⟨call, op⟩ at client i:
    attempt ← 0
    loop:
        if now ≥ deadline:                 # ① 截止时间(上层 SLO)优先
            return FAIL_DEADLINE
        send ⟨call, op, key=idempotency_key(op)⟩ to S
        wait for ⟨reply⟩ with timeout T
        if ⟨reply = OK⟩:  return OK
        attempt ← attempt + 1
        if not idempotent(op):  return FAIL           # ② 非幂等: 绝不重试
        if attempt > r_max:     return FAIL           # ③ 有界
        if budget_used ≥ B · requests_sent_in(W_b):   # ④ 重试预算
            return FAIL_BUDGET
        budget_used ← budget_used + 1
        d ← min(b · 2^(attempt-1), b_max)             # ⑤ 指数退避
        sleep random_uniform(0, d)                    # ⑥ 全抖动(full jitter)
    end loop

算法逻辑解说

设 $b=100\,\text{ms}$、$r_{\max}=3$、$b_{\max}=2\,\text{s}$。三次重试的退避基准是 $d_1=100$、$d_2=200$、$d_3=400\,\text{ms}$;采用全抖动后,实际等待是 $[0,d_k]$ 上的均匀随机数(期望 $50/100/200\,\text{ms}$)。若使用无抖动的固定退避,那么”在 $t_0$ 同时失败的所有客户端”会在 $t_0+100$、$t_0+300$、$t_0+700\,\text{ms}$ 三个时刻同步发起重试——这三个人为的尖峰就是”重试风暴”,也正是 Facebook 2010 事故中”所有缓存服务器同时去查数据库、数据库被每秒十万级查询压垮”的成因。

正确性论证

  • 安全性 1(不放大:重试量的硬上界)引理:对每个用户请求,客户端最多发出 $1+r_{\max}$ 次调用;若预算生效,则全局重试流量 ≤ $B \times$ 正常流量。 证明attempt 单调递增且被 $r_{\max}$ 截断;budget_used 单调递增,且每次重试前都必须通过 $budget\used < B\cdot requests\_sent\_in(W_b)$ 的检查,因此在任意窗口内重试次数不超过正常请求数的 $B$ 倍。$\square$ *推论*:下游面对的最坏负载是 $(1+r{\max})$ 倍(单层);若 $L$ 层各自重试,则最坏 $(1+r_{\max})^L$ 倍——这就是”重试放大”的数学形式,也是必须在多层同时设置预算的原因(不能只在客户端设)。

  • 安全性 2(抖动打散惊群)命题:设 $n$ 个客户端在 $t_0$ 同时失败。无抖动时,第 $k$ 次重试在 $t_0+\sum_{j\le k} d_j$ 处形成幅度为 $n$ 的尖峰;全抖动时,第 $k$ 次重试时刻是 $[t_0+S_{k-1},\,t_0+S_k]$($S_k=\sum_{j\le k} d_j$)上的独立均匀随机变量,因此在”每个 1 ms 时间槽内到达的请求数”这一度量上,期望峰值从 $\Theta(n)$ 降到 $\Theta(n/(1000\,d_k))$ 量级。 证明要点:$n$ 个独立同分布随机变量之和的集中程度由标准差控制($\propto\sqrt{n}$),而不是像同步情形那样退化成”全部落在同一时刻”。$\square$ 实践含义:抖动不减少总重试次数,只是把同样的重试量摊平在时间上;它把”同步冲击”变成”可被排队系统吸收的平滑负载”。对排队系统而言,这意味着下游的有效利用率 $\rho=\lambda\bar{s}$ 的瞬时峰值被削掉,从而避免”过载 → 超时 → 重试 → 更过载”的正反馈。

  • 活性(Liveness)定理:若故障是瞬时的(首次重试即成功),请求在 $\le 1+r_{\max}$ 次尝试内返回 OK;若故障持续,请求在 $\le r_{\max}$ 次尝试与截止时间 $D$ 之内必然终止(返回 FAIL 而非无限重试)。$\square$ 因此重试机制不会让请求无限悬挂——这一点对 MTTR 至关重要(无限悬挂 = 资源永不释放 = 级联失败)。

  • 依赖的假设(必须明说)
    1. 幂等性:重试的前提是”重复执行不产生额外副作用”(Lecture 18:at-least-once + 幂等 ≈ 实际效果上的 exactly-once)。非幂等操作(如”转账”而不带去重键)重试会造成重复副作用。
    2. 下游有吸收能力:$(1+r_{\max})(1+B)$ 倍的瞬时负载必须在下游容量之内。若下游已经饱和,任何重试策略都是有害的——这也是”熔断 + 负载丢弃”必须与重试同时存在的原因。
    3. 预算判据可观测:错误率/预算统计依赖可观测性(26.2.16);判据失准时预算形同虚设。
  • 反例(”重试能提高可用性”错在哪):在”下游容量已经不足”的场景中,重试不改变下游的有效容量(容量与到达率的差决定成功量),只会增加队列长度与延迟。本章 26.4.1 的实验直接量化了这一点:加入无退避重试后,入口尝试次数放大到 1.42 倍,积压峰值从 11793 升到 15562,平均延迟从 443 ms 升到 478 ms,而成功率没有任何提升(16.7% → 16.7%)——这就是讲义 Facebook 案例”重试没有退避、rinse-n-repeat”的定量复现。

复杂度

  • 时间:每次用户请求 $O(1+r_{\max})$ 次调用;累计等待 $\le \sum_{k=1}^{r_{\max}} \min(b\cdot 2^{k-1}, b_{\max})$(几何级数,$O(b\cdot 2^{r_{\max}})$,被 $b_{\max}$ 截断)。
  • 空间:$O(1)$(每个请求),加客户端的预算计数器 $O(1)$。
  • 消息复杂度:每次用户请求 $O(1+r_{\max})$ 条消息;全系统重试消息量 $\le B$ 倍正常量(预算生效时)。

算法 26.3.3:负载丢弃与优先级队列(Load Shedding with Priority Queue)

假设与系统模型

  • 环境:单服务(或单个前端),请求到达率 $\lambda$ 可能超过服务能力 $\mu$(过载)。
  • 故障模型:过载是”慢故障”——不是崩溃,而是排队长度与延迟同时发散。
  • 请求模型:每个请求携带优先级 $p\in\{HIGH, MED, LOW\}$;超过延迟预算 $D$ 的结果对用户没有价值(超时的成功等于失败)。
  • 目标:过载时保住高优先级请求的服务质量,而不是”让所有请求一起变慢”。
  • 参数:并发上限 $C$、软阈值 $L_{\text{soft}}$、硬上限 $L_{\text{hard}}(\gg L_{\text{soft}})$、延迟预算 $D$、中优先级自适应准入概率 $p_{\text{admit}}$。

伪代码

变量(服务端本地):
    Q: 按优先级出队的队列(同级 FIFO)      ← 空
    inflight: 正在被处理的请求数            ← 0
    p_admit: 中优先级准入概率(自适应)      ← 1.0

upon event ⟨request, req⟩ at server:
    # ① 硬上限:任何优先级都拒绝, 保护自身不 OOM
    if inflight + |Q| ≥ L_hard:
        return OVERLOADED                       # O(1) 返回, 不排队
    # ② 过载判定(用"排队长度"与"实测 p99"双信号)
    overloaded ← (|Q| ≥ L_soft) or (p99_latency > D)
    if overloaded:
        p_admit ← max(0.05, p_admit * 0.9)      # AIMD 式收缩
    else:
        p_admit ← min(1.0, p_admit + 0.05)      # AIMD 式恢复
    # ③ 分级准入
    if overloaded and req.priority = LOW:
        return SHED                             # 优先丢弃低优先级
    if overloaded and req.priority = MED and random() > p_admit:
        return SHED
    if req.priority = HIGH and inflight ≥ C:    # 高优先级也保护硬上限
        return SHED
    # ④ 入队 + 调度
    Q.insert(req)                               # 按优先级出队, 同级 FIFO
    schedule()

upon event ⟨done, r⟩ at server:
    inflight ← inflight - 1
    schedule()

procedure schedule():
    while inflight < C and Q is not empty:
        r ← Q.pop_max_priority()                # ★ 高优先级严格优先
        inflight ← inflight + 1
        启动 r 的处理(并给 r 设置剩余预算 D - 已等待时间)

算法逻辑解说

设 $C=120$、$L_{\text{soft}}=100$、$L_{\text{hard}}=10000$,请求分高(20%)、低(80%)两类。正常情况下 $\vert Q\vert <100$,overloaded=falsep_admit=1,所有请求照常排队执行。当流量涨到 6 倍(本讲义代码实验中的洪峰),$\vert Q\vert $ 突破 100 ⇒ overloaded=true:低优先级请求在入口就被丢弃SHED,耗时 $O(1)$,不占用任何资源),中优先级按 $p_{\text{admit}}$ 概率丢弃,高优先级照常入队并在队列里被优先出队。结果是:同样的物理容量下,高优先级请求的等待时间不会随低优先级流量增长

正确性论证

  • 安全性 1(资源有界):恒有 $inflight\le C$ 且 $\vert Q\vert \le L_{\text{hard}}$,因此内存与线程占用有界(不会 OOM),且被丢弃的请求不占用任何资源。$\square$ 对比:无准入控制的系统里,队列无界 ⇒ 内存耗尽 + 延迟无界增长(AWS 2011 中控制面”操作积压、线程池填满”就是资源无界的表现)。

  • 安全性 2(优先级隔离):设高优先级到达率 $\lambda_H$、其平均服务时间 $\bar{s}_H$,则高优先级子系统的利用率 $\rho_H=\lambda_H\bar{s}_H$。若 $\rho_H<1$,则高优先级队列的长度随机稳定(stable):因为(a)高优先级请求总是优先出队,其等待时间只由”前面正在被服务的高优先级请求”与”队列中已有的高优先级请求”决定,与低优先级流量无关;(b)过载时低优先级在入口就被丢弃,不会进入队列与高优先级竞争。因此高优先级请求的成功率不会因为低优先级洪峰而下降——这就是优先级队列 + 丢弃策略要给出的保证。$\square$ 本章实验印证:在”超时 + 熔断 + 舱壁 + 有界重试 + 优先级丢弃”的完整配置(S6)下,高优先级成功率 47.8%、低优先级 16.4%;而在只缺”优先级丢弃”的配置(S5)下,两者分别是 27.9% 与 27.4%——优先级丢弃把”平均地一起变慢”变成了”明确地保住一类流量”

  • 活性(Liveness)定理:若 $\rho_H<1$,高优先级请求在有限时间内被服务(队列稳定);若 $\rho_H \ge 1$,则任何调度策略都无法避免队列发散——此时唯一的正确做法是继续丢弃(或扩容),而不是排队(排队只会把”快速失败”变成”缓慢失败 + 资源耗尽”)。$\square$ 这也解释了为什么”高优先级也必须受硬上限 $L_{\text{hard}}$ 约束”:优先级只决定丢弃顺序,不改变容量守恒

  • 必须同时满足的假设

    1. 优先级不可被伪造(否则攻击者/错误调用方全部标 HIGH,等于没有优先级);
    2. 低优先级不能永久饿死:纯优先级队列在 $\rho_H$ 接近 1 时会让低优先级无限等待,工程上需要 aging(等待越久优先级越高)配额预留
    3. 过载信号可观测:$\vert Q\vert $ 与实测 p99 必须真实可得(灰色故障下服务器自测指标可能”看起来正常”,见 26.2.3);
    4. 丢弃语义要对客户端可见(返回 503/429 + Retry-After),否则客户端会立刻重试,把丢弃变成重试风暴。

复杂度

  • 时间:入队/出队 $O(\log \vert Q\vert )$(堆)或 $O(1)$(分级桶/多队列);准入判断 $O(1)$。
  • 空间:$O(L_{\text{hard}})$(队列)+ $O(1)$(状态)。
  • 消息:被丢弃的请求零下游消息(这本身就是它保护系统的原因)。

算法 26.3.4:舱壁隔离的资源池划分(Bulkhead)

假设与系统模型

  • 环境:一个调用方需要访问 $k$ 个下游 $D_1,\dots,D_k$(例如数据库、认证、推荐、日志)。
  • 故障模型:任一 $D_i$ 都可能独立地变慢或不可用;调用方资源(线程/连接/FD)总数有限 $C_{\text{total}}$。
  • 目标:$\forall i$,$D_i$ 的故障不得降低 $\{D_j\}_{j\ne i}$ 的服务质量
  • 参数:各下游的配额 $C_i$($\sum_i C_i\le C_{\text{total}}$)、各池队列上限 $Q_i$、各调用超时 $\tau_i$。

伪代码

配置: 为每个下游 D_i 分配独立资源池 pool_i(容量 C_i) 与等待队列 queue_i(上限 Q_i)
      Σ_i C_i ≤ C_total

upon event ⟨request, op_i⟩ (op_i 需要访问 D_i):
    if inflight_i ≥ C_i:                     # ★ 配额截断: 故障依赖到此为止
        if |queue_i| < Q_i and waited_i < τ_i:
            queue_i.insert(op_i)             # 短暂排队(仍受配额与超时约束)
        else:
            return REJECT_i                  # 快速失败 / 走降级路径
    else:
        inflight_i ← inflight_i + 1
        用 pool_i 中的连接 send ⟨call, op_i⟩ to D_i with timeout τ_i

upon event ⟨done, _⟩ for D_i:
    inflight_i ← inflight_i - 1
    if queue_i not empty and inflight_i < C_i:
        r ← queue_i.pop_first();  inflight_i ← inflight_i + 1; 重新发送 r

upon event ⟨timeout, _⟩ for D_i:
    视为 ⟨done⟩(归还连接、递减计数)      # ★ 舱壁必须与超时配合

算法逻辑解说

设 $C_{\text{total}}=60$,两个下游:$D_{\text{db}}$(配额 42)与 $D_{\text{aux}}$(配额 18)。$D_{\text{db}}$ 变慢 20 倍后,所有需要 db 的请求都会逐渐占满 42 个配额,然后停在 42:第 43 个及之后的 db 请求要么在受控的小队列里短暂等待(且受超时约束),要么被 REJECT_db 快速失败。由于 aux 的 18 个槽位从不被 db 请求占用,$D_{\text{aux}}$ 的健康流量保持正常——这就是”隔水舱”。

正确性论证

  • 隔离定理(Isolation):$\forall i$,若 $D_i$ 持续故障,则 $inflight_i$ 单调上升到 $C_i$ 后保持不变,且此后 $inflight_j\ (j\ne i)$ 的上界仍为 $C_j$,即对任意时刻: \(\sum_{j\ne i} inflight_j \;\le\; C_{\text{total}} - C_i\) 因此 $\{D_j\}_{j\ne i}$ 的可用容量与 $D_i$ 的状态无关,其服务质量由自身的 $C_j$ 与 $\lambda_j$ 决定。$\square$ 证明:由伪代码,inflight_i 只在通过 inflight_i < C_i 检查后才递增,因此它对所有 $t$ 恒有 $inflight_i\le C_i$;又 $pool_i$ 彼此独立(不共享连接/线程),故 $j$ 的池不会因 $i$ 的占用而减少。$\square$ 本章实验印证:S5(超时 + 舱壁,无熔断)与 S3(仅超时)相比,健康依赖 aux 的成功率从 32.2% 升到 52.2%,P95 延迟从 620 ms 降到 860 ms 的紧致分布(而 S4 用熔断实现保护时 P95 反而有 4820 ms 的长尾——因为熔断器存在”半开试探突发”的抖动,而舱壁是稳态的资源切分)。

  • 代价引理(利用率损失):舱壁用”峰值隔离”换”平均利用率”。当仅有部分下游繁忙时,空闲配额不可挪用(除非实现”借还”机制),因此 \(\text{平均利用率} \;\le\; \frac{\sum_i \min(\lambda_i\bar{s}_i,\; C_i)}{C_{\text{total}}}\) 在流量倾斜严重时,这个上界可能远低于 1。这就是”隔离”必须付费的地方,也是实践上常用信号量限并发(可动态调整)而非物理分池的原因。

  • 活性:池内请求仍可能排队,因此舱壁必须配超时:否则一个”永不返回”的下游会把 $C_i$ 个槽位永久占死(隔离仍在,但 $D_i$ 自身永远无法恢复)。伪代码中 timeout 事件按 done 处理,保证了槽位归还。

  • 与熔断的关系(互补性):舱壁限制横向扩散半径(资源维度:一个依赖最多能吃掉多少资源);熔断限制时间维度与流量(一个依赖的失败被允许持续多久、允许多少试探)。二者叠加才是完整的”故障隔离”:ACL 里的 “bulkhead + circuit breaker” 组合正是本章 S6 配置。

复杂度

  • 时间:准入 $O(1)$;池内出队 $O(1)$。
  • 空间:$O(\sum_i Q_i)$(队列)+ $O(\sum_i C_i)$(连接/线程)。
  • 配置复杂度:需要为每个下游选择 $C_i$(本讲义实验中按流量比例 70/30 分池),是运维复杂度而非算法复杂度。

算法 26.3.5:相关性故障感知的副本放置(Fault-Domain-Aware Placement)

假设与系统模型

  • 环境:$N$ 个存储节点,组织成故障域树:叶节点 = 机器,父节点 = 机架,更高层 = 可用区(AZ),再上层 = 区域。同一故障域内的组件会一起失效
  • 故障模型:一个故障事件 $\mathcal{E}$ 命中某个故障域(即该域内全部节点失效),故障位置在域内均匀随机;此外可能存在跨域的相关故障(同一软件版本、同一次配置推送、同一次定时任务)。
  • 复制与一致性:每个对象有 $R$ 个副本;读写使用多数派 quorum($R=3$ 时为 2)。系统可用的条件是存活副本数 ≥ 多数派
  • 目标:最小化”单个顶层故障域失效 ⇒ 丢多数派”的概率,并且不因未知的拓扑相关性而集中副本

伪代码

输入: 对象 o, 副本数 R, 故障域树 T, 顶层故障域集合 F (|F| ≥ R), 节点可用性谓词 alive(·), 负载谓词 free(·)

procedure place(o):
    for attempt = 1 to A_max:                 # 有界重试, 避免放置过程本身失败
        F' ← random_sample_without_replacement(F, R)      # ★ 关键: R 个互不相同的顶层故障域
        pos ← []
        for f in F':
            cand ← { n ∈ leaves(f) : alive(n) ∧ free(n) }  # 域内候选
            if cand = ∅:  break to next attempt            # 该域当前无可用节点
            pos.append(random_choice(cand))                # ★ 域内随机, 而非贪心选"最空"
        if |pos| = R:  return pos
    return PLACEMENT_FAILED                   # 触发告警: 可用域不足, 需要扩容

procedure repair(o, failed_replica):          # 副本修复路径
    f_bad ← top_domain(failed_replica)
    cand_domains ← F \ { f_bad } ∪ {f : f 内已有副本}
    f_new ← random_choice({ f ∈ cand_domains : 域内当前副本数 = 0, 若不存在则取副本数最少者 })
    n ← random_choice({ n ∈ leaves(f_new) : alive(n) ∧ free(n) })
    在 n 上重建副本, 并从多数派读取数据做校验(端到端 checksum)

算法逻辑解说

以讲义代码实验 2 的拓扑为例:3 个 AZ × 3 机架 × 3 节点 = 27 台机器,$R=3$,多数派 = 2。

  • 随机放置(无感知):从 27 个节点里随机取 3 个 ⇒ 有 2.5% 的概率”3 个副本中有 2 个落在同一个机架”(超几何分布),一次机架失效就丢多数派。
  • 索引贪心:若按”编号最小的空闲机器”贪心放置(节点编号往往是机架连续的),3 个副本刚好落在同一机架的 3 台机器上 ⇒ 一次机架失效丢掉全部 3 个副本(实验测得:机架失效丢多数派概率 11.11%,且 11.11% 的情况下全部副本丢失)。
  • 机架感知:先随机选 3 个不同机架,再在机架内随机选机器 ⇒ 机架失效丢多数派概率降为 0;但单 AZ 失效仍可能命中 2 个副本(实验测得 22.4%)。
  • 可用区感知:先随机选 3 个不同 AZ ⇒ 单机架失效 0%,单 AZ 失效 0%

正确性论证

  • 定理 1(单故障域失效的安全性):若 $R$ 个副本位于 $R$ 个互不相同且互不包含的顶层故障域,则任一顶层故障域失效至多影响 1 个副本,存活副本数 $\ge R-1$。 对 $R=3$:存活 $2 \ge \lceil 4/2\rceil = 2$ ⇒ 仍有多数派,读写在 quorum 下继续可用(Lecture 17 的 quorum 相交性保证一致性不破)。$\square$ 推论(故障域层级必须对齐):把副本铺在”机架子域”上并不能防”AZ 级失效”——因为一个 AZ 包含多个机架。故障域隔离必须做到与”副本数”同级的域数:3 个副本就要 3 个互不相交的顶层域。实验数据正是这一点的量化:机架感知在单 AZ 失效下仍有 22.1% 的概率丢多数派。

  • 定理 2(相关性故障下保证失效 —— 相关性故障的形式化):设存在跨故障域的相关故障事件 $\mathcal{E}$(例如”所有运行版本 $v$ 的节点同时因该版本的 bug 崩溃”,或”一次配置推送同时作用于所有节点”),令 $\mathcal{S}$ 为受影响的节点集合。 若所有副本都满足 $n\in\mathcal{S}$(同版本、同配置、同数据格式),则无论放置策略如何,受影响副本数 $=R$ ⇒ 多数派丢失;若损坏不可恢复(如磁盘数据被错误写入),则数据永久丢失形式化对比:在独立故障假设下,$P(\text{全部副本失效})=\prod_{i=1}^{R}P_i$,$R$ 越大越安全;在完全相关下,$P(\text{全部失效})=P(\mathcal{E})$,与 $R$ 无关。因此 \(\text{冗余度 }R \;\perp\; \text{相关故障}\) 即:增加副本数对相关故障没有任何帮助。$\square$ 实践含义(唯一解药是多样化):让副本在版本、实现、供应商、运行时上异构(例如:主实现 + 独立重写实现、不同大版本灰度共存、不同云厂商的对象存储),使得 $\mathcal{S}$ 不可能覆盖所有副本;同时用渐进式发布保证”同一时刻不是所有副本都运行新版本”。实验 3 的”相关性故障”一行显示:所有放置策略的丢多数派概率都是 100%——这是本章最刺痛、也最重要的一行结论。

  • 命题(随机化优于贪心):在未知的拓扑相关性下(机器编号与机架/交换机/电源域的对应关系不是放置算法所能确知的),确定性贪心放置会把”未知的相关性”原样保留(甚至放大:编号连续 ⇒ 同机架);而随机化把未知相关性转化为已知的均匀分布,使”某故障域命中 ≥2 个副本”的概率恰好等于超几何分布给出的值: \(P(\text{命中}\ge 2) = \frac{\binom{m}{2}\binom{N-m}{R-2}+\binom{m}{3}\binom{N-m}{R-3}}{\binom{N}{R}}\quad(m=\text{该故障域的节点数})\) 这个概率不依赖于我们是否知道拓扑,因此随机化在信息不完整时是稳健(robust)的;而一旦拓扑已知(现代云都提供 AZ/机架标签),就应当用显式的反亲和约束取代随机——即”随机是拓扑信息缺失时的兜底,故障域感知是拓扑信息可用时的最优”。

  • 机制图解:随机放置 vs 故障域感知放置的多数派存活概率对比(数据来自 26.4.2 的实验,3 副本,27 台机器,4000 次蒙特卡洛)。

   丢多数派的概率   (机架失效 = 3 台, 可用区失效 = 9 台, 相关故障 = 27 台全中)
   策略              单机架失效        单可用区失效       相关性故障(同版本/同配置)
   ----------------  ----------------  -----------------  ------------------------------
   A 随机(无感知)     2.5%  #           25.2% #######      100% ############################
   B 索引贪心(0,1,2) 11.1%  ###         33.3% #########    100% ############################
   C 机架感知         0.0%  .           22.4% ######       100% ############################
   D 可用区感知       0.0%  .            0.0% .            100% ############################
   ----------------  ----------------  -----------------  ------------------------------
   刻度:             0%           25%   0%          25%    0%                        100%
   读法:
     (1) D(可用区感知) 把"单域失效丢多数派"压到 0 —— 故障域层级必须与副本数对齐;
     (2) B(贪心) 比 A(随机) 更差 —— 确定性的贪心把"未知的拓扑相关性"原样放大;
     (3) 相关性故障那一列对所有策略都是 100% —— 冗余救不了相关故障, 只能靠多样化。
         (注: A 的"丢全部副本"概率在机架失效下为 0.04%, B 为 11.11%。)

复杂度

  • 时间:单次放置 $O(R\cdot \log\vert F\vert )$(域抽样)+ 域内采样 $O(1)$;带重试 $O(A_{\max}\cdot R)$。
  • 空间:每个对象 $O(R)$ 的位置元数据。
  • 对比随机放置:多出的开销仅是”域选择”这一步,代价极小,收益是”单域失效不再丢多数派”。

26.3.6 定量论证:可用性的两条改进路径($A=\dfrac{\text{MTTF}}{\text{MTTF}+\text{MTTR}}$)

模型与推导

一个”故障—修复”周期的长度是 $\text{MTTF}+\text{MTTR}$,其中不可用时间为 MTTR,因此长期平均可用性为 \(A=\frac{\text{MTTF}}{\text{MTTF}+\text{MTTR}}=\frac{1}{1+\rho},\qquad \rho=\frac{\text{MTTR}}{\text{MTTF}}\) 这是一个只依赖比值 $\rho$ 的函数。由此立刻得到三条结论:

结论 1(两条路径的精确等价):把 MTTR 缩小 $k$ 倍与把 MTTF 提高 $k$ 倍,对 $\rho$ 的影响完全相同,因此对可用性的提升也完全相同。在数学上两条路完全对称。

结论 2(绝对量上的不对称):对 $\rho$ 取对数微分:$\mathrm{d}\rho/\rho = \mathrm{d}\text{MTTR}/\text{MTTR} - \mathrm{d}\text{MTTF}/\text{MTTF}$。因此省下 1 小时 MTTR 等价于提高 $\dfrac{\text{MTTF}}{\text{MTTR}}$ 小时的 MTTF。 数值例子($\text{MTTF}=10000$ h,$\text{MTTR}=10$ h):

方案结果说明
基线$A=\frac{10000}{10010}=99.9001\%$年停机 8.75 小时(讲义量级:三个 9)
方案 A:MTTR $10\text{h}\to1\text{h}$$A=\frac{10000}{10001}=99.9900\%$年停机 52.6 分钟整整多出一个 9
方案 B:MTTR 不变,提高 MTTF要把可用性提到 99.9900%,需要 MTTF = 100000 h(提高 10 倍)相当于让硬件/软件的寿命延长一个数量级
结论 2 的推论在这一配置下,省下 1 小时恢复时间 ≡ 多得到 1000 小时(约 42 天)的无故障时间而”提高 1000 小时 MTTF”(+10%)通常需要跨代设计改进

结论 3(规模效应让 MTTR 成为唯一可用杠杆):$n$ 个组件构成的系统,其 MTTF 近似为 \(\text{MTTF}_{\text{sys}} \approx \frac{\text{MTTF}_{\text{unit}}}{n}\) 讲义给的数量级:单机平均 10 年(120 个月)出一次故障 ⇒ 120 台服务器时系统 MTTF ≈ 1 个月;12000 台时 ≈ 7.2 小时(Lecture 5-6 原话:”Failures are the norm, not the exception”)。于是规模每扩大 10 倍,MTTF 就缩短 10 倍;要保持可用性不变,MTTR 也必须缩短 10 倍。用本章代码实验 3 的数字:若单机 MTTF = 10 年,要把 12000 台的系统做到 99.99%,需要 MTTR ≤ 2.6 秒——人工响应绝无可能,只能靠自动故障转移 + 自动重启 + 自动回滚。而 10 万台规模下要求 MTTR ≤ 0.32 秒,只有”无人工介入的全自动切换”才能满足

结论 4(从三个 9 到五个 9):停机时间要从 8.76 小时/年(99.9%)压到 5.26 分钟/年(99.999%),需要 100 倍改善。要么 MTTR 缩小 100 倍(从 10 小时到 6 分钟:需要自动化检测 + 自动切换 + 一键回滚),要么 MTTF 提高 100 倍(在 12000 台规模下,等于要求单机 MTTF 达到 1.2 亿小时 ≈ 13700 年——物理上不可能)。

综上,ROC 的核心论据成立

在”商品化硬件 + 大规模”的现实里,$\text{MTTF}_{\text{sys}}$ 会随规模线性恶化且不可控,而 MTTR 是设计变量(超时、熔断、负载丢弃、自动切换、快速重启、可回滚变更、混沌演练都在压缩它)。因此提高可用性的主杠杆是减少 MTTR,而不是追求零故障。 这也解释了为什么讲义中所有四个案例的”教训”都落在同一类动作上:更快地检测、更快地止血、更快地恢复、更快地回滚

26.4 代码示例与分布式实现

三份代码都只用 Python 标准库、固定随机种子、可直接 python3 xxx.py 运行,并且输出即本章的定量证据:第一份把”级联失败如何发生、各种防护机制如何起作用”完整跑出来;第二份把”相关性故障与故障域隔离”变成概率表;第三份把”两条改进路径”变成可计算的数字。

26.4.1 实验 1:级联失败与六种防护机制的模拟器

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""CS 425 Lecture 26 / 代码 1: 级联失败与六种防护机制的对比模拟器

服务链(同步阻塞调用; 上游槽位在下游调用的整个过程中一直被占住 —— 这正是级联失败的机理):
    api(120 个前端处理线程) -> svc(60 个工作线程) -> db(10 连接) / aux(5 连接)
故障时间线:
    t = 15s : db 服务时间变成 20 倍 (容量 1000/s -> 50/s)
    t = 25s : 6 倍流量洪峰, 持续到 t = 40s
仅使用 Python 标准库; 固定随机种子; 可直接 python3 运行。
"""

import math
import random
from collections import deque
from itertools import islice

SEED = 425
DT = 0.01                       # 一个 tick = 10 ms
T_END = 60.0
DEGRADE_AT, DB_SLOWDOWN = 15.0, 20.0
SURGE_AT, SURGE_END, SURGE_X = 25.0, 40.0, 6.0
RATE = 400.0                    # 基础入口到达率 (req/s)
SLOTS = {"api": 120, "svc": 60, "db": 10, "aux": 5}
BULKHEAD = {"db": 42, "aux": 18}          # 舱壁: svc 线程按依赖分成两个独立池
MEAN_MS = {"api": 5.0, "svc": 10.0, "db": 10.0, "aux": 10.0}
DEP_PROB = 0.7                  # 70% 请求要访问 db, 30% 访问 aux
PRIO_HIGH = 0.2
DEFAULT_WAIT = 3.0              # 库默认超时(没人调过)
TUNED_WAIT = 0.25               # 按延迟预算规划过的超时
CLIENT_TO = 5.0                 # 客户端(用户)放弃的时限
BREAKER_WIN, BREAKER_MIN, BREAKER_ERR = 2.0, 20, 0.5
BREAKER_COOL, BREAKER_TRIALS = 5.0, 3
MAX_RETRIES = 3
RETRY_BASE = 0.05
RETRY_BUDGET_ERR = 0.9          # 重试预算: 下游错误率超过它就不再重试
SHED_HIGH, SHED_LOW = 100, 50   # api 排队 >100 丢弃低优先级, 回落到 50 才恢复
SLO_MS = 100.0                  # "好请求"的延迟预算
SCAN = 64                       # 每 tick 最多从队首扫多少个请求来分配槽位

CONFIGS = {
    "S1 无防护": dict(tuned=False, retry=False, breaker=False, bulkhead=False, shed=False),
    "S2 加重试": dict(tuned=False, retry=True, breaker=False, bulkhead=False, shed=False),
    "S3 加超时": dict(tuned=True, retry=False, breaker=False, bulkhead=False, shed=False),
    "S4 加熔断": dict(tuned=True, retry=False, breaker=True, bulkhead=False, shed=False),
    "S5 加舱壁": dict(tuned=True, retry=False, breaker=False, bulkhead=True, shed=False),
    "S6 全防护": dict(tuned=True, retry=True, breaker=True, bulkhead=True, shed=True),
}


class Req:
    __slots__ = ("rid", "prio", "dep", "born", "stage", "wait_from",
                 "retries", "q_layer", "cb")

    def __init__(self, rid, prio, dep, born):
        self.rid, self.prio, self.dep, self.born = rid, prio, dep, born
        self.stage, self.wait_from, self.retries, self.q_layer = 0, born, 0, None
        self.cb = False                     # 本次下游调用要不要向熔断器汇报结果


class Layer:
    """busy = 正在被服务; held = 已服务完本层但正在等下游(槽位仍被占住)"""
    def __init__(self, name, pools):
        self.name, self.pools = name, pools
        self.busy, self.held, self.queue = {}, {}, deque()
        self.cnt = {p: 0 for p in pools}    # 增量维护占用计数, 避免每次 O(n) 求和

    def free(self, pool):
        return self.pools[pool] - self.cnt[pool]

    def put_busy(self, rid, pool, rem):
        if rid not in self.busy:
            self.cnt[pool] += 1
        self.busy[rid] = (pool, rem)

    def pop_busy(self, rid):
        v = self.busy.pop(rid, None)
        if v is not None:
            self.cnt[v[0]] -= 1
        return v

    def busy_to_held(self, rid):
        v = self.busy.pop(rid, None)
        if v is not None:
            self.held[rid] = v[0]

    def pop_held(self, rid):
        v = self.held.pop(rid, None)
        if v is not None:
            self.cnt[v] -= 1

    def total_used(self):
        return len(self.busy) + len(self.held)

    def total_slots(self):
        return sum(self.pools.values())


class Breaker:
    """三状态熔断器: CLOSED -> OPEN -> HALF_OPEN -> CLOSED"""
    def __init__(self):
        self.state, self.calls, self.opened_at = "CLOSED", [], -1.0
        self.trials = self.inflight = 0
        self.failed_trial = False

    def allow(self):
        if self.state == "CLOSED":
            return True
        if self.state == "HALF_OPEN" and self.trials > 0:
            self.trials -= 1
            self.inflight += 1
            return True
        return False

    def record(self, now, failed):
        if self.state == "HALF_OPEN":
            self.inflight -= 1
            if failed:
                self.failed_trial = True
                self.state, self.opened_at = "OPEN", now
            elif self.inflight <= 0 and not self.failed_trial:
                self.state, self.calls = "CLOSED", []
            return
        if self.state == "OPEN":
            return
        self.calls.append((now, failed))
        self.calls = [c for c in self.calls if now - c[0] <= BREAKER_WIN]
        if len(self.calls) >= BREAKER_MIN:
            errs = sum(1 for _, f in self.calls if f)
            if errs / len(self.calls) > BREAKER_ERR:
                self.state, self.opened_at = "OPEN", now

    def tick(self, now):
        if self.state == "OPEN" and now - self.opened_at >= BREAKER_COOL:
            self.state, self.trials = "HALF_OPEN", BREAKER_TRIALS
            self.inflight, self.failed_trial = 0, False

    def err_ratio(self):
        if len(self.calls) < BREAKER_MIN:
            return 0.0
        return sum(1 for _, f in self.calls if f) / len(self.calls)


def poisson(lam):
    if lam <= 0:
        return 0
    if lam > 30:
        return max(0, int(random.gauss(lam, math.sqrt(lam))))
    l, k, p = math.exp(-lam), 0, 1.0
    while True:
        k += 1
        p *= random.random()
        if p <= l:
            return k - 1


class Sim:
    def __init__(self, cfg):
        self.cfg = cfg
        pools = {n: {"": SLOTS[n]} for n in SLOTS}
        if cfg["bulkhead"]:
            pools["svc"] = dict(BULKHEAD)
        self.layers = {n: Layer(n, pools[n]) for n in SLOTS}
        self.reqs, self.retry_q, self.born_q = {}, [], deque()
        self.breaker, self.now, self.rid = Breaker(), 0.0, 0
        self.arrived = self.attempts = self.ok = self.failed = 0
        self.dep_calls = self.fast_fail = 0
        self.reached = {}
        self.lat, self.good = [], 0
        self.prio = {0: [0, 0], 1: [0, 0]}
        self.cls = {"db": [0, 0], "aux": [0, 0]}
        self.buckets, self.rate, self.shedding = [], [], False
        self.t_q1k = self.t_q5k = None

    # ---------- 工具 ----------
    def names_of(self, stage):
        return ["api"] if stage == 0 else (["svc"] if stage == 1 else ["db", "aux"])

    def layer_of(self, stage, req):
        return self.layers["api" if stage == 0 else ("svc" if stage == 1 else req.dep)]

    def pool_of(self, stage, req):
        return req.dep if (self.cfg["bulkhead"] and stage == 1) else ""

    def service_ms(self, name):
        mean = MEAN_MS[name]
        if name == "db" and self.now >= DEGRADE_AT:
            mean *= DB_SLOWDOWN
        return random.expovariate(1.0 / mean)

    def wait_limit(self):
        return TUNED_WAIT if self.cfg["tuned"] else DEFAULT_WAIT

    def rate_now(self):
        return RATE * (SURGE_X if SURGE_AT <= self.now < SURGE_END else 1.0)

    # ---------- 请求生命周期 ----------
    def admit(self, prio, dep):
        self.arrived += 1
        self.attempts += 1
        self.prio[prio][1] += 1
        self.cls[dep][1] += 1
        req = Req(self.rid, prio, dep, self.now)
        self.rid += 1
        self.born_q.append((self.now, req.rid))
        if self.cfg["shed"]:
            q = len(self.layers["api"].queue)
            if self.shedding and q <= SHED_LOW:
                self.shedding = False
            elif q > SHED_HIGH:
                self.shedding = True
            if self.shedding and prio == 1:      # 过载时优先丢弃低优先级请求
                self.failed += 1
                return
        self.reqs[req.rid] = req
        self.enter(req, 0)

    def enter(self, req, stage):
        if stage == 2 and req.dep == "db" and self.cfg["breaker"] and not self.breaker.allow():
            self.fast_fail += 1                  # 熔断打开: 立刻失败, 不碰下游
            self.fail(req)
            return
        if stage == 2:
            self.dep_calls += 1                  # 真正到达下游的调用
            self.reached[req.rid] = True
            req.cb = self.cfg["breaker"]
        lay = self.layer_of(stage, req)
        pool = self.pool_of(stage, req)
        req.stage, req.wait_from = stage, self.now
        if lay.free(pool) > 0:
            lay.put_busy(req.rid, pool, self.service_ms(lay.name))
        else:
            lay.queue.append(req.rid)
            req.q_layer = lay.name

    def dispatch(self, stage):
        for name in self.names_of(stage):
            lay = self.layers[name]
            while lay.queue and lay.queue[0] not in self.reqs:
                lay.queue.popleft()              # 惰性删除: 清掉已经结束的请求
            while True:
                pick = None
                for rid in islice(lay.queue, SCAN):
                    r = self.reqs.get(rid)
                    if r is None or r.stage != stage or (stage == 2 and r.dep != name):
                        continue
                    p = self.pool_of(stage, r)
                    if lay.free(p) > 0:
                        pick = rid
                        break
                if pick is None:
                    break
                lay.queue.remove(pick)
                r = self.reqs[pick]
                r.q_layer = None
                lay.put_busy(pick, self.pool_of(stage, r), self.service_ms(name))

    def advance(self, stage):
        for name in self.names_of(stage):
            lay = self.layers[name]
            for rid in list(lay.busy):
                pool, rem = lay.busy[rid]
                r = self.reqs.get(rid)
                if r is None:
                    lay.pop_busy(rid)
                    continue
                rem -= DT * 1000.0
                if rem > 0:
                    lay.busy[rid] = (pool, rem)
                    continue
                lay.busy_to_held(rid)            # 槽位继续被占住, 直到下游调用结束
                if stage < 2:
                    self.enter(r, stage + 1)
                else:
                    self.success(r)
        self.dispatch(stage)

    def success(self, req):
        if req.cb:
            self.breaker.record(self.now, False)
            req.cb = False
        dt = self.now - req.born
        self.release(req)
        self.ok += 1
        self.lat.append(dt)
        if dt * 1000 <= SLO_MS:
            self.good += 1
        self.prio[req.prio][0] += 1
        self.cls[req.dep][0] += 1

    def fail(self, req, client_retry=True):
        """一次尝试失败: 释放全部资源; 客户端可选择重试(从入口重新发起)"""
        if req.cb:
            self.breaker.record(self.now, True)
            req.cb = False
        self.release(req)
        if client_retry and self.cfg["retry"] and req.retries < MAX_RETRIES:
            if not self.cfg["breaker"] or self.breaker.err_ratio() < RETRY_BUDGET_ERR:
                req.retries += 1
                delay = 0.0
                if self.cfg["breaker"]:          # S6: 指数退避 + 抖动
                    delay = RETRY_BASE * (2 ** req.retries) * random.uniform(0.5, 1.5)
                self.retry_q.append((self.now + delay, req))
                return
        self.failed += 1

    def release(self, req):
        for name in ("api", "svc", req.dep):
            lay = self.layers[name]
            lay.pop_busy(req.rid)
            lay.pop_held(req.rid)
        req.q_layer = None
        self.reqs.pop(req.rid, None)

    def check_timeouts(self):
        lim = self.wait_limit()
        for name in ("api", "svc", "db", "aux"):
            lay = self.layers[name]
            head = []
            for i, rid in enumerate(lay.queue):  # 只检查队首 8 项(排队基本按时间有序)
                if i >= 8:
                    break
                head.append(rid)
            for rid in head:
                r = self.reqs.get(rid)
                if r is not None and self.now - r.wait_from > lim:
                    lay.queue.remove(rid)
                    r.q_layer = None
                    self.fail(r)
        while self.born_q and self.now - self.born_q[0][0] > CLIENT_TO:
            _, rid = self.born_q.popleft()
            r = self.reqs.get(rid)
            if r is not None:
                self.fail(r, client_retry=False)   # 用户放弃: 不再重试

    # ---------- 主循环 ----------
    def run(self):
        for k in range(int(T_END / DT)):
            self.now = k * DT
            self.breaker.tick(self.now)
            for _ in range(poisson(self.rate_now() * DT)):
                self.admit(0 if random.random() < PRIO_HIGH else 1,
                           "db" if random.random() < DEP_PROB else "aux")
            if self.retry_q:
                due = [x for x in self.retry_q if x[0] <= self.now]
                self.retry_q = [x for x in self.retry_q if x[0] > self.now]
                for _, r in due:                 # 重试 = 从入口再发起一次完整尝试
                    self.attempts += 1
                    self.reqs[r.rid] = r
                    r.stage, r.wait_from = 0, self.now
                    self.enter(r, 0)
            for stage in (0, 1, 2):
                self.advance(stage)
            self.check_timeouts()
            if k % 200 == 0:
                self.sample()
        self.sample()
        self.analyze()

    def sample(self):
        for lay in self.layers.values():         # 惰性删除后压缩队列
            lay.queue = deque(rid for rid in lay.queue if rid in self.reqs)
        self.buckets.append(dict(
            arrived=self.arrived, ok=self.ok, failed=self.failed,
            api_q=len(self.layers["api"].queue), svc_q=len(self.layers["svc"].queue),
            db_q=len(self.layers["db"].queue),
            api_u=self.layers["api"].total_used() / SLOTS["api"],
            svc_u=self.layers["svc"].total_used() / SLOTS["svc"],
            db_u=self.layers["db"].total_used() / SLOTS["db"]))

    def analyze(self):
        prev_a = prev_o = 0
        for b in self.buckets:
            da, do = b["arrived"] - prev_a, b["ok"] - prev_o
            prev_a, prev_o = b["arrived"], b["ok"]
            self.rate.append(do / da if da else 1.0)
        for i, b in enumerate(self.buckets):      # 入口积压首破阈值(千/五千)的时刻
            if self.t_q1k is None and b["api_q"] > 1000:
                self.t_q1k = i * 2.0
            if self.t_q5k is None and b["api_q"] > 5000:
                self.t_q5k = i * 2.0


def chart(values, width=52, height=6, ymax=100.0, char="*"):
    n, grid = len(values), [[" "] * width for _ in range(height)]
    for c in range(width):
        lo, hi = int(c * n / width), max(int(c * n / width) + 1, int((c + 1) * n / width))
        v = sum(values[lo:hi]) / len(values[lo:hi])
        lvl = max(0, min(height - 1, int(round(v / ymax * (height - 1)))))
        grid[height - 1 - lvl][c] = char
    out = ["%5.0f%% |%s" % (ymax * (height - 1 - r) / (height - 1), "".join(grid[r]))
           for r in range(height)]
    out.append("       +" + "-" * width)
    marks = [" "] * width
    for t, sym in ((0, "0"), (15, "!"), (25, "S"), (40, "e"), (60, "6")):
        marks[min(width - 1, int(t / T_END * width))] = sym
    out.append("        " + "".join(marks) + "   ! DB变慢   S~e 洪峰期")
    return out


def pct(x, y):
    return 100.0 * x / y if y else 0.0


def summarize(s):
    lat = sorted(s.lat)
    return dict(
        ok=pct(s.ok, s.arrived), good=pct(s.good, s.arrived),
        lat_ms=(sum(s.lat) / len(s.lat) * 1000) if s.lat else 0.0,
        p95_ms=lat[int(len(lat) * 0.95)] * 1000 if lat else 0.0,
        amp=s.attempts / max(1, s.arrived), down_amp=s.dep_calls / max(1, s.arrived),
        qmax=max(b["api_q"] for b in s.buckets),
        svc_u=sum(b["svc_u"] for b in s.buckets) / len(s.buckets) * 100,
        hi=pct(*s.prio[0]), lo=pct(*s.prio[1]),
        db=pct(*s.cls["db"]), aux=pct(*s.cls["aux"]),
        fast=s.fast_fail, q1k=s.t_q1k, q5k=s.t_q5k)


def main():
    print("=" * 84)
    print("CS 425 L26 实验 1: 级联失败与六种防护机制")
    print("链路 api(120线程) -> svc(60线程) -> db(10连接)/aux(5连接);  入口 400 req/s")
    print("t=15s 数据库变慢 20 倍(容量 1000/s -> 50/s);  t=25~40s 六倍流量洪峰")
    print("=" * 84)
    res = {}
    for name, cfg in CONFIGS.items():
        random.seed(SEED)                        # 同一随机序列, 便于公平对比
        s = Sim(cfg)
        s.run()
        res[name] = s
        m = summarize(s)
        print("\n--- %s ---" % name)
        print("  成功率 %.1f%% (%d/%d 次尝试)   好请求率(成功且<=100ms) %.1f%%   失败 %d"
              % (m["ok"], s.ok, s.arrived, m["good"], s.failed))
        print("  平均延迟 %.0f ms   P95 %.0f ms   入口放大 %.2fx   下游调用放大 %.2fx   熔断快速失败 %d"
              % (m["lat_ms"], m["p95_ms"], m["amp"], m["down_amp"], m["fast"]))
        print("  高优先级成功率 %.1f%%   低优先级 %.1f%%   db 类 %.1f%%   aux 类 %.1f%%"
              % (m["hi"], m["lo"], m["db"], m["aux"]))

    s1 = res["S1 无防护"]
    print("\n" + "=" * 84)
    print("S1(无防护)时间线 —— 本章最重要的表: 一个依赖变慢如何吃掉调用方的资源")
    print("=" * 84)
    print("  时刻  api排队  svc排队  db排队   api占用  svc占用   db占用   该段成功率")
    prev_a = prev_o = 0
    for i, b in enumerate(s1.buckets):
        da, do = b["arrived"] - prev_a, b["ok"] - prev_o
        prev_a, prev_o = b["arrived"], b["ok"]
        if i % 2 or i * 2 < 13:
            continue
        print("  %4.0fs %7d %8d %7d    %5.0f%%   %5.0f%%   %5.0f%%     %6.1f%%"
              % (i * 2, b["api_q"], b["svc_q"], b["db_q"], b["api_u"] * 100,
                 b["svc_u"] * 100, b["db_u"] * 100, pct(do, da)))
    print("  结束时仍有 %d 个请求卡在系统里 (api 排队 %d, svc 排队 %d, db 排队 %d)"
          % (len(s1.reqs), len(s1.layers["api"].queue),
             len(s1.layers["svc"].queue), len(s1.layers["db"].queue)))

    print("\n" + "=" * 84)
    print("成功率演化 (每列约 1.15 s; !=DB变慢  S~e=洪峰)")
    print("=" * 84)
    for name, s in res.items():
        print("\n%s" % name)
        for line in chart([100 * r for r in s.rate]):
            print(line)

    print("\n" + "=" * 84)
    print("对比表")
    print("=" * 84)
    head = ("%-10s %7s %8s %8s %8s %8s %8s %8s %8s" %
            ("场景", "成功率", "好请求率", "平均延迟", "P95延迟", "入口放大",
             "积压峰值", "破千时刻", "svc占用"))
    print(head)
    print("-" * len(head))
    for name, s in res.items():
        m = summarize(s)
        t1 = ("%.0f s" % m["q1k"]) if m["q1k"] is not None else "  未破"
        t5 = ("%.0f s" % m["q5k"]) if m["q5k"] is not None else "未破"
        print("%-10s %6.1f%% %7.1f%% %6.0f ms %6.0f ms %7.2fx %8d %8s %7.0f%%"
              % (name, m["ok"], m["good"], m["lat_ms"], m["p95_ms"], m["amp"],
                 m["qmax"], t1, m["svc_u"]))
    print("\n  场景        高优先级成功率  低优先级成功率   db 类成功率   aux 类成功率   积压破五千")
    print("  " + "-" * 76)
    for name, s in res.items():
        m = summarize(s)
        t5 = ("%.0f s" % m["q5k"]) if m["q5k"] is not None else "未破"
        print("  %-10s %12.1f%% %13.1f%% %11.1f%% %12.1f%% %11s"
              % (name, m["hi"], m["lo"], m["db"], m["aux"], t5))
    print("\n注: 好请求率 = 成功且端到端延迟 <= %.0f ms 的比例;  入口放大 = 入口尝试次数/请求数;" % SLO_MS)
    print("    积压 = 卡在入口(api 层)排队等待处理线程的请求数;  破千时刻 = 积压首次超过")
    print("    1000 的时刻(秒), 可用来比较系统'被压垮'的速度。")


if __name__ == "__main__":
    main()

【代码做什么?】

  1. 建立三层同步调用链api(120 个前端处理线程)→ svc(60 个工作线程)→ 下游 db(10 个连接)/ aux(5 个连接)。70% 的请求要走 db,30% 走 aux(模拟”一个有问题的依赖 + 一个健康的依赖”)。
  2. 关键建模:上游槽位在整个下游调用期间一直被占住Layer.busy 是”正在被服务”,Layer.held 是”已服务完本层、但正在等下游”)。这一条是级联失败的全部机理所在——阻塞式调用会把下游的慢,转化为上游的资源耗尽
  3. 注入故障:第 15 秒起 db 的服务时间变成 20 倍(容量从 1000/s 掉到 50/s,而 db 类请求的到达率是 280/s);第 25–40 秒叠加 6 倍流量洪峰(惊群/突增)。
  4. 跑六个场景,每次只加一种机制:S1 无防护(只有库默认的 3 秒超时)→ S2 加无退避重试 → S3 把超时调成 250 ms → S4 加熔断器 → S5 加舱壁隔离 → S6 全防护(+ 指数退避抖动重试 + 重试预算 + 优先级丢弃)。
  5. 统计并作图:成功率、好请求率(成功且 ≤100 ms)、平均/P95 延迟、入口放大倍数、下游调用放大倍数、积压峰值、池占用率;每个场景画一张成功率演化 ASCII 曲线,并打印 S1 的逐 2 秒时间线(排队长度 + 各层池占用)。
  6. 对比表把所有指标并列,用同一随机种子(random.seed(425))保证六个场景看到完全相同的请求到达序列,从而差异只来自防护机制本身。

【分布式机制透视】

  • 消息传递的模拟:一次”请求”对象在 api → svc → db 之间移动,等价于三段同步 RPC;Layer.queue 就是连接池/线程池的等待队列,Layer.busy 是正在被服务的槽位,Layer.held 是”被阻塞的调用方线程”——真实系统中它就是那个卡在 socket.read() 上的工作线程。
  • 并发与时序:整个模拟是离散时间步(每 tick 10 ms),时间推进由 Sim.run() 的循环驱动;请求的到达用泊松过程采样,服务时间用指数分布采样——这正是排队论里 M/M/c 模型的离散化版本。因此观察到的”池占用率 → 100%”与”队列发散”是排队系统的必然结果,而不是编码技巧。
  • 分布式系统的对应物api 池 = Web 服务器的处理线程;svc 池 = 业务逻辑的工作线程池;db/aux 池 = 数据库连接池;”入口排队 11793” = 客户端连上来但拿不到处理线程的请求(真实系统里表现为 LB 队列/accept backlog/客户端超时)。
  • 熔断器、舱壁、负载丢弃都被实现为独立的可组合开关,从而可以测量”每一种机制单独贡献了多少”——这在真实系统里是做不到的(你没法在生产上做 A/B 故障实验),正是模拟器的价值。

【与理论的对应】

  • 26.3.1 熔断器class Breaker 逐行对应伪代码的 CLOSED/OPEN/HALF_OPEN 状态机(allow() 对应放行判断与试探配额预留、record() 对应结果回报、tick() 对应冷却到期转半开)。实验验证了引理 1:加了熔断器后,故障依赖的调用被限制为”每 5 秒最多 3 个试探”(熔断快速失败 13971 次调用没有产生任何网络流量),svc 池平均占用率从 76%(S1/S2)降到 56%(S4),健康依赖 aux 的成功率从 16.8%(S1)升到 56.8%(S4)——调用方被保住了
  • 26.3.2 退避 + 抖动 + 预算:S2 使用”失败立刻重试、最多 3 次”,实测 入口放大 1.42 倍、积压峰值 11793 → 15562(+32%)、平均延迟 443 ms → 478 ms,而成功率 16.7% 毫无提升——与讲义 Facebook 案例”没有退避、rinse-n-repeat、数据库被压垮”完全同构。S6 改为”指数退避 + 全抖动 + 重试预算(下游错误率 > 0.9 就不再重试)”,其下游调用放大仅 0.28 倍(S3 为 0.32,见预算的抑制作用)
  • 26.3.3 负载丢弃:S6 在入口积压超阈值时丢弃低优先级请求(优先级丢弃),结果 高优先级成功率 47.8% vs 低优先级 16.4%,而 S5(无丢弃)是 27.9% vs 27.4%——优先级隔离的定量证据
  • 26.3.4 舱壁:S5 把 svc 池按依赖切成 42/18 两个独立池,健康依赖 aux 的成功率从 S3 的 32.2% 升到 52.2%,且 P95 延迟保持在 860 ms(对比 S4 用熔断实现保护时有 4820 ms 的长尾)——资源维度的隔离比时间维度的隔离更”平滑”
  • 26.3.6 可用性:本实验的”成功率/积压/延迟”就是 MTTR 的构件:S1 的 p95 是 2250 ms 且积压破万,S6 的 p95 是 1870 ms 且积压峰值仅 951——同样的故障、同样的容量下,”恢复能力”决定了用户看到的是”灾难”还是”降级”

运行输出(实际运行结果,节选)

====================================================================================
CS 425 L26 实验 1: 级联失败与六种防护机制
链路 api(120线程) -> svc(60线程) -> db(10连接)/aux(5连接);  入口 400 req/s
t=15s 数据库变慢 20 倍(容量 1000/s -> 50/s);  t=25~40s 六倍流量洪峰
====================================================================================

--- S1 无防护 ---
  成功率 16.7% (9094/54355 次尝试)   好请求率(成功且<=100ms) 12.4%   失败 44154
  平均延迟 443 ms   P95 2250 ms   入口放大 1.00x   下游调用放大 0.17x   熔断快速失败 0
  高优先级成功率 17.0%   低优先级 16.7%   db 类 16.7%   aux 类 16.8%

--- S2 加重试 ---
  成功率 16.7% (9057/54346 次尝试)   好请求率(成功且<=100ms) 12.4%   失败 43552
  平均延迟 478 ms   P95 1770 ms   入口放大 1.42x   下游调用放大 0.17x   熔断快速失败 0
  高优先级成功率 17.2%   低优先级 16.5%   db 类 16.8%   aux 类 16.5%

--- S3 加超时 ---
  成功率 21.5% (11613/54086 次尝试)   好请求率(成功且<=100ms) 16.5%   失败 42355
  平均延迟 146 ms   P95 620 ms   入口放大 1.00x   下游调用放大 0.32x   熔断快速失败 0
  高优先级成功率 21.8%   低优先级 21.4%   db 类 16.8%   aux 类 32.2%

--- S4 加熔断 ---
  成功率 26.8% (14532/54275 次尝试)   好请求率(成功且<=100ms) 16.1%   失败 39731
  平均延迟 404 ms   P95 4820 ms   入口放大 1.00x   下游调用放大 0.31x   熔断快速失败 13971
  高优先级成功率 26.8%   低优先级 26.8%   db 类 13.8%   aux 类 56.8%

--- S5 加舱壁 ---
  成功率 27.5% (14926/54227 次尝试)   好请求率(成功且<=100ms) 22.4%   失败 39218
  平均延迟 280 ms   P95 860 ms   入口放大 1.00x   下游调用放大 0.35x   熔断快速失败 0
  高优先级成功率 27.9%   低优先级 27.4%   db 类 16.9%   aux 类 52.2%

--- S6 全防护 ---
  成功率 22.6% (12170/53826 次尝试)   好请求率(成功且<=100ms) 17.3%   失败 41360
  平均延迟 270 ms   P95 1870 ms   入口放大 1.50x   下游调用放大 0.28x   熔断快速失败 0
  高优先级成功率 47.8%   低优先级 16.4%   db 类 17.0%   aux 类 35.8%

====================================================================================
S1(无防护)时间线 —— 本章最重要的表: 一个依赖变慢如何吃掉调用方的资源
====================================================================================
  时刻  api排队  svc排队  db排队   api占用  svc占用   db占用   该段成功率
    16s     214       60      50      100%     100%     100%       62.9%
    20s    1009       60      47       99%      98%     100%       18.1%
    24s     984       60      50      100%     100%     100%       16.9%
    28s    7033       60      48       98%      97%     100%        3.3%
    32s   11609       60      45       98%      97%     100%        3.5%
    36s   11753       60      50      100%     100%     100%        2.7%
    40s   11762       60      49      100%     100%     100%        3.0%
    44s    3670       60      49       99%      98%     100%       16.7%
    48s     967       60      49      100%     100%     100%       15.8%
    52s     991       60      49      100%     100%     100%       18.8%
    56s     948       60      49       99%      98%     100%       18.6%
    60s     987       60      50      100%     100%     100%       18.8%
  结束时仍有 1107 个请求卡在系统里 (api 排队 987, svc 排队 60, db 排队 50)

====================================================================================
成功率演化 (每列约 1.15 s; !=DB变慢  S~e=洪峰)
====================================================================================

S1 无防护
  100% |  ************                                      
   80% |                                                    
   60% |              **                                    
   40% |**                                                  
   20% |                ******              ****************
    0% |                      **************                
       +----------------------------------------------------
        0            !       S            e                6   ! DB变慢   S~e 洪峰期

S2 加重试
  100% |  ************                                      
   80% |                                                    
   60% |              **                                    
   40% |**                                                  
   20% |                ******              ****************
    0% |                      **************                
       +----------------------------------------------------
        0            !       S            e                6   ! DB变慢   S~e 洪峰期

S3 加超时
  100% |  ************                                      
   80% |                                                    
   60% |              **                                    
   40% |**              ******              *  ***  ** *****
   20% |                      **             **   **  *     
    0% |                        ************                
       +----------------------------------------------------
  ... (S4/S5/S6 的曲线形状: 数据库变慢后先落到 20%~40%, 洪峰期间被压到接近 0%,
       洪峰结束后恢复到约 40%; 完整输出可运行本程序复现) ...

====================================================================================

S1 无防护
  100% |  ************                                      
   80% |                                                    
   60% |              **                                    
   40% |**                                                  
   20% |                ******              ****************
    0% |                      **************                
       +----------------------------------------------------
        0            !       S            e                6   ! DB变慢   S~e 洪峰期

S2 加重试
  100% |  ************                                      
   80% |                                                    
   60% |              **                                    
   40% |**                                                  
   20% |                ******              ****************
    0% |                      **************                
       +----------------------------------------------------
        0            !       S            e                6   ! DB变慢   S~e 洪峰期

S3 加超时
  100% |  ************                                      
   80% |                                                    
   60% |              **                                    
   40% |**              ******              *  ***  ** *****
   20% |                      **             **   **  *     
    0% |                        ************                
       +----------------------------------------------------
        0            !       S            e                6   ! DB变慢   S~e 洪峰期

S4 加熔断
  100% |  ************                                      
   80% |                                    ***             
   60% |              **                       **           
   40% |**              ***  *                   ********** 
   20% |                   ** **  ***** ****               *
    0% |                        **     *                    
       +----------------------------------------------------
        0            !       S            e                6   ! DB变慢   S~e 洪峰期

S5 加舱壁
  100% |  ************                                      
   80% |              **                     **             
   60% |                                    *  **           
   40% |**              ******                   ***********
   20% |                      **                            
    0% |                        ************                
       +----------------------------------------------------
        0            !       S            e                6   ! DB变慢   S~e 洪峰期

S6 全防护
  100% |  ************                                      
   80% |              **                                    
   60% |                                                    
   40% |**               ****                **  ***       *
   20% |                *    ***            *  **   ******* 
    0% |                        ************                
       +----------------------------------------------------
        0            !       S            e                6   ! DB变慢   S~e 洪峰期

====================================================================================
对比表
====================================================================================
场景             成功率     好请求率     平均延迟    P95延迟     入口放大     积压峰值     破千时刻    svc占用
---------------------------------------------------------------------------------
S1 无防护       16.7%    12.4%    443 ms   2250 ms    1.00x    11793     20 s      76%
S2 加重试       16.7%    12.4%    478 ms   1770 ms    1.42x    15562     20 s      76%
S3 加超时       21.5%    16.5%    146 ms    620 ms    1.00x     9881     26 s      73%
S4 加熔断       26.8%    16.1%    404 ms   4820 ms    1.00x     8285     26 s      56%
S5 加舱壁       27.5%    22.4%    280 ms    860 ms    1.00x     9488     26 s      54%
S6 全防护       22.6%    17.3%    270 ms   1870 ms    1.50x      951       未破      54%

  场景        高优先级成功率  低优先级成功率   db 类成功率   aux 类成功率   积压破五千
  ----------------------------------------------------------------------------
  S1 无防护             17.0%          16.7%        16.7%         16.8%        28 s
  S2 加重试             17.2%          16.5%        16.8%         16.5%        28 s
  S3 加超时             21.8%          21.4%        16.8%         32.2%        30 s
  S4 加熔断             26.8%          26.8%        13.8%         56.8%        32 s
  S5 加舱壁             27.9%          27.4%        16.9%         52.2%        30 s
  S6 全防护             47.8%          16.4%        17.0%         35.8%          未破

注: 好请求率 = 成功且端到端延迟 <= 100 ms 的比例;  入口放大 = 入口尝试次数/请求数;
    积压 = 卡在入口(api 层)排队等待处理线程的请求数;  破千时刻 = 积压首次超过
    1000 的时刻(秒), 可用来比较系统'被压垮'的速度。

26.4.2 实验 2:相关性故障与故障域隔离

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""CS 425 Lecture 26 / 代码 2: 相关性故障与故障域感知的副本放置

拓扑(节点编号是机架连续的, 这正是真实数据中心的常见做法):
    3 个可用区(AZ) x 3 个机架(rack) x 3 个节点 = 27 台机器
    每个对象 3 个副本; 多数派 quorum = 2/3
故障事件: 单节点失效 / 单机架失效(3 台) / 单可用区失效(9 台) / 相关性故障(同版本软件或同配置, 27 台全中)
仅使用 Python 标准库; 固定随机种子; 可直接 python3 运行。
"""

import random

TRIALS = 4000
REPLICAS = 3
AZS, RACKS_PER_AZ, NODES_PER_RACK = 3, 3, 3
NODES = AZS * RACKS_PER_AZ * NODES_PER_RACK      # 27


def az_of(node):
    return node // (RACKS_PER_AZ * NODES_PER_RACK)


def rack_of(node):
    return node // NODES_PER_RACK


def place_random():
    return random.sample(range(NODES), REPLICAS)


def place_greedy_index():
    """按索引贪心: 挑编号最小的 3 台机器 —— 编号连续 = 同一机架"""
    return [0, 1, 2]


def place_rack_aware():
    """每个副本放在不同的机架"""
    racks = random.sample(range(AZS * RACKS_PER_AZ), REPLICAS)
    return [r * NODES_PER_RACK + random.randrange(NODES_PER_RACK) for r in racks]


def place_az_aware():
    """每个副本放在不同的可用区"""
    azs = random.sample(range(AZS), REPLICAS)
    return [az * (RACKS_PER_AZ * NODES_PER_RACK) + random.randrange(RACKS_PER_AZ * NODES_PER_RACK)
            for az in azs]


STRATEGIES = [
    ("A 随机放置(无感知)", place_random),
    ("B 索引贪心(0,1,2)", place_greedy_index),
    ("C 机架感知", place_rack_aware),
    ("D 可用区感知", place_az_aware),
]


def rack_failure_nodes(rack):
    return [rack * NODES_PER_RACK + i for i in range(NODES_PER_RACK)]


def az_failure_nodes(az):
    base = az * RACKS_PER_AZ * NODES_PER_RACK
    return list(range(base, base + RACKS_PER_AZ * NODES_PER_RACK))


def evaluate(strategy, event_sets):
    """对"故障位置在故障域内均匀随机"求期望: 平均所有可能的事故位置"""
    hit_q = hit_all = total = 0
    for nodes in event_sets:
        nodes = set(nodes)
        for _ in range(TRIALS):
            reps = strategy()
            hit = sum(1 for r in reps if r in nodes)
            hit_q += 1 if hit >= 2 else 0
            hit_all += 1 if hit == REPLICAS else 0
            total += 1
    return hit_q / total, hit_all / total


def pad(text, width):
    """按显示宽度补空格(中文字符占 2 列)"""
    w = sum(2 if ord(c) > 0x2E80 else 1 for c in text)
    return text + " " * max(0, width - w)


def bar(p, width=28):
    n = int(round(p * width))
    return "#" * n + "." * (width - n)


def main():
    random.seed(425)                 # 固定随机种子, 保证结果可复现
    print("=" * 86)
    print("CS 425 L26 实验 2: 相关性故障与故障域感知的副本放置")
    print("拓扑: 3 个可用区 x 3 个机架 x 3 个节点 = %d 台机器;  每个对象 %d 个副本;"
          % (NODES, REPLICAS))
    print("      多数派 quorum = 2/3;  每次实验 %d 次随机试验" % TRIALS)
    print("=" * 86)

    events = [
        ("单节点失效   (1 台机器)", [[n] for n in range(NODES)]),
        ("单机架失效   (3 台机器)", [rack_failure_nodes(r) for r in range(AZS * RACKS_PER_AZ)]),
        ("单可用区失效 (9 台机器)", [az_failure_nodes(a) for a in range(AZS)]),
        ("相关性故障   (27 台全中)", [list(range(NODES))]),
    ]
    rows = []
    for sname, fn in STRATEGIES:
        for ename, nodes in events:
            q, a = evaluate(fn, nodes)
            rows.append((sname, ename, len(nodes[0]), q, a))

    print("\n" + pad("放置策略", 20) + pad("故障事件", 26)
          + pad("丢多数派", 12) + pad("丢全部副本", 12) + "丢多数派概率")
    print("-" * 100)
    last = None
    for sname, ename, nn, q, a in rows:
        if last is not None and sname != last:
            print("-" * 100)
        last = sname
        print(pad(sname, 20) + pad(ename, 26)
              + pad("%.2f%%" % (q * 100), 12) + pad("%.2f%%" % (a * 100), 12) + bar(q))

    print("\n结论:")
    print("  1. 单节点失效: 任何策略都不会丢多数派(3 个副本本来就在 3 台不同机器上)。")
    print("  2. 索引贪心(副本落在连号的 0/1/2): 一旦失效的正好是那个机架(概率 1/9),")
    print("     3 个副本会全部丢失: 机器编号往往与拓扑相关(同机架连号), '看起来高效'的")
    print("     确定性放置正好把")
    print("     3 个副本塞进同一个故障域。这就是随机放置优于贪心的原因: 随机化把")
    print("     未知的相关性打散, 而贪心的确定性会把未知的相关性放大。")
    print("  3. 机架感知把'单机架失效丢多数派'降为 0, 但一个可用区里有 3 个机架,")
    print("     3 个副本虽落在不同机架、却仍可能有两个同处一个 AZ(概率约 22%), 于是")
    print("     单可用区失效仍可能丢多数派 —— 故障域的层级必须与副本数对齐。")
    print("  4. 只有可用区感知能把'单可用区失效丢多数派'降为 0 —— 故障域的层级必须")
    print("     与副本数对齐: 3 个副本就要有 3 个互不相交的顶层故障域。")
    print("  5. 相关性故障(同一软件 bug、同一次配置推送)对所有放置策略都是 100% 致命:")
    print("     冗余只能对抗'独立'故障, 对抗'相关'故障唯一的手段是多样化(版本/实现异构)。")


if __name__ == "__main__":
    main()

【代码做什么?】

  1. 构造一个真实的拓扑:3 个可用区 × 3 个机架 × 3 个节点 = 27 台机器,节点编号按机架连续node // 3 就是机架号——这正是现实中机柜内连号布线的常见做法)。
  2. 实现四种放置策略:随机(无感知)索引贪心(挑编号最小的 3 台 = 0/1/2)、机架感知(3 个不同机架)、可用区感知(3 个不同 AZ)。
  3. 对四类故障事件(单节点 / 单机架 3 台 / 单 AZ 9 台 / 跨全部 27 台的相关性故障),遍历所有可能的故障位置并用 4000 次蒙特卡洛试验求平均,统计”丢掉多数派(≥2 个副本受影响)”与”丢掉全部副本”的概率。
  4. 打印概率表与结论。

【分布式机制透视】

  • 故障域被显式建模:拓扑的层次(AZ ⊃ 机架 ⊃ 节点)不是装饰,而是”哪些机器会一起挂”的定义。代码把”故障事件”实现为某个域内全部节点的集合,这恰好对应真实的机架断电、交换机重启、AZ 级网络故障。
  • quorum 的可用性判据hit >= 2 就是”多数派丢失”——与 Lecture 17 的 quorum 相交性直接对应:3 副本系统中,只要存活副本 ≥2,读写仍能保证一致性。
  • 相关性故障用”事件集合”表示:第四个事件(27 台全中)建模的是”同一软件 bug / 同一次配置推送”,它不尊重任何拓扑边界,因此所有放置策略的结果都是 100%——这就是”相关性故障击穿冗余”的形式化演示。
  • 随机化的作用:随机放置不是”随便放”,而是”在信息不完整时把未知相关性转化为均匀分布”;当拓扑信息可用时,用反亲和约束直接表达意图(机架感知/AZ 感知)。

【与理论的对应】

  • 26.3.5 定理 1:机架感知在”单机架失效”上把丢多数派概率从随机放置的 2.48% 降到 0.00%,AZ 感知在”单 AZ 失效”上把 24.97% 降到 0.00%——正是”副本分布在 R 个互不相同的顶层故障域 ⇒ 单域失效不丢多数派”的实测。
  • 26.3.5 命题(随机优于贪心):索引贪心在单机架失效下丢多数派概率 11.11%,且这 11.11% 是全部副本一起丢(数据永久丢失),而随机放置只有 2.48%(丢全部副本 0.04%)。原因写在代码注释里:节点编号与故障域相关,贪心把这份未知的相关性放大成了灾难
  • 26.3.5 定理 2(相关性故障):第四行数据是所有策略的 100%——增加副本数对相关故障毫无帮助,唯一解药是多样化(版本异构、实现异构、供应商异构)与渐进式发布。这一行也是讲义 AWS 2011”13% 的卷同时重镜像”、Facebook”所有缓存服务器同时去查库”的共同数学形态。
  • 与 Lecture 22 的呼应:放置只解决”副本在哪里”,数据是否真的没坏还需要端到端校验和(GFS chunk checksum 思想)——放置防的是”物理一起丢”,校验防的是”静默损坏”。

运行输出(实际运行结果)

======================================================================================
CS 425 L26 实验 2: 相关性故障与故障域感知的副本放置
拓扑: 3 个可用区 x 3 个机架 x 3 个节点 = 27 台机器;  每个对象 3 个副本;
      多数派 quorum = 2/3;  每次实验 4000 次随机试验
======================================================================================

放置策略            故障事件                  丢多数派    丢全部副本  丢多数派概率
----------------------------------------------------------------------------------------------------
A 随机放置(无感知)  单节点失效   (1 台机器)   0.00%       0.00%       ............................
A 随机放置(无感知)  单机架失效   (3 台机器)   2.48%       0.04%       #...........................
A 随机放置(无感知)  单可用区失效 (9 台机器)   25.17%      2.74%       #######.....................
A 随机放置(无感知)  相关性故障   (27 台全中)  100.00%     100.00%     ############################
----------------------------------------------------------------------------------------------------
B 索引贪心(0,1,2)   单节点失效   (1 台机器)   0.00%       0.00%       ............................
B 索引贪心(0,1,2)   单机架失效   (3 台机器)   11.11%      11.11%      ###.........................
B 索引贪心(0,1,2)   单可用区失效 (9 台机器)   33.33%      33.33%      #########...................
B 索引贪心(0,1,2)   相关性故障   (27 台全中)  100.00%     100.00%     ############################
----------------------------------------------------------------------------------------------------
C 机架感知          单节点失效   (1 台机器)   0.00%       0.00%       ............................
C 机架感知          单机架失效   (3 台机器)   0.00%       0.00%       ............................
C 机架感知          单可用区失效 (9 台机器)   22.41%      1.16%       ######......................
C 机架感知          相关性故障   (27 台全中)  100.00%     100.00%     ############################
----------------------------------------------------------------------------------------------------
D 可用区感知        单节点失效   (1 台机器)   0.00%       0.00%       ............................
D 可用区感知        单机架失效   (3 台机器)   0.00%       0.00%       ............................
D 可用区感知        单可用区失效 (9 台机器)   0.00%       0.00%       ............................
D 可用区感知        相关性故障   (27 台全中)  100.00%     100.00%     ############################

结论:
  1. 单节点失效: 任何策略都不会丢多数派(3 个副本本来就在 3 台不同机器上)。
  2. 索引贪心(副本落在连号的 0/1/2): 一旦失效的正好是那个机架(概率 1/9),
     3 个副本会全部丢失: 机器编号往往与拓扑相关(同机架连号), '看起来高效'的
     确定性放置正好把
     3 个副本塞进同一个故障域。这就是随机放置优于贪心的原因: 随机化把
     未知的相关性打散, 而贪心的确定性会把未知的相关性放大。
  3. 机架感知把'单机架失效丢多数派'降为 0, 但一个可用区里有 3 个机架,
     3 个副本虽落在不同机架、却仍可能有两个同处一个 AZ(概率约 22%), 于是
     单可用区失效仍可能丢多数派 —— 故障域的层级必须与副本数对齐。
  4. 只有可用区感知能把'单可用区失效丢多数派'降为 0 —— 故障域的层级必须
     与副本数对齐: 3 个副本就要有 3 个互不相交的顶层故障域。
  5. 相关性故障(同一软件 bug、同一次配置推送)对所有放置策略都是 100% 致命:
     冗余只能对抗'独立'故障, 对抗'相关'故障唯一的手段是多样化(版本/实现异构)。

26.4.3 实验 3:可用性数学与 MTTR 敏感性分析

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""CS 425 Lecture 26 / 代码 3: 可用性数学与 MTTR 敏感性分析

    A = MTTF / (MTTF + MTTR)
定量回答: 提高可用性有两条路(提高 MTTF / 降低 MTTR), 为什么工程上通常
"减少恢复时间"更划算? 附带"几个 9"对应的停机时间表与规模效应。
仅使用 Python 标准库; 可直接 python3 运行。
"""

import math

HOURS_PER_YEAR = 365 * 24          # 8760
HOURS_PER_MONTH = 30 * 24          # 720


def availability(mttf_h, mttr_h):
    """A = MTTF / (MTTF + MTTR)"""
    return mttf_h / (mttf_h + mttr_h)


def downtime_hours_per_year(a):
    return (1.0 - a) * HOURS_PER_YEAR


def fmt_downtime(h):
    if h >= 24:
        return "%.2f 天" % (h / 24)
    if h >= 1:
        return "%.2f 小时" % h
    if h * 60 >= 1:
        return "%.2f 分钟" % (h * 60)
    return "%.2f 秒" % (h * 3600)


def mttr_for_target(mttf_h, target):
    """在 MTTF 固定时, 要把可用性做到 target, MTTR 必须是多少?"""
    return mttf_h * (1.0 - target) / target


def mttf_for_target(mttr_h, target):
    """在 MTTR 固定时, 要把可用性做到 target, MTTF 必须是多少?"""
    return mttr_h * target / (1.0 - target)


def nines_str(a):
    """把可用性写成 'N 个 9' 的直观形式"""
    n = -math.log10(max(1e-12, 1.0 - a))
    return "%.4f%%(%.0f个9)" % (a * 100, round(n))


def bar(v, vmax, width=34, ch="#"):
    n = int(round(v / vmax * width))
    return ch * max(0, min(width, n)) + "." * (width - max(0, min(width, n)))


def main():
    print("=" * 84)
    print("CS 425 L26 实验 3: 可用性 = MTTF/(MTTF+MTTR) 与两条改进路径")
    print("=" * 84)

    print("\n[1] '几个 9'对应的停机时间 (按一年 %d 小时 / 一个月 %d 小时计)"
          % (HOURS_PER_YEAR, HOURS_PER_MONTH))
    print("-" * 84)
    print("  可用性      每年停机        每月停机        说明")
    print("  " + "-" * 76)
    notes = {
        0.99: "两个 9: 单机、无自动化故障转移的典型水平",
        0.999: "三个 9: 有冗余但恢复主要靠人工",
        0.9999: "四个 9: 需要自动故障转移 + 严格的变更管理",
        0.99999: "五个 9: 需要多副本 + 秒级自动切换 + 变更零失误",
        0.999999: "六个 9: 只有极少数经过专门设计的系统能做到",
    }
    for target in (0.99, 0.999, 0.9999, 0.99999, 0.999999):
        d = downtime_hours_per_year(target)
        print("  %-12s %-14s %-14s %s"
              % (nines_str(target), fmt_downtime(d), fmt_downtime(d / 12), notes[target]))

    print("\n[2] 可用性对 MTTF / MTTR 的敏感性 (行=MTTF, 列=MTTR)")
    print("-" * 84)
    mttfs, mttrs = [1000.0, 10000.0, 100000.0], [0.1, 1.0, 10.0, 100.0]
    print("  " + "MTTF(h)".ljust(12) + "".join(("MTTR=%gh" % m).ljust(18) for m in mttrs))
    for m in mttfs:
        row = []
        for r in mttrs:
            a = availability(m, r)
            row.append("%.6f%% (%.1fh/y)" % (a * 100, downtime_hours_per_year(a)))
        print("  " + ("%8.0f" % m).ljust(12) + "".join(c.ljust(18) for c in row))

    print("\n[3] 两条路径的等价性: 减少 MTTR k 倍 ≡ 提高 MTTF k 倍")
    print("-" * 84)
    print("  因为 A = 1/(1 + MTTR/MTTF) 只取决于比值 MTTR/MTTF, 所以:")
    print("     把 MTTR 从 R 降到 R/k   ==>   等价于把 MTTF 提高 k 倍。")
    print("  数值例子(MTTF = 10000 小时):")
    mttf0 = 10000.0
    a10 = availability(mttf0, 10.0)
    a1 = availability(mttf0, 1.0)
    need = mttf_for_target(10.0, a1)
    print("     MTTR = 10 h  ->  可用性 %.4f%%  年停机 %s"
          % (a10 * 100, fmt_downtime(downtime_hours_per_year(a10))))
    print("     MTTR =  1 h  ->  可用性 %.4f%%  年停机 %s   (多出一个 9)"
          % (a1 * 100, fmt_downtime(downtime_hours_per_year(a1))))
    print("     若不动 MTTR(=10h), 想把可用性提到 %.4f%%, 需要 MTTF = %.0f h (提高 %.1f 倍)"
          % (a1 * 100, need, need / mttf0))

    print("\n[4] 规模效应: 系统 MTTF ≈ 单机 MTTF / n (讲义中的数据中心实测量级)")
    print("-" * 84)
    per_machine_years = 10.0                       # 一台机器平均 10 年出一次故障
    per_machine_h = per_machine_years * HOURS_PER_YEAR
    print("  单台机器 MTTF = %.0f 年 = %.0f 小时 (即平均每 120 个月出一次故障)"
          % (per_machine_years, per_machine_h))
    print("  机器数       系统 MTTF(平均多久出一次故障)      要做到 99.99% 所需的 MTTR")
    print("  " + "-" * 76)
    for n in (1, 120, 12000, 100000):
        sys_mttf = per_machine_h / n
        need_mttr = mttr_for_target(sys_mttf, 0.9999)
        print("  %-12d %-32s %s"
              % (n, "%.2f 小时" % sys_mttf if sys_mttf < 48 else "%.1f 天" % (sys_mttf / 24),
                 fmt_downtime(need_mttr)))

    print("\n  解读: 规模每扩大 10 倍, 系统 MTTF 就缩短 10 倍, 于是要保持同样的可用性,")
    print("        MTTR 也必须缩短 10 倍 —— 这就是'规模越大越要靠快速恢复'的数学根源。")

    print("\n[5] 从三个 9 到五个 9: 两条路各要付出多少?")
    print("-" * 84)
    for mttf in (10000.0, 1000000.0):
        mttr_now = mttr_for_target(mttf, 0.999)
        mttr_5n = mttr_for_target(mttf, 0.99999)
        print("  MTTF = %-9.0f h: 99.9%%  需要 MTTR = %-10s   99.999%% 需要 MTTR = %-10s  (改善 %.0f 倍)"
              % (mttf, fmt_downtime(mttr_now), fmt_downtime(mttr_5n), mttr_now / mttr_5n))
    print("  同样地, 若把 MTTR 固定在 10 小时, 99.9%% -> 99.999%% 需要把 MTTF 提高 %.0f 倍。"
          % (mttf_for_target(10.0, 0.99999) / mttf_for_target(10.0, 0.999)))

    print("\n[6] 用 ASCII 图看两条路径 (MTTF = 10000 h, 横轴 = MTTR, 对数刻度)")
    print("-" * 84)
    print("   MTTR      可用性        9 的个数")
    for r in (100.0, 30.0, 10.0, 3.0, 1.0, 0.3, 0.1, 0.03):
        a = availability(10000.0, r)
        nines = -math.log10(1.0 - a)
        print("   %6.2f h  %.6f%%   %s  (%.2f 个 9)"
              % (r, a * 100, bar(nines, 6.0, 24), nines))
    print("   说明: 横条长度 = 可用性的'9 的个数'(满格 6 个 9); MTTR 每缩小 10 倍就多约一个 9。")

    print("\n结论:")
    print("  1. 可用性只取决于比值 MTTR/MTTF, 所以两条路在数学上完全对称。")
    print("  2. 但在工程上不对称: MTTF 由硬件与软件质量决定, 在'商品化硬件 + 大规模'")
    print("     的前提下会随规模线性变差(10 万台机器时平均每几分钟就有一台故障);")
    print("     而 MTTR 由设计决定 —— 自动故障转移、快速重启、可回滚的变更、限流与熔断,")
    print("     这些都是可以工程化地把 MTTR 从小时级压到秒级的手段。")
    print("  3. 因此面向恢复的计算(ROC)主张: 追求'永不故障'不现实, 把 MTTR 做小才是")
    print("     提高可用性的主要杠杆; 冗余的作用也正是把'单机 MTTR'换成'系统切换时间'。")


def math_log10(x):
        return math.log10(x)


if __name__ == "__main__":
    main()

【代码做什么?】

  1. 实现 $A=\text{MTTF}/(\text{MTTF}+\text{MTTR})$,并给出“几个 9”对应的年/月停机时间表(99% 到 99.9999%)。
  2. 打印 MTTF × MTTR 的敏感性矩阵(3 × 4),直观看”同样的可用性可以来自完全不同的组合”。
  3. 验证两条路径的等价性:数值演示”MTTR 从 10 h 降到 1 h ⇒ 可用性 99.9001% → 99.9900%”,以及”若不动 MTTR,需要把 MTTF 提高 10 倍(10000 h → 100000 h)”。
  4. 规模效应:以”单机 10 年故障一次”为基准,计算 1 / 120 / 12000 / 100000 台规模下的系统 MTTF,以及”要做到 99.99% 需要多短的 MTTR”。
  5. 从三个 9 到五个 9 的代价,并画出 MTTR 与”9 的个数”的 ASCII 关系图。

【分布式机制透视】

  • 这段代码没有网络与进程,它模拟的是可靠性随系统规模与恢复速度的演化规律——即”MTTR 是一个设计变量”这件事的数学证明。它是本章所有防护机制(超时、熔断、舱壁、自动切换)的价值度量工具:每引入一个机制,问的不是”它优雅吗”,而是”它把 MTTR 压缩了几倍”。
  • “系统 MTTF ≈ 单件 MTTF / n” 与讲义 Lecture 5-6 的数量级(120 台 → 1 个月;12000 台 → 7.2 小时)一致;代码把它推广到 10 万台规模,得到 MTTR ≤ 0.32 秒的要求——这个数字本身就是”为什么必须自动化”的证明

【与理论的对应】

  • 26.3.6 结论 1/2:代码输出直接给出”减少 MTTR k 倍 ≡ 提高 MTTF k 倍”的等价性与数值例子(10000 h 与 100000 h 的对照)。
  • 26.3.6 结论 3:规模效应表证明 MTTF 在高规模下不可控(12000 台时只有 7.3 小时),因此可用性必须来自对 MTTR 的工程控制——这正是 ROC 的核心论据,也是讲义四个案例的共同教训(它们无一例外都在讨论”多少小时恢复、多少数据丢失”,而不是”如何让硬件不坏”)。
  • RPO/RTO 的工程含义:代码里的 MTTR 只刻画”不可用时间”,真实系统还要看 RPO(丢多少数据)。讲义的 AWS 2011 事故最终”0.07% 的卷永久丢失“就是 RPO 被击穿的实例:可用性恢复 ≠ 数据恢复,两者需要分别设计(跨 AZ 复制解决可用性,端到端校验与多副本修复解决数据完整性)。

运行输出(实际运行结果,节选)

====================================================================================
CS 425 L26 实验 3: 可用性 = MTTF/(MTTF+MTTR) 与两条改进路径
====================================================================================

[1] '几个 9'对应的停机时间 (按一年 8760 小时 / 一个月 720 小时计)
------------------------------------------------------------------------------------
  可用性      每年停机        每月停机        说明
  ----------------------------------------------------------------------------
  99.0000%(2个9) 3.65 天         7.30 小时        两个 9: 单机、无自动化故障转移的典型水平
  99.9000%(3个9) 8.76 小时        43.80 分钟       三个 9: 有冗余但恢复主要靠人工
  99.9900%(4个9) 52.56 分钟       4.38 分钟        四个 9: 需要自动故障转移 + 严格的变更管理
  99.9990%(5个9) 5.26 分钟        26.28 秒        五个 9: 需要多副本 + 秒级自动切换 + 变更零失误
  99.9999%(6个9) 31.54 秒        2.63 秒         六个 9: 只有极少数经过专门设计的系统能做到

[2] 可用性对 MTTF / MTTR 的敏感性 (行=MTTF, 列=MTTR)
------------------------------------------------------------------------------------
  MTTF(h)     MTTR=0.1h         MTTR=1h           MTTR=10h          MTTR=100h         
      1000    99.990001% (0.9h/y)99.900100% (8.8h/y)99.009901% (86.7h/y)90.909091% (796.4h/y)
     10000    99.999000% (0.1h/y)99.990001% (0.9h/y)99.900100% (8.8h/y)99.009901% (86.7h/y)
    100000    99.999900% (0.0h/y)99.999000% (0.1h/y)99.990001% (0.9h/y)99.900100% (8.8h/y)

[3] 两条路径的等价性: 减少 MTTR k 倍 ≡ 提高 MTTF k 倍
------------------------------------------------------------------------------------
  因为 A = 1/(1 + MTTR/MTTF) 只取决于比值 MTTR/MTTF, 所以:
     把 MTTR 从 R 降到 R/k   ==>   等价于把 MTTF 提高 k 倍。
  数值例子(MTTF = 10000 小时):
     MTTR = 10 h  ->  可用性 99.9001%  年停机 8.75 小时
     MTTR =  1 h  ->  可用性 99.9900%  年停机 52.55 分钟   (多出一个 9)
     若不动 MTTR(=10h), 想把可用性提到 99.9900%, 需要 MTTF = 100000 h (提高 10.0 倍)

[4] 规模效应: 系统 MTTF ≈ 单机 MTTF / n (讲义中的数据中心实测量级)
------------------------------------------------------------------------------------
  单台机器 MTTF = 10 年 = 87600 小时 (即平均每 120 个月出一次故障)
  机器数       系统 MTTF(平均多久出一次故障)      要做到 99.99% 所需的 MTTR
  ----------------------------------------------------------------------------
  1            3650.0 天                         8.76 小时
  120          30.4 天                           4.38 分钟
  12000        7.30 小时                          2.63 秒
  100000       0.88 小时                          0.32 秒

  解读: 规模每扩大 10 倍, 系统 MTTF 就缩短 10 倍, 于是要保持同样的可用性,
        MTTR 也必须缩短 10 倍 —— 这就是'规模越大越要靠快速恢复'的数学根源。

[5] 从三个 9 到五个 9: 两条路各要付出多少?
------------------------------------------------------------------------------------
  MTTF = 10000     h: 99.9%  需要 MTTR = 10.01 小时     99.999% 需要 MTTR = 6.00 分钟     (改善 100 倍)
  MTTF = 1000000   h: 99.9%  需要 MTTR = 41.71 天      99.999% 需要 MTTR = 10.00 小时    (改善 100 倍)
  同样地, 若把 MTTR 固定在 10 小时, 99.9% -> 99.999% 需要把 MTTF 提高 100 倍。

[6] 用 ASCII 图看两条路径 (MTTF = 10000 h, 横轴 = MTTR, 对数刻度)
------------------------------------------------------------------------------------
   MTTR      可用性        9 的个数
   100.00 h  99.009901%   ########................  (2.00 个 9)
    30.00 h  99.700897%   ##########..............  (2.52 个 9)
    10.00 h  99.900100%   ############............  (3.00 个 9)
     3.00 h  99.970009%   ##############..........  (3.52 个 9)
     1.00 h  99.990001%   ################........  (4.00 个 9)
     0.30 h  99.997000%   ##################......  (4.52 个 9)
     0.10 h  99.999000%   ####################....  (5.00 个 9)
     0.03 h  99.999700%   ######################..  (5.52 个 9)
   说明: 横条长度 = 可用性的'9 的个数'(满格 6 个 9); MTTR 每缩小 10 倍就多约一个 9。

结论:
  1. 可用性只取决于比值 MTTR/MTTF, 所以两条路在数学上完全对称。
  2. 但在工程上不对称: MTTF 由硬件与软件质量决定, 在'商品化硬件 + 大规模'
     的前提下会随规模线性变差(10 万台机器时平均每几分钟就有一台故障);
     而 MTTR 由设计决定 —— 自动故障转移、快速重启、可回滚的变更、限流与熔断,
     这些都是可以工程化地把 MTTR 从小时级压到秒级的手段。
  3. 因此面向恢复的计算(ROC)主张: 追求'永不故障'不现实, 把 MTTR 做小才是
     提高可用性的主要杠杆; 冗余的作用也正是把'单机 MTTR'换成'系统切换时间'。

26.5 性能与可扩展性分析

26.5.1 故障的真实概率与分布:用”规模 × 时间”预测故障

讲义的量化口径(Lecture 5-6):单台机器(OS/磁盘/主板/网络合计)平均 10 年(120 个月)出一次故障,则:

规模 $n$系统 MTTF $\approx$ 单机 MTTF$/n$等价说法
1 台120 个月(10 年)一台机器十年一坏
120 台1 个月每个月都会有一台机器出问题
12 000 台7.2 小时每天约有 3.3 台机器在故障
100 000 台52.6 分钟每天约 27 台机器在故障

普通”商品化硬件”量级的敏感性推算(【补充】以下为按常见年化故障率的数量级做的估算,用于建立直觉,不是某家公司的实测值):若单机年化故障率取 2%(相当于单机 MTTF = 50 年),则

\[\text{每天故障台数} = \frac{100000 \times 0.02}{365} \approx 5.5\ \text{台/天}\]

10 万台机器、年故障率 2% ⇒ 平均每天 5~6 台机器故障;若按讲义更保守的”10 年一坏”(年化 10%),同样规模会变成每天约 27 台。二者差了 5 倍,但结论完全一致:在这种规模下,故障不是”会不会发生”,而是”今天发生几次”

磁盘与内存(【补充】同为量级推算,用来说明”为什么必须做端到端校验”):

  • 若单块磁盘年化故障率 2%、一台机器 12 块盘,则 10 万台机器每年约 2.4 万块盘需要更换——平均每月 2000 块。这解释了为什么现代分布式存储(Ch.22 的 GFS 等)把”磁盘故障”当作常态事件来设计,而不是异常。
  • 若内存的不可纠正位错误率是每 GB·小时 $10^{-9}$ 量级,则 1 TB 内存、10 万台机器、每天产生的错误数量级为 \(1000\ \text{GB} \times 24\ \text{h} \times 10^{-9} \times 100000 \approx 2.4\ \text{次/天}\) ECC 内存会纠正绝大多数此类错误,但残留的”静默数据损坏”才是真正危险的部分:它不报错、不崩溃,只是数据悄悄变错——这正是 checksum 与端到端校验存在的理由(Ch.22):崩溃可检测,损坏不可检测。

规模 × 时间的推论:任何”单点故障率 $p$ 极小”的组件,只要系统足够大、运行足够久,$1-(1-p)^{n\cdot t}$ 都会趋近于 1。设计时必须假设在系统的生命周期内,每一个”可能的故障”都会发生——包括那些概率是 $10^{-6}$ 的。讲义的案例恰好覆盖了三个概率量级:误操作(高频)、配置错误(高频)、变压器起火(低频但后果极端)。

26.5.2 成本与收益的权衡:RTO/RPO 决定架构与账单

可用性不是免费的,它是被购买的。讲义在 The Planet 案例里直接点出:跨机房/跨提供商冗余“可能让客户花更多钱”,并类比成保险费(insurance premiums)。把成本量化下来,架构选择就变成一道算术题:

目标需要的手段成本典型场景
RPO ≈ 0、RTO 秒级同步复制到 ≥3 个故障域 + 自动故障转移 + quorum3 倍存储 + 跨区带宽 + 更高写延迟(同步复制要等多数派确认)支付、元数据、锁服务
RPO 分钟级、RTO 分钟级异步复制 + 半自动切换2~3 倍存储 + 少量跨区流量大多数在线业务
RPO 小时级、RTO 小时级定期快照 + 冷备机房1.x 倍存储后台分析、日志
RPO 天级、RTO 天级每日备份 + 可重建最低可重算的数据、缓存

关键洞察:RTO 与 RPO 共同决定成本,而用户可感知的往往只有 RTO。讲义的两个案例都落在”RTO 极长”这一侧:The Planet 约 3 天,AWS 2011 3.5 天(含长尾恢复),AWS 2025 约 14 小时(11:48 PM → 次日 1:50 PM)。这些数字远大于”99.99% ⇒ 年停机 52.6 分钟”的预算——一次这样的事故会把好几年的可用性预算一次烧光

26.5.3 级联失败的时间尺度:秒级崩溃 vs 小时级人工响应

把本章实验与讲义案例的时间尺度并排:

阶段本章模拟的实验数据讲义案例的现实对应
依赖变慢$t=15$ s 注入(容量 1000/s → 50/s)AWS 2011:一次网络流量调整(12:47 AM)
调用方池被占满数百毫秒~1 秒内svc 池占用率在 16 s 已达 100%)AWS 2011:控制面”操作积压 → 线程池填满 → 开始拒绝请求”
入口积压爆发约 5 秒后积压破千(20 s)、13 秒后破五千(28 s)Facebook 2010:数据库”很快被每秒十万级查询压垮”
用户可见的全线失败约 10~20 秒内成功率跌到个位数AWS 2011:错误率在几分钟内全面上升
人工检测 + 决策 + 止血本模型假设自动机制即时生效(不含人工环节)AWS 2011:12:47 AM 出错 → 2:40 AM 才禁用创建卷接口;AWS 2025:11:48 PM 出错 → 2:25 AM DynamoDB 才恢复 → 1:50 PM EC2 才正常

结论级联失败是秒级的,而人的响应是分钟到小时级的。因此:

  1. 止血必须自动化(超时、熔断、负载丢弃、自动故障转移、自动回滚)——它们必须在毫秒~秒的时间尺度上生效,这正是它们必须写进代码、而不是写进运维手册的原因。
  2. 人工动作留给自动化做不了的事:判断是否牺牲某个功能、是否切换区域、是否接受数据丢失(RPO 决策)。
  3. 自动化的每一步都要有护栏(AWS 2025 的教训):自动化把 MTTR 压小,但也可能把故障放大——因为它能在秒级内做出成千上万次错误决策(讲义原话:根因正在从人转向 agent)。

26.5.4 “五个 9”的现实性

99.999% 意味着全年不可用 5.26 分钟。要做到它,必须同时满足:

  1. 多副本 + 多故障域:任何单点(机器、机架、AZ、交换机、电源、DNS、认证、配置系统)都不能成为单点——包括控制面(AWS 2025 的 DNS 记录)与自动化程序(Enactor)。
  2. 秒级自动故障转移:由 26.3.6 的规模效应可知,12000 台规模下要做到 99.99% 就需要 MTTR ≤ 2.6 秒;五个 9 的要求更苛刻。任何”打电话叫人”的流程都不可能达标
  3. 变更零失误:一次错误的配置推送可以让全年预算在一次事故中归零。因此必须:金丝雀(1% → 5% → 25% → 100%)、自动回滚、特性开关、变更冻结、错误预算。
  4. 容量必须真实可用:冗余必须有承载能力(讲义:The Planet 的其他机房”受限于电力与冷却”;American Eagle”在灾难中才发现无法切换到备份机房”)。$N+1$ 只是及格线,接管场景要按”最大单域损失”预留。
  5. 恢复路径也要演练:恢复动作本身(重建副本、重放日志、重建缓存、重签租约)往往从未在真实压力下运行过——AWS 2025 的”DWFM 不给过期租约续约 ⇒ 全量重签 ⇒ 充血性崩溃”就是恢复路径的尖峰把系统再打垮一次。

现实判断:真正的五个 9 通常不是”一个 99.999% 的大系统”,而是由许多小单元(cell)拼出来的组合可用性:单个 cell 只需做到两三个 9,但通过故障隔离(一个 cell 挂掉不影响其他 cell)与快速的 cell 重建,整体达到四个九到五个九——这正是 26.5.5 的 cell 架构。

26.5.5 现代实践:把”打断依赖链”变成架构

讲义在 AWS 2025 的教训里提出一个开放问题:“级联的原因让故障持续,我们需要新的原则和打断这些依赖链的方法”。以下是今天工程界给出的答案【补充,直接回应讲义的提问】:

实践核心思想它打断了哪条依赖链
多区域 / 多云不把可用性押在单一区域、单一供应商上(讲义提到跨提供商冗余与 Sky Computing 的方向)打断”供应商级故障 + 数据锁定”链条;代价是一致性复杂度(跨区复制延迟、冲突处理)与出口流量成本
单元架构(Cell-based Architecture)把系统切成彼此独立的小单元(每个 cell 有自己的一整套依赖:DB、缓存、队列、LB),请求按用户/租户路由到固定 cell把”全局故障”变成”1/N 的故障”——爆炸半径从 100% 降到 1/N;这是 26.2.4 故障域思想在服务层的应用
静态稳定性(Static Stability)控制面故障时数据面必须继续工作:提前把路由/容量/策略下发并缓存到数据面,故障期间不依赖控制面做新决策打断”元数据服务故障 ⇒ 全部依赖它的系统瘫痪”(AWS 2025 的 DWFM 依赖 DynamoDB 就是反面教材)
预计算与缓存提前算好路由表、容量上限、限流阈值、备用 DNS 记录;缓存配置并支持 TTL 过期后使用”最后已知良好值”打断”恢复时需要重新计算 ⇒ 恢复本身造成过载”(Facebook 恢复时”慢慢重建缓存”、AWS 2025 的”租约全量重签”)
分区/分片 + 独立配额每个分片/租户有独立配额与独立队列,热点或坏租户不能拖垮全局打断”一个热点 ⇒ 全局资源耗尽”(连接 Lecture 2 的多租户与 Lecture 9 的分区键设计)
降级预案产品化把”只读模式”“关闭个性化”“返回旧数据”做成可一键切换的产品功能,并定期演练打断”依赖不可用 ⇒ 功能完全不可用”

26.5.6 与前面各章的连接:每个理论概念在真实故障中的角色

前面的理论概念在真实故障中的角色讲义中的对应证据
故障检测的误判(Ch.7 / Lecture 5-6)误判触发错误决策:把”联系不上”当成”没有备份” ⇒ 激进重镜像;健康检查在 failed/healthy 之间翻转 ⇒ DNS 抖动AWS 2011 的重镜像风暴;AWS 2025 的 NLB 健康检查 flip-flopping
时钟不同步(Ch.11 / Lecture 12)用本地时间戳判断”谁更新” ⇒ 时钟错乱就丢更新;用版本号/序列号可避免依赖物理时钟AWS 2025 的 Enactor 用 plan 的时间戳判新鲜度,慢者用过期的判断覆盖了新数据
脑裂与 quorum(Ch.16/Ch.17)分区期间必须”少数派不服务”,否则双主写入;”保证只有一个 primary”的协商必须有 fencingAWS 2011:副本在分区后自行判断并重建;”保证一个 primary 的协商”被竞态破坏
2PC 的阻塞(Ch.20)协调者故障时参与者持有锁/资源 ⇒ 长时间占用 ⇒ 与其他故障叠加成全局不可用AWS 2025:DWFM 的租约本质是一次分布式租约协调,恢复后全量重签把系统压垮
重试与 exactly-once(Ch.18)重试是 at-least-once 语义的必然结果,必须靠幂等 + 去重才安全;无界重试就是自伤Facebook 2010:无退避重试把数据库打垮;讲义点名 Exponential backoff(源自 802.11 的拥塞避免)
一致性模型选择(Ch.10)强一致(quorum 复制)⇒ 分区时可能不可用;最终一致 ⇒ 故障期间可继续服务但可能读到旧值。故障场景下的一致性决策就是可用性决策AWS 2011”使用多 AZ 的客户未受影响”:他们用跨 AZ 一致性成本换到了可用性
静默数据损坏与 checksum(Ch.22)崩溃可检测、损坏不可检测;必须端到端校验并用其他副本修复AWS 2011 最终 0.07% 的卷永久丢失(副本同时损坏 + 修复路径失效)
straggler 与备份任务(Ch.5 / Ch.21)慢节点拖慢整个并行作业,备份任务/推测执行是标准解法;反过来,慢依赖也会拖垮调用方本章实验:一个慢 20 倍的依赖把上游 60 个线程全部占死 ⇒ 与 MapReduce straggler 同构

26.5.7 故障类型 → 检测手段 → 防护机制 → 相关章节(全课程串联表)

故障类型检测手段防护机制相关章节
进程崩溃心跳超时 / SWIM 的 ping-req / Suspicion副本切换、自动重启、leader 选举Ch.7、Ch.16
灰色故障(变慢/部分失败)客户端视角 SLI、跨层关联、合成探测、慢请求追踪超时、熔断、舱壁、负载丢弃、渐进式重平衡Ch.7、Ch.18、本章 26.2.3/26.3
网络分区(含非对称)心跳丢失 + 多路径探测 + quorum 判定quorum 写、少数派只读、分区期间禁止自动重配置Ch.16、Ch.17
节点过载 / 惊群队列长度、池占用率、p99 延迟限流、优先级丢弃、退避 + 抖动、请求去重(单飞)本章 26.3.2/26.3.3
依赖不可用错误率 + 超时率(熔断器窗口统计)熔断、降级、缓存兜底、多依赖冗余本章 26.3.1
数据损坏(静默)端到端 checksum、副本比对、读时校验校验和 + 副本修复 + 定期 scrubCh.22
配置错误 / 坏版本金丝雀指标对比、自动回滚判据、配置 schema 校验灰度发布、特性开关、变更审计与冻结本章 26.2.14
元数据 / 控制面故障元数据服务 SLI、DNS 解析成功率静态稳定性、元数据多副本、预计算路由、多 DNS本章 26.5.5、Ch.22
相关性故障(机架/AZ/版本)拓扑感知的部署校验、版本分布监控故障域隔离、反亲和、多样化、渐进发布本章 26.2.4 / 26.3.5
人为误操作变更审计、双人复核、异常检测最小权限、回收站与延迟删除、一键回滚本章 26.2.11
容量不足 / 热点饱和度、分片倾斜度容量规划($N+1$/$N+2$)、分片打散、cell 架构本章 26.5.2 / 26.5.5
时钟 / 时序问题时钟偏移监控、逻辑时钟与版本号版本号替代表时间戳、NTP 监控、单调时钟Ch.11
安全攻击(DoS)流量异常、错误率突增限流清洗、任播、多 DNS、最小权限Ch.25

26.5.8 真实世界可靠性设计清单(Checklist)

#原则具体动作自检问题
1假设故障会发生,并且假设你的假设是错的为模型外故障(配置错误、误操作、静默损坏)设计兜底“如果配置写错了,系统会怎么表现?”
2隔离故障域跨机架/AZ/区域放置;舱壁分池;cell 架构;独立配额“一个机架/一个 AZ/一个依赖挂掉,爆炸半径是几成?”
3让失败快速失败所有远程调用设超时并传播 deadline;熔断;负载丢弃“依赖变慢 20 倍时,我的线程池多久被占满?”(本章实验:不到 1 秒)
4让恢复快速且自动自动故障转移、自动重启、一键回滚、预热与懒加载“MTTR 是几秒还是几小时?这个数字能满足 5 个 9 吗?”
5变更要可控、可回滚、可观测金丝雀 → 蓝绿 → 特性开关;变更审计与冻结“回滚比发布更快吗?演练过吗?”
6端到端校验checksum、副本比对、定期 scrub、读写校验“磁盘说写成功,我凭什么相信?”
7用混沌工程主动验证稳态假设 + 生产小范围注入 + 自动终止开关“上一次真实演练是什么时候?发现了什么?”
8从事故中学习并修复根因无指责复盘、可验证的行动项、公开分享“上次事故的行动项,完成了几条?”
9有界重试指数退避 + 全抖动 + 重试预算 + 幂等“下游已经过载时,我的重试是在帮忙还是在补刀?”
10可观测性优先指标/日志/追踪;从客户端视角定义 SLI;错误预算告警“故障时我能在 60 秒内说出是哪一跳慢了吗?”

规模化的性能小结

机制时间开销空间开销消息/资源开销可扩展性瓶颈
熔断器每次调用 $O(1)$$O(N_{\min})$ 窗口故障期间下游流量 → $O(K/T_{\text{cool}})$多实例间状态不共享(每实例独立熔断,可能”部分熔断”)
退避重试$O(1+r_{\max})$ 次调用$O(1)$$\le B$ 倍正常流量(预算生效时)下游容量;预算判据的准确性
优先级丢弃$O(\log\vert Q\vert )$ 入出队$O(L_{\text{hard}})$被丢弃请求 0 下游消息优先级真实性;低优先级饥饿
舱壁准入 $O(1)$$O(\sum C_i+\sum Q_i)$各依赖连接数 $\le C_i$配额分配(静态配额在流量倾斜时浪费)
故障域放置$O(R\log\vert F\vert )$$O(R)$ 元数据/对象跨域复制的带宽与延迟域的数量($\vert F\vert \ge R$ 是硬约束);跨域写延迟
自动故障转移检测 $O(T_{\text{fail}})$ + 切换$O(1)$切换瞬间的流量重分配检测时间与脑裂风险(需要 quorum/fencing)

26.6 关键要点

  1. 真实系统的问题几乎从不是”崩溃”,而是”变慢”、”部分失败”和”配置错误”。 讲义的四个案例全部如此:一次网络流量调整、一个无效配置值、一次变压器起火、一次 DNS 空记录——没有一个是干净的 crash-stop。因此设计的重点不是让系统永不故障,而是让故障的影响可隔离、可快速恢复、可被观测到。
  2. 减少 MTTR 比增加 MTTF 更有效,而控制爆炸半径比重试更有效。 由 $A=\text{MTTF}/(\text{MTTF}+\text{MTTR})$ 与”系统 MTTF ≈ 单机 MTTF / $n$”可知:规模越大,MTTF 越不可控;而在 MTTF=10000 h、MTTR=10 h 的配置下,省下 1 小时恢复时间等价于增加 1000 小时无故障时间
  3. 冗余的可靠性等于”故障域的数量”,不等于”副本的数量”。 3 个同机架副本在机架断电时等于 0 个副本;实验测得随机放置在单机架失效下丢多数派概率 2.48%(索引贪心 11.11%),而可用区感知放置为 0而面对相关性故障(同版本、同配置),任何放置策略都是 100% 失效——唯一的缓解是多样化。
  4. 失败放大是重大故障的直接原因,而”重试”是最常见的放大器。 1 个请求重试 3 次 = 下游 4 倍负载;每层都重试 3 次的 3 层链路 = 64 倍。实验证明无退避重试让入口负载放大 1.42 倍、积压峰值 +32%,而成功率毫无提升重试必须有界、退避、抖动、有预算,且只用于幂等操作。
  5. 超时、熔断、舱壁、负载丢弃是四种互补的”止血”机制,必须在毫秒~秒级自动生效。 级联失败是秒级的(实验:依赖变慢后不到 1 秒调用方池被占满、约 10~20 秒全线失败),而人的响应是分钟到小时级的(案例:从出错到止血 2~3 小时,到完全恢复 3.5 天/14 小时)。
  6. 从事故中学习是唯一能”把故障变成资产”的方式。 无指责复盘 + 可验证的行动项 + 公开分享;讲义明确指出:经历宕机并认真修复的公司,之后的基础设施反而更强

26.7 常见陷阱与注意事项

  1. 陷阱一:”我们有 3 个副本,所以是可靠的。” 为什么错:可靠性来自故障域的数量。若 3 个副本在同一机架/同一 AZ/同一电源域,一次事件就能全灭(26.4.2 实验:索引贪心在单机架失效下 11.11% 全部丢失)。 正确做法:显式定义故障域层级,用反亲和约束把副本铺到与副本数同级的顶层域(3 副本 → 3 个 AZ),并在部署时校验(防止运维脚本/调度器把副本又挤到一起)。

  2. 陷阱二:”我们加了超时,所以不会被拖垮。” 为什么错超时的值比有无更重要。本章实验里,”库默认 3 秒”与”规划过的 250 毫秒”产生了完全不同的结果:默认超时下上游线程被长期占用,健康依赖的成功率只有 16.8%;合理超时下升到 32.2%。而且超时不会自动传播——上游放弃后,下游可能还在消耗资源。 正确做法:超时值由延迟预算反推(略大于 p99),并沿调用链传播剩余预算(deadline propagation);对不同优先级用不同预算。

  3. 陷阱三:”失败了就重试,重试能提高可用性。” 为什么错:重试只在容量充足的瞬时故障下有用。下游过载时,重试是自伤(实验:+42% 入口负载、+32% 积压、+35 ms 延迟、成功率不变);多层重试还会指数放大;非幂等操作的重试会造成重复副作用。 正确做法:有界 + 指数退避 + 全抖动 + 重试预算 + 幂等键;下游明显不可用时主动不重试(配合熔断)。

  4. 陷阱四:”看平均延迟就够了。” 为什么错:平均值把”1% 的用户等 10 秒”掩盖成”平均 100 ms”。灰色故障恰恰表现为尾部延迟爆炸而平均值正常;实验里 S1 的平均延迟 443 ms(看起来”还行”)而 P95 是 2250 ms正确做法:用分位数(p50/p95/p99)与直方图,并把 SLI 定义在客户端视角;把”成功率”与”延迟预算内的成功率(好请求率)”分开看。

  5. 陷阱五:”监控面板显示一切正常。” 为什么错:服务器自身指标(CPU、内存、进程存活)在灰色故障下完全正常——它只是”对一部分客户端很慢”。讲义案例中 AWS 2025 的健康检查结果在 failed/healthy 之间来回翻转,正是”内部视角无法判断自身健康”的写照。 正确做法从客户端视角测量(合成探测、端到端 SLI)、跨层关联(把网关/服务/DB/网络指标对齐)、用分布式追踪定位”卡在哪一跳”,并在池占用率、队列长度这类饱和度指标上做早期告警。

  6. 陷阱六:”配置改动很小,不用走发布流程。” 为什么错:讲义中配置错误是最常见且最致命的根因之一:一个无效配置值让 Facebook 全站宕机 2.5 小时;一个空的 zone file 让 1300 万德国网站消失;AWS 2025 的空 DNS 记录引爆了一整天的多服务故障。配置改动往往直接生效、没有类型检查、没有回滚、影响所有实例正确做法把配置当代码:版本化、评审、schema 校验、灰度、可秒级回滚;危险操作(删除、DNS 发布、断路器操作)加双人复核与延迟生效。

  7. 陷阱七:”熔断器打开了,说明我们系统出故障了。” 为什么错:熔断器打开是保护动作,不是故障本身;把它当成”要立刻人工关闭的告警”会导致运维把保护撤掉(AWS 2025 中工程师禁用健康检查自动化就是人工接管自动化的例子,方向是对的,但应当有既定流程而不是临时决定)。 正确做法:把熔断状态纳入标准指标与 SLO 语义(快速失败要返回可区分的错误码与 Retry-After),并为自动化提供总开关(kill switch)+ 护栏(变更速率上限、影响面评估)

  8. 陷阱八:”恢复就是把故障节点修好。” 为什么错恢复路径本身会制造尖峰:AWS 2025 中 DynamoDB 恢复后”所有租约同时过期 ⇒ 全量重签 ⇒ DWFM 充血性崩溃”;Facebook 恢复时只能”慢慢放用户回来”以免再次压垮数据库;大规模重启还会带来冷启动风暴(同时抢配置、缓存、连接)。讲义的”长尾恢复”(AWS 2011 到 4 月 24 日才恢复 98.96% 的卷)也是同一现象。 正确做法:把恢复过程当作一次受控的流量事件来设计:错峰(jitter)、限速(token bucket)、分批(batch)、预热(warm-up)、并在演练中验证恢复路径本身。

26.8 思考题(带答案)

题 1(计算与推演) 某服务由 12 000 台机器组成,单台机器的 MTTF 是 10 年(讲义口径),当前故障后平均需要 2 小时人工介入才能恢复(MTTR = 2 h)。 (a)系统 MTTF 是多少?(b)当前系统可用性是多少?(c)若要做到 99.99%,MTTR 必须压到多少?(d)如果不动 MTTR,只提高单机 MTTF,需要提高多少倍?请据此说明 ROC 的主张。

: (a)$\text{MTTF}_{\text{sys}} \approx 10\ \text{年}/12000 = 87600\ \text{h}/12000 = 7.3\ \text{h}$(与讲义”约 7.2 小时”一致)。 (b)$A=\dfrac{7.3}{7.3+2}=\dfrac{7.3}{9.3}=78.5\%$。(注意:这说明“单机很可靠”完全不能保证系统可靠”——单机 10 年一坏的系统,整体可用性只有 78.5%。) (c)要求 $A\ge 0.9999$:$\dfrac{7.3}{7.3+\text{MTTR}}\ge 0.9999 \Rightarrow \text{MTTR}\le 7.3\times\dfrac{0.0001}{0.9999}\approx 7.3\times10^{-4}\ \text{h}\approx \mathbf{2.6\ 秒}$。 (d)由 $A$ 只依赖比值 $\text{MTTR}/\text{MTTF}$:要维持同样的 $A$,把 MTTR 缩小 $k$ 倍等价于把 MTTF 提高 $k$ 倍。从 2 h 压到 2.6 s 是约 2770 倍;等价地,需要把单机 MTTF 提高 2770 倍,即 10 年 → 27 700 年——这在物理上不可能。 结论:在”商品化硬件 + 大规模”下,可用性的唯一可控杠杆是 MTTR:必须用自动检测 + 自动故障转移 + 自动重启/回滚把”2 小时”压到”秒级”。这就是 ROC 的核心主张。(补充:真实系统还会用冗余把”整机故障”转化为”副本切换”,从而把 MTTR 从”修机器的时间”降为”切换时间”。)

题 2(”直观但错误”) 有同学说:”我们给每个请求都重试 3 次,单次请求成功率 99%,四次尝试都失败的概率是 $0.01^4=10^{-8}$,所以我们的可用性从 99% 提升到了 99.999999%。这个推理错在哪里?”

:错在四个隐含假设全部不成立

  1. 故障不是独立的。99% 的失败率通常来自系统性问题(依赖挂了、配置错了、容量不足),此时”四次尝试”面对的是同一个故障状态,$0.01^4$ 的前提(独立同分布)不成立。更准确地说:瞬时故障(独立)可以用重试掩盖,持续故障(相关)不能
  2. 重试会改变被重试对象的成功率。本章实验测得:无退避重试把入口尝试次数放大 1.42 倍、积压峰值推高 32%、平均延迟从 443 ms 升到 478 ms,而成功率一点没变——因为下游的有效容量并没有因为重试而增加,重试只是让更多人挤在同一个瓶颈上。
  3. 代价没有被计入。”成功”不只是”最终成功”,还包括延迟:用户等待 4 次退避后的成功,体验上可能等同于失败(这也是”好请求率 ≤100 ms”这个指标存在的意义)。
  4. 非幂等操作的重试会产生重复副作用(Lecture 18:需要幂等键/去重才能安全地 at-least-once 重试)。 正确表述:重试能提高瞬时故障下的成功率,但必须有界 + 退避 + 抖动 + 预算,且只有在下游仍有余量时才有正面作用;对于持续故障,正确的动作是熔断 + 降级 + 负载丢弃,而不是加重试。

题 3(案例分析) 在 AWS 2025 的事故中,dynamodb.us-east-1.amazonaws.com 的 DNS 记录变空之后,为什么”EC2 新实例创建失败”和”NLB 健康检查抖动”会跟着发生?请画出依赖链,并给出三个能打断该链条的护栏。

:依赖链(讲义时间线):

 两个 DNS Enactor 并发写同一份 plan (缺少并发控制)
        |
        v
 dns 记录为空 (dynamodb.us-east-1.amazonaws.com)
        |
        v
 us-east-1 所有依赖 DynamoDB 的系统解析失败 ("panicked")
        |
        +--> DWFM(实例管理) 的健康检查依赖 DynamoDB ==> 检查失败
        |         |
        |         v
        |    11:48PM-2:24AM DWFM 与实例之间的"租约"陆续过期
        |         |
        |         v
        |    2:25AM DynamoDB 恢复, 但 DWFM 不认为过期租约可续约
        |         ==> 必须为 us-east-1 全部实例重新建立租约
        |         ==> 租约系统与 DWFM 被压垮 ("DWFM 的充血性崩溃")
        |         |
        |         v
        |    5:28AM 租约洪流传播 ==> 压垮 EC2 Network Manager
        |         ==> EC2 延迟升高 (6:21AM - 10:36AM)
        |         |
        |         v
        |    新实例创建返回 "request limit exceeded"/"insufficient capacity"
        |
        +--> NLB 的健康检查系统看到 EC2 实例"失败"
                  |
                  v
             失败 ==> 从 DNS 摘除该实例 (增加 DNS 负载)
             健康 ==> 又加回 DNS (再增加 DNS 负载)
                  ==> 结果在 failed/healthy 之间来回翻转
                  ==> 5:30AM-2:09PM NLB 持续异常, 连多 AZ 的 LB 也被拖累

三个能打断链条的护栏

  1. 并发控制护栏:plan 写入使用版本号 + CAS(compare-and-swap)单写者(每个 AZ 的 Enactor 通过领导者选举串行化),并在写入前重新校验新鲜度;删除语义改为逻辑删除(不允许”用空内容覆盖”)。这直接消除根因——“只在步骤 1 检查新鲜度”的 check-then-act 竞态
  2. 静态稳定性护栏:让 EC2 的租约/健康检查不依赖控制面路径(DynamoDB/DNS)——把租约的续期逻辑改为”控制面不可用时继续沿用本地已知有效的长租约 + 事后对账”,而不是”控制面一断就批量过期”;同时对过期租约的续期做限速与错峰(token bucket + jitter),避免恢复瞬间的尖峰。
  3. 健康检查护栏:健康判定加迟滞 + 去抖 + 变更速率上限(例如”同一实例 60 秒内只允许一次 DNS 变更”),并把”因 DNS 抖动而摘除”与”真的故障”区分开;此外,自动化必须有 kill switch 与影响面评估(工程师 9:36 AM 禁用健康检查自动化就是事后做了这件事,但它应当是可预先配置的运行模式)。

题 4(设计题) 你要为一个新的分布式存储服务设计副本放置策略。系统有 3 个区域(region),每个区域 3 个可用区,每个可用区若干机架。你有 3 个副本,目标是”单可用区失效不丢数据且仍可读写”。请说明你的放置策略、需要的最小故障域数量,并回答:如果后来发现一个”只在闰秒时触发的软件 bug 会让所有副本同时出错”,你的设计还成立吗?应当怎么改?

放置策略:3 个副本放在3 个不同可用区(若区域间延迟可接受,进一步跨 3 个区域)。最小故障域数量 = 副本数 3(26.3.5 定理 1:$R$ 个副本需要 $R$ 个互不相同的顶层故障域,才能保证单域失效至多损失 1 个副本、存活 2 个 ≥ 多数派 2)。同时应满足:(a)选择新副本时只在”原副本所在域之外”的域中选(修复路径也遵守反亲和);(b)部署校验(防止调度器把副本挤到一起);(c)跨域写使用 quorum,并接受跨 AZ/跨区域写延迟与流量成本。 关于闰秒 bug设计不再成立。这是一个跨故障域的相关性故障——所有副本运行同一版本、面对同一时间事件,于是 $\mathcal{S}$(受影响节点集合)覆盖全部副本,26.3.5 定理 2 给出结论:$P(\text{全部副本失效})=P(\mathcal{E})$,与副本数和放置方式无关(实验 3 中”相关性故障”一行对所有策略都是 100%)。 应当怎么改

  1. 多样化(diversity):让副本在版本、实现、运行时/库上异构——例如保留一个”独立实现”的副本、或让不同副本运行不同大版本($N-1$ 与 $N$ 共存),使同一 bug 不能命中全部副本。
  2. 渐进式发布:杜绝”所有副本同一时刻升级到同一版本”(金丝雀 + 分域滚动 + 变更冻结窗口覆盖闰秒这类已知风险时刻)。
  3. 对已知时间风险做防护:对闰秒/时区/夏令时等时间事件使用单调时钟做时长测量、用版本号而非时间戳判新旧(连接 Ch.11)、并在闰秒窗口内冻结变更、加强观测
  4. 检测与恢复:加入跨副本的结果比对/校验和(防静默损坏,连接 Ch.22)与”最后已知良好快照”,保证在最坏情况下仍能恢复到一致状态(RPO 目标明确化)。