系统是被打出来的 · 系统设计实战手册
🔥 实战关卡四 · 真实事故 · PART 4

GitHub 2018
四十三秒与二十四小时

网络断了 43 秒。自动 failover 完全按配置正确地执行了。然后是 24 小时 11 分钟的服务降级——不是因为他们修不好,而是因为他们选择了不快点修好。

关于本页的事实边界:本页根据 GitHub 官方公开复盘改编,细节经过叙事化处理。 技术事实、时间线与决策链均取自页末所列官方原始文档,每个事实性陈述后以 [S1] 标注出处;氛围与心理描写为叙事化补充,不代表当事人的真实言行。 本页不虚构任何人物台词,不指名批评任何个人。官方复盘明确指出自动化组件 「完全按其配置行事」,本页不将其表述为工具缺陷。

关卡 4 · 1 现场43 秒

2018 年 10 月 21 日 22:52 UTC。一次例行维护——更换出故障的 100G 光设备——导致 GitHub 的美东网络枢纽与美东主数据中心之间失去连通性 [S1]。

连通性在 43 秒后恢复 [S1]。

但这短短 43 秒触发了一连串事件,最终造成 24 小时 11 分钟的服务降级 [S1]。

网络中断
43 秒
● 例行维护引发
服务降级
24h 11m
▲ 约中断时长的 2000 倍
分叉写入
954 条
最繁忙集群,受影响窗口内
用户数据丢失
无
● 这是代价换来的

一次合法 failover 如何制造数据分叉

先交代背景。GitHub 用 MySQL 存非 Git 的元数据(PR、issue、认证、后台任务等),集群规模从数百 GB 到近 5 TB,每个集群带最多数十个只读副本 [S1]。拓扑管理与自动 failover 交给 Orchestrator,它基于 Raft 做共识 [S1]。

官方复盘在讲事故之前,先自己点了一句隐患——请记住它,因为整场事故都长在这句话上:

「Orchestrator 可能实现出应用层根本支撑不了的拓扑,因此必须让它的配置与应用层的预期对齐。」[S1]

翻译成工程语言:数据层的共识算法,不知道应用层能不能活。

43 秒里发生的事,一步都不违反设计:

22:52 UTC · 第 0 秒

网络分区发生。原本在主数据中心活跃的 Orchestrator 按 Raft 共识开始"卸任"流程 [S1]——这是 Raft 该做的事。

分区期间

美西数据中心与美东公有云的 Orchestrator 节点凑齐了法定人数(quorum),开始把集群 failover 过去,让写入指向美西 [S1]——这也是 Raft 该做的事:多数派还在,就该选出新领导。

22:52:43 · 网络恢复

应用层立刻开始把写流量打向美西的新主库 [S1]——这同样是应用层该做的事:主库在哪就写哪。

此刻,问题成型

美东的数据库上有一小段还没复制到美西的写入;而美西正在持续接收新写入。两边都有对方没有的数据,因此无法安全地把主库切回美东 [S1]。

这里要精确一点,因为这是最容易被讲错的地方:官方复盘并没有证据表明两地数据库「同时」作为主库持续接受写入。它更准确的名字是跨区域自动 failover 之后的数据分叉——美东留着 failover 前没来得及复制出去的写,美西接管后产生了新的写。但一旦进入恢复阶段,它和病历卡 No.14 面对的是同一道难题:

病历卡 No.14脑裂██‖██ 两边各有对方没有的事实,系统无法自动判断该覆盖哪一边。 触发点是一台出故障的 100G 光设备及其维护过程中的网络分区——所以不能说"没有组件坏"; 但官方既没有把事故归因于个人操作失误,也没有归因于 Orchestrator 的代码缺陷: 它完全按配置完成了跨区域 failover,只是应用层扛不住那个拓扑。 与第 15 章 No.14 的恢复难题对照阅读 →

未复制到美西的写入总量其实很小——官方举的例子是:最繁忙的一个集群,在受影响窗口里有 954 次写入 [S1]。

954 条。接下来的 24 小时,都是为这 954 条(以及其它集群里同类的少量写入)付的账。

关卡 4 · 2 决策点你要一致,还是要可用

决策点 D1 · 23:13 UTC · 本关卡的核心岔路

两边数据已分叉。美西已经吃进将近 40 分钟的应用写入,美东还压着几秒钟未复制的写。你怎么办?

A 是 GitHub 实际的选择,而且他们说清楚了这是一笔明码标价的交易。官方原文:美西已写入 30 多分钟的数据,使他们「没有别的选择,只能向前修复以保住用户数据」[S1]。代价被写得同样明确:跑在美东、却要写美西 MySQL 的应用扛不住跨洲往返延迟,这个决定会让服务对很多用户不可用 [S1]。最后是那句表态——「我们认为,为了确保用户数据的一致性,长时间的服务降级是值得的。」[S1]

B 是压力最大时最诱人的选项,因为它能让站点立刻变快。但它的实质是:把 40 分钟里所有用户的 issue、评论、PR、推送记录悄悄抹掉。这类损失有一个恶劣特性——用户不会立刻发现,他们会在几天后困惑地问"我那条评论去哪了",而那时你已经没有证据链了。可用性的损失是当场可见、可解释、可道歉的;一致性的损失是延迟爆炸、不可解释、不可挽回的。这就是 D3 那句表态背后的算术。

C 的方向是对的,作为唯一方案则不可行。GitHub 确实做了这件事——他们捕获了 MySQL binlog 里那些未复制的写入,逐条分析哪些可以自动对账、哪些需要联系用户 [S1]。但这是并行进行的后续工作,不是恢复路径:站点不可能停在那里等人工对账完成。C 的正确定位是 A 的补充,而不是替代。

D 是脑裂里最危险的想法,因为它看起来最"务实"。让两边继续各自写入,等于让分叉持续扩大——每多一分钟,需要对账的数据就多一分钟,而两份数据的合并难度随分叉时长非线性上升。第 15 章讲过的那条铁律在这里生效:发现脑裂后的第一动作是让其中一边停止写入,而不是让两边都活着。

决策点 D2 · 13:15 UTC · 第二天下午

主库已切回美东,但数十个只读副本落后数小时,且复制延迟不降反升。此时正接近流量高峰。

B 是 GitHub 做的,而且它的机理值得单独记住。他们在美东公有云增开只读副本;这些副本可用后,读请求得以摊到更多机器上,降低了副本整体的利用率,复制才追得上来 [S1]。为什么有效?因为副本要同时干两件事:服务读请求 和 重放主库日志。当读负载吃满它的资源时,重放就没有余量——这正是第 3 章讲的「接收位点追上了、应用位点追不上」,只不过瓶颈不在单线程重放,而在利用率。第 2 章的 ρ 在这里第二次索命。

A 忽略了一个已经被观测到的事实:延迟在上升。官方记录明确写着当时「复制延迟在增加而不是朝一致状态收敛」[S1]。一个正在发散的量不会因为等待而收敛。这也是本书最反直觉的数字课之一:GitHub 原本按线性外推给出了「两小时」的恢复预期,但实际的追赶曲线遵循的是幂衰减而非线性,加上欧美用户陆续开始工作带来写负载上升,恢复比原估计更久 [S1]。

C 会把局部问题变成全局崩溃。把落后副本摘掉,等于把它们的读流量压到剩下那几台已经追上的副本上——那几台的利用率随即飙升,重放开始落后,然后它们也要被摘掉。这是第 7 章级联故障(病历卡 No.09)的标准剧本,只不过发生在副本层。正确方向和 B 相反:不是减少承载者,是增加。

D 有实际价值,GitHub 也一直在做(webhook 与 Pages 构建从 23:19 起就停了 [S1])。它之所以只是次优,是因为它处理的是写侧和异步任务的负载,而此刻的瓶颈在读副本的利用率。降级能争取时间,但不增加重放余量。最好的组合是 B + D:一边加承载,一边减负载。

决策点 D3 · 16:24 UTC · 最后一次选择

副本已同步,拓扑已切回原状,延迟与可用性问题都解决了。要不要把状态改回绿色?

B。GitHub 明确写道:作为优先保数据完整性而非缩短事故窗口的自觉决定,他们在开始处理积压时仍然保持红色状态 [S1],直到 23:03 UTC 全部积压处理完毕、系统完整性与运行状态得到确认,才转绿 [S1]。这个选择的深层理由在后面一句:他们不想进一步侵蚀状态播报本身的可信度 [S1]。状态页是一种承诺,提前转绿是透支它。

A 犯的是一个定义错误:把"页面能打开"当成了"服务正常"。此时排队的还有超过 500 万个 hook 事件和 8 万次 Pages 构建 [S1]。对依赖 webhook 的用户来说,服务此刻和挂着没有区别——他们的 CI 没被触发,部署没有发生。可用性要按用户完成任务的能力定义,不按你的仪表盘定义。

C 在直觉上最合理,可惜当时办不到。官方复盘专门检讨了这一点:他们当时只有 green / yellow / red 三档,无法准确表达"哪些部分能用、哪些不能用",因此事后加速了状态播报机制的改造 [S1]。这条对你的意义很直接:如果你的状态页只有三档,你迟早会被迫在"撒谎"和"过度悲观"之间二选一。

D 是最微妙的错误:它把责任从系统转移给了用户的阅读能力。绿色是一个会被机器和人快速消费的信号,公告是需要人主动去读的文本。当两者矛盾时,人们相信颜色。不要用注释去纠正一个错误的状态值。

顺带一提,积压本身也咬了一口

16:45 UTC 重新处理积压时,有约 20 万个 webhook 载荷因为超过内部 TTL 被丢弃;团队发现后暂停处理并上调了 TTL [S1]。

第 9 章算过的那条式子在这里有了新含义:积压不只是「要多久才能追平」,还有一个隐藏条款——队列里的东西会过期。追平之前就过期的消息,追平也救不回来。

关卡 4 · 3 教训三条

壹
自动 failover 的能力边界,是它看不见应用层。Orchestrator 完全按其配置行事 [S1]——它算出了一个合法的多数派,选出了一个合法的新主库。它不知道的是:美东的应用连不动美西的库。GitHub 的修复不是换工具,而是限制它的权力:禁止跨区域提升主库;区域内选主一般是安全的,突然引入的跨洲延迟才是主要成因 [S1]。给自动化设定边界,和给它设定目标同样重要。
貳
恢复时长远大于故障时长,而且它由平时的架构决定。43 秒的网络中断,24 小时 11 分的降级。慢在哪?备份每 4 小时一次、保留多年,但存在异地公有云对象存储上,恢复数 TB 数据要花数小时,大量时间耗在传输、解压、校验、加载上 [S1]。最刺眼的一句是:这套流程至少每天演练一次、恢复时长很清楚——但在这次之前,他们从来没有真的需要从备份完整重建整个集群 [S1]。演练过"恢复",不等于演练过"最坏的那种恢复"。
叁
把"选哪一边"提前想清楚,事故中就不必现场做哲学题。CAP 在机房级别不是理论,是 23:13 那一刻必须做的决定。GitHub 之所以能在十几分钟内定下方向,是因为他们有一条早就确立的排序:数据完整性 > 站点可用性 > 恢复速度 [S1]。这条排序应该写在预案里、在演练中被检验,而不是在凌晨由当值的人临时发明。预案的真正价值不是告诉你怎么操作,是告诉你当两个都重要时先放弃哪个。

关卡 4 · 4 带回「秒抢」通往最后一章的那条线

此刻的「秒抢」:约 7000 万用户、295 人,第 15 章刚经历过一次演练变真故障的脑裂。GitHub 这一关几乎逐条对应你刚学的东西——但它最重要的贡献是指出了下一步该往哪走。

GitHub 的失效点「秒抢」的对应物动作关联
自动 failover 跨区提主 数据库高可用组件、注册中心、配置中心的自动选主 明确写死选主的地理边界;并回答一个问题:跨机房提主之后,应用还连得动吗 第 15 章
954 条分叉写入 订单、库存扣减、支付回调 确保关键写入可逐条追溯(binlog / 事件日志留存),数据分叉后能对账而不是靠猜 第 10 · 11 章
副本利用率吃满导致追不上 只读副本、缓存回填、消费者组 给副本留重放余量:读利用率红线设在 70%,而不是等它追不上再加机器 第 2 · 3 章
积压消息过期被丢 MQ 里的订单通知、出票任务 盘一遍所有队列的 TTL:TTL 必须大于你最坏情况下的追平时间,否则积压追平之日就是数据丢失之时 第 9 章
状态只有三档 「秒抢」的服务状态页 按功能模块分别播报(购票 / 支付 / 出票 / 查询),而不是整站一个颜色 第 14 章

最后一句,请把它读完

GitHub 复盘的结尾,写的不是技术改进,是一段组织层面的结论。他们说这次事故改变了他们对站点可靠性的思维方式:在这样复杂的服务体系里,更严的运维管控或更快的响应时间,都不足以保障可靠性 [S1]。

所以他们决定开始一件事——系统性地在故障有机会影响用户之前,主动验证失效场景;并为此投入故障注入与混沌工程的工具建设 [S1]。

四个关卡到这里结束了。它们分别教了你:备份要演练(关卡一)、爆炸半径要设计(关卡二)、部署一致性要机器验证(关卡三)、以及现在这一条——当系统复杂到一定程度,"小心"和"快"都不再是答案。

剩下的办法,是在它打你之前先打它。

这正是第 16 章。

📄 原始来源(本页全部事实性陈述的出处)

  1. [S1] GitHub 官方复盘 October 21 post-incident analysis(Jason Warner,2018-10-30 发布,2021-12-19 更新)—— https://github.blog/news-insights/company-news/oct21-post-incident-analysis/

逐句核对清单见构建仓库 sources/FACTS-level4.md;来源原文留档见 sources/raw/github-2018-post-incident.txt。第三方转述未被采信为事实依据。