存储系统的设计文档里充满了”本方案能容忍 X 种故障”的承诺。但生产环境里的故障很少按剧本走——它们总是在最不可能的角度、以最想不到的组合方式撞过来。研究事故复盘(Postmortem)的意义不在于幸灾乐祸,而在于理解真实故障的形态:它们如何绕过多层防御、如何在被检测到之前静默传播、以及为什么”我们以为做好了备份”这句话在事后复盘时听起来如此苍白。
本文选取两个有公开详细纪录的存储事故——GitLab 2017 年数据库事故和 GitHub 2018 年 10 月存储故障——做完整的根因分析,然后提取跨事故的通用模式,最后讨论如何把这些教训融入日常工程实践。
信息来源 本文分析基于事故方发布的官方 postmortem 和 incident report。具体细节以原始报告为准——本文的价值在于系统的根因分析和模式提取,而非独家事实发现。
一、GitLab 2017 年数据库事故
1.1 事件概览
2017 年 1 月 31 日,GitLab.com 经历了一次持续约 18 小时的服务中断和近 24 小时的数据恢复过程。最终结果:约 6 小时的用户数据(issues、merge requests、comments、snippets、用户设置)永久丢失。整个恢复过程在 YouTube 上直播,成为运维社区被研究最多的事故之一。
GitLab 在事后发布了长达 4000 字的官方 postmortem,并附带了详细的时间线。
1.2 时间线重建
2017-01-31(UTC):
18:00 Spam 攻击导致数据库写入压力上升,团队开始排查。
21:00 写入锁竞争加剧,引发短时不可用。
22:00 db2.cluster 复制滞后约 4 GB 后停止;团队在 db2 上清空
/var/opt/gitlab/postgresql/data/ 以重建复制,并调整 db1 的
max_wal_senders、max_connections 等参数。
23:00 工程师本想清理 db2 数据目录以排查 pg_basebackup 卡住,
却在 db1.cluster.gitlab.com(生产主库)上执行了:
rm -rf /var/opt/gitlab/postgresql/data/
23:27 删除被中止,但约 300 GB 数据中仅剩约 4.5 GB。
GitLab.com 下线,开始翻查全部备份与副本。
2017-02-01:
00:36 从 db1.staging.gitlab.com 备份 staging 数据库。
00:55 将 staging 的 /var/opt/gitlab/postgresql/data/ 复制到生产 db1。
最终以约 6 小时前的快照恢复上线;官方认定丢失窗口为
17:20–23:25 UTC 之间的数据库变更(issues、MR、评论等)。
flowchart LR
A["Spam 写入风暴"] --> B["复制滞后 / db2 重建"]
B --> C["误删 db1 数据目录"]
C --> D["五层备份均不可用"]
D --> E["从 staging 副本恢复"]
1.3 五层备份同时失效
GitLab 运维团队认为他们已经配置了五层独立的备份保护。但在真正需要恢复的时候,五层全部失效:
第 1 层:pg_dump 定时备份
状态:近几个月来一直失败
原因:pg_dump 的 cron job 输出未被监控
失效模式:静默——备份脚本返回了非零退出码,但无人知晓
第 2 层:S3 上的数据库备份同步
状态:已同步,但恢复时发现不完整
原因:同步脚本只同步了新文件,但数据库备份的文件命名模式在
某个时间点发生了变化——旧的备份文件未保留
失效模式:备份"存在"但不完整
第 3 层:磁盘快照(LVM snapshot)
状态:配置文档中声称有,但实际未启用
原因:部署过程中跳过了这个步骤,文档未更新
失效模式:备份机制不存在
第 4 层:Azure NFS 上的备份副本
状态:存在但未验证可用性
原因:从未做过恢复演练,恢复时发现备份格式已过期
失效模式:备份存在但不可用
第 5 层:PostgreSQL WAL 归档
状态:WAL 文件存在,但 pg_basebackup 的基础备份出问题
原因:WAL 回放需要完整的基础备份+连续的 WAL 文件链。
基础备份损坏导致整个 WAL 链不可用
失效模式:备份链断裂
最终的数据恢复,不是来自这五层中任何一层——而是来自 db1.staging.gitlab.com 上的 staging 数据库副本(通过复制同步,滞后生产约数小时)。GitLab 工程团队将 staging 的数据目录复制到生产 db1,重建了生产数据库。
1.4 根因分析
这次事故不是单点故障,而是一个复杂的故障链:
直接原因:人为操作失误——在错误的服务器上执行了
rm -rf。
根本原因:
备份恢复从未演练过。 五层备份没有一层被定期验证。备份的存在不等于备份的可用——“没有定期做过恢复演练的备份,全部视为无效备份”这条运维铁律被忽视了。
备份的监控是盲区。
pg_dump失败了几个月,无人知晓。运维团队监控了磁盘使用、CPU、内存、QPS——但没有监控”备份是否成功”。故障切换没有回滚计划。 在主从同步延迟的情况下触发强制故障切换,这个操作的预期结果没有被事先推演。当切换效果不好时,团队处于”边想边做”的应急模式。
权限控制缺失。 一个工程师能以 sudo 权限删除数据库主目录,且目标服务器没有明显的视觉或命令提示区分。生产主节点和从节点的终端提示符没有显著差异——这不是纯技术问题,而是运维环境的人机工程问题。
运维操作没有”双人复核”机制。 在执行
rm -rf这种不可逆操作之前,没有强制其他人确认目标是否正确。
1.5 事故后的改进
GitLab 在 postmortem 中列出了一长串改进措施,其中与存储直接相关的包括:
- 所有数据库备份改为每日通过
pg_basebackup执行,备份成功/失败状态纳入告警监控。 - 所有备份定期执行恢复演练(至少每季度一次)。
- 数据库操作命令改为使用预定义的自动化脚本,减少手工
rm -rf场景。 - 生产数据库服务器的终端提示符增加明确的颜色和主机名区分。
- PostgreSQL 的
pg_basebackup和 WAL 归档配置被审查和加固。
二、GitHub 2018 年 10 月存储故障
2.1 事件概览
2018 年 10 月 21 日晚间至 22 日凌晨(UTC),GitHub.com 经历了一次持续约 24 小时的服务降级,期间主要的 Git 操作(push/pull)和 Web 服务间歇性不可用。事故涉及 MySQL 主从拓扑在跨数据中心网络分区下的故障,以及由此引发的数据不一致。
GitHub 在 2018 年 10 月 30 日发布了官方 incident report。
2.2 时间线重建
2018-10-21 22:52 UTC:
22:52 计划中的网络维护将 US East Coast 数据中心和其网络枢纽
之间的连接中断。维护预期仅持续 43 秒,但实际上网络分区
持续了约 40 秒的网络中断 + 额外的不稳定期。
22:52 Orchestrator(GitHub 的 MySQL 拓扑管理工具)检测到
MySQL 主节点不可达,触发自动故障切换。
但在网络分区的另一侧,原主节点仍然在运行并接受写入。
← 脑裂(Split-Brain)发生。
22:54 网络恢复。但此时 MySQL 拓扑已经处于不一致状态:
- 分区 A 侧:新主节点(通过 Orchestrator 提升)接受写入
- 分区 B 侧:旧主节点仍然认为自己是最新的,仍在接受写入
两份数据开始分歧。
22:54 代理层(ProxySQL)开始向两个"主节点"同时路由写入。
数据漂移(Data Drift)开始。
后续的恢复过程涉及: - 将 MySQL 集群切换到只读模式,停止所有写入。 - 从备份恢复完整的数据集。 - 回放 MySQL 二进制日志(binlog)来补回分区后的写入——这需要精细地识别哪些写入是合法的,哪些是脑裂导致的重复或冲突数据。 - 手工修复由于数据漂移导致的不一致记录。
恢复时间超过 24 小时,主要瓶颈是 binlog 的分析和选择性回放——这不是一个简单的”恢复备份”操作,而是需要手工诊断和修复每一条异常数据的法证过程。
2.3 根因分析
直接原因:计划网络维护导致了意外的网络分区,触发了 MySQL 拓扑的脑裂。
根本原因:
Orchestrator 的故障检测只依赖网络可达性。 当 Orchestrator 无法 ping 通主节点时,它直接假设主节点已死——但没有机制验证主节点是否真的不可用(如尝试从另一个网络路径连接,或检查后端存储状态)。
网络分区期间的写入冲突检测缺失。 当脑裂发生时,没有机制(如 epoch、fencing token)来识别”已被取代的旧主节点”并阻止它继续接受写入。
proxy 层没有及时发现拓扑不一致。 ProxySQL 在将流量路由到两个主节点的过程中,没有意识到”应该只有一个主节点”。
运维操作窗口的爆炸半径评估不足。 计划中的网络维护(“仅 43 秒的瞬断”)被评估为低风险。但评估没有考虑”瞬断触发自动故障切换后,需要多级人工介入来恢复”的级联效应。43 秒的原始操作,最终引发了 24 小时的恢复过程。
2.4 与 GitLab 事故的对比
对比维度 GitLab 2017 GitHub 2018
────────────────────────────────────────────────────────
触发事件 人工 rm -rf 网络维护触发分区
故障类型 数据丢失 数据漂移(脑裂)
备份是否可用 五层全部失效 备份可用(恢复解决了根本问题)
恢复瓶颈 没有可用的备份 binlog 分析和选择性回放耗时
持续时间 18 小时服务中断 24 小时服务降级
事后透明度 实时直播恢复过程 详细 incident report
核心教训 备份不可信不可用 脑裂检测和防护不足
两个事故的共同点:事故的爆炸半径远超初始操作的预期。
GitLab 的 rm -rf
预期影响范围是”清理一台从节点的旧数据”,实际结果是”删除了唯一最新数据副本”。GitHub
的 43
秒网络瞬断预期影响范围是”短暂延迟”,实际结果是”数据漂移 + 24
小时恢复”。
三、跨事故的通用模式
两个事故在表面上完全不同(一个误操作,一个网络分区),但抽离出以下通用模式。
3.1 备份不可信模式
GitLab 事故的核心教训——“五层备份全部失效”——不是个案。这是运维世界的普遍模式:团队相信备份存在,但没有人验证备份可用。
为什么这个模式反复出现:
- 备份的验证成本高(需要一台独立的恢复环境、需要实际的恢复时间窗口)。
- 备份的失败是静默的(没有业务指标会因为备份失败而恶化——直到你需要用它)。
- “上次验证过”给了虚假的安全感(半年前的验证不能保证今天的备份可用——配置可能被改了,磁盘可能坏了,脚本的输出格式可能变了)。
工程对策:
- 备份的验证必须自动化。不能依赖”定期手工演练”——人类的定期演练最终会变成”从未演练”。
- 备份验证的结果必须纳入告警。备份失败应该和磁盘满了一样触发 PagerDuty。
- 定期恢复测试的周期应该短于备份配置变更的周期。如果每周都在改数据库配置,每月一次的恢复演练远远不够。
3.2 爆炸半径低估模式
两个事故都涉及对操作影响的严重低估:
- GitLab:在错误的服务器上执行了命令。团队没有意识到”在这台机器上的任何操作都能影响唯一的数据副本”——爆炸半径是整个生产数据库。
- GitHub:43 秒的网络瞬断。“这只是短暂的网络中断”的常规假设与实际结果(数据库脑裂 + 24 小时恢复)之间的差距,反映出对系统在边界条件下行为的认知不足。
工程对策:
- 任何生产环境操作(哪怕是”43 秒的网络闪断”)都要评估最坏情况,而不是预期情况。
- 建立”爆炸半径”的概念模型,明确标注每台机器上运行的数据的”唯一性”——这台机器上有没有唯一的数据副本?如果有,其爆炸半径是所有依赖该数据的所有服务。
- 对于持有唯一数据副本的机器,手动
rm -rf永远不应该出现在操作流程中。所有数据操作应该通过脚本执行,脚本在删除数据前应做二次确认(如读取集群状态确认当前节点角色)。
3.3 故障检测在错误抽象层的模式
GitHub 事故中,Orchestrator 在”ping 不通就是死了”的抽象层做故障检测,而没有在”后端存储的状态是什么”的层面做验证。
这是一种普遍的反模式:故障检测机制工作在比它所保护的系统更高的抽象层。 当你用 ICMP ping 来检测 MySQL 的健康状态时,你能检测到网络分区(ping 不通),但无法区分”网络分区”和”服务器真的死了”。你做出的故障切换决策可能恰恰让事情变得更糟——把一个仍在健康运行的节点踢出了集群。
工程对策:故障检测的抽象层应该与所保护的系统在同一层或更低。检测
MySQL 的健康状态,应该在 MySQL
协议层面做(能建立连接、能执行查询),而不是在 IP
层做。检测文件系统是否正常,应该用 fsync
一个测试文件的方式,而不是检查挂载点是否存在。
四、构建抗事故的存储实践
与容量类故障的关联:小文件增殖导致的 inode 耗尽、Btrfs
元数据 ENOSPC 等,在监控上常表现为「df -h
尚有空闲但写入失败」——参见本站 小文件问题
与 磁盘空间耗尽。备份链断裂与容量告警盲区叠加时,爆炸半径会迅速扩大。
从这些事故中提取的具体工程建议。
4.1 备份的三层验证
传统的”备份存在就 OK”已经被这些事故证明是不够的。一个更严格的验证框架:
第 1 层:完整性验证(自动,每次备份后执行)
- 备份文件的校验和是否与源一致
- 备份文件的大小是否在合理范围(不低于上次的 50%,不高于上次的 200%)
- 备份时间戳是否在预期窗口内
第 2 层:可恢复性验证(自动,每周执行)
- 在隔离环境中恢复最新备份
- 验证数据集的关键约束(行数、校验和、关键表是否非空)
- 结果自动告警
第 3 层:业务可用性验证(手动,每季度执行)
- 在 staging 环境使用恢复的数据启动完整应用栈
- 运行冒烟测试套件
- 确认所有关键业务流程可执行
4.2 故障切换的安全设计
从 GitHub 的脑裂事故中提取的故障切换设计原则:
Fencing(隔离)优先于 Promotion(提升)。 在提升一个从节点为新主节点之前,必须确认旧主节点已经被有效隔离(无法接受写入)。手段包括:STONITH(Shoot The Other Node In The Head)、修改网络策略阻止旧主节点通信、撤销旧主节点的共享存储访问权限。
多数派决策。 单点检测器(如单个 Orchestrator 实例)不应该独自做出故障切换决策。至少需要多数派检测器同意——正如 Raft/Paxos 协议要求的。
Epoch 递增。 每次故障切换增加一个全局 epoch 编号。所有写操作附带当前 epoch。旧 epoch 的写入被识别为无效——即使脑裂发生,旧主节点的写入也被自动丢弃。
4.3 运维人机工程
GitLab 的 rm -rf
事故暴露的不只是操作流程问题,也是终端环境的人机工程问题。几个改进了的实践:
- 生产终端的视觉区分:生产服务器的终端背景色/提示符颜色与开发/测试环境明确区分。不是一个”小技巧”,而是防止操作失误的最后一道防线。
- 危险命令的二次确认:
rm -rf、DROP DATABASE、ALTER TABLE ... DROP等不可逆操作,在生产环境中应该有一个强制确认步骤(如输入服务器 hostname 作为确认)。 - 操作记录与审计:所有对生产服务器的
sudo操作应该被记录到独立的审计日志中。script命令或tmux的 pipe-pane 可以做终端会话的完整录制。
4.4 事故演练
定期的事故演练(Game Day / Fire Drill)和混沌工程(参考本站存储混沌工程)是验证备份可用性和故障切换逻辑的最有效手段,但它不能只停留在”磁盘挂了”这种简单故障上。
一些容易被忽略的演练场景:
- “备份恢复实际上需要多少时间”:不只是备份的完整性,而是端到端恢复时间——包括通知决策者、准备恢复环境、传输备份文件、回放日志、验证数据、切换流量。很多团队的 RTO(恢复时间目标)是基于”恢复数据本身需要 X 小时”做出的,但实际端到端恢复时间是 X 的 3-5 倍。
- “删除生产数据有多容易”:让团队成员演示”如果不小心删了数据库,数据还能不能找回来”。这个演练比抽象的安全意识培训有效得多。
- “如果唯一了解这台机器配置的人今天不在,你能恢复吗”:依赖个人知识的配置是不可恢复的配置。每个运维操作流程的关键步骤必须写在能被团队成员访问的文档里(而不是某个人的 shell history 里)。
五、行动清单
| 优先级 | 行动 | 对应事故模式 |
|---|---|---|
| P0 | 备份成功/失败纳入告警;每次备份后自动校验和与大小合理性检查 | 备份不可信 |
| P0 | 每周在隔离环境自动恢复最新备份并验证关键约束 | 备份不可信 |
| P0 | 故障切换前必须先 fencing 旧主;单点检测器不得独自 promote | 脑裂 / 爆炸半径 |
| P1 | 生产终端与主机名视觉区分;危险命令二次确认(输入 hostname) | 人为误操作 |
| P1 | 计划维护评估最坏情况 RTO,而非预期中断时长 | 爆炸半径低估 |
| P1 | 同时监控 df -h 与 df -i;Btrfs
单独监控元数据池 |
容量 / inode ENOSPC |
| P2 | 季度 Game Day:端到端恢复计时 +「误删数据能否找回」演练 | 全流程验证 |
| P2 | 故障检测在目标协议层(SQL ping、fsync 探针),而非仅 ICMP | 错误抽象层 |
底线:存储可靠性不取决于设计了几层保护,而取决于验证过几层保护真的能用。
参考资料
- GitLab. (2017-02-01). “Postmortem of database outage of January 31.” —— https://about.gitlab.com/blog/2017/02/01/gitlab-dot-com-database-incident/
- GitHub. (2018-10-30). “October 21 post-incident analysis.” —— https://github.blog/2018-10-30-oct21-post-incident-analysis/
- GitHub. (2018-10-22). “October 21 incident report.” —— https://blog.github.com/2018-10-22-oct21-incident-report/
- 本站 存储混沌工程 — 故障注入和混沌实验的方法论与工具。
- 本站 备份策略工程 — 备份方案的系统化讨论。
- 本站 灾难恢复设计 — 灾备架构的工程实践。
- Allspaw, J. (2012). “Blameless PostMortems and a Just Culture.” Etsy Engineering Blog. —— 无责事后复盘的方法论基础。
上一篇: POSIX 文件锁:flock、fcntl 与 NFS 锁的工程陷阱 下一篇: O_DIRECT 与 io_uring:固定缓冲区、register_buffers 与工程选型
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【存储工程】备份策略工程
系统剖析存储备份策略——全量/差异/增量备份、逻辑 vs 物理备份、WAL 连续归档、备份验证与恢复测试、MySQL 与 PostgreSQL 备份方案实战
【存储工程】Direct I/O 与 O_DIRECT:绕过缓存的得与失
在 Linux 的传统 I/O 路径中,应用程序通过 read() 和 write() 系统调用与文件交互时,数据并不会直接在用户空间缓冲区(User Buffer)和磁盘之间传输。内核会在两者之间插入一层页缓存(Page Cache),作为磁盘数据在内存中的缓存副本。一次典型的写入流程如下:
【存储工程】小文件问题:为什么文件数量比文件大小更致命
系统分析小文件在块分配、元数据管理、磁盘寻道和网络协议四个层面的放大效应,用数据量化 slack space、inode 开销和 syscall 成本,给出应用层聚合与对象存储归档两种工程方案。
【存储工程】磁盘空间耗尽:从 70% 到 ENOSPC 的行为退化链
逐层拆解 ext4、XFS、Btrfs、ZFS 从 70% 填充到 100% 耗尽过程中的块分配退化、碎片化加剧和 ENOSPC 故障模式,给出各文件系统的容量红线、监控阈值和应急恢复方法。