土法炼钢兴趣小组的算法知识备份

【存储工程】存储事故复盘:经典生产故障的根因与教训

文章导航

分类入口
storage
标签入口
#postmortem#incident-analysis#backup#data-recovery#reliability#gitlab#github

目录

存储系统的设计文档里充满了”本方案能容忍 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

根本原因

  1. 备份恢复从未演练过。 五层备份没有一层被定期验证。备份的存在不等于备份的可用——“没有定期做过恢复演练的备份,全部视为无效备份”这条运维铁律被忽视了。

  2. 备份的监控是盲区。 pg_dump 失败了几个月,无人知晓。运维团队监控了磁盘使用、CPU、内存、QPS——但没有监控”备份是否成功”。

  3. 故障切换没有回滚计划。 在主从同步延迟的情况下触发强制故障切换,这个操作的预期结果没有被事先推演。当切换效果不好时,团队处于”边想边做”的应急模式。

  4. 权限控制缺失。 一个工程师能以 sudo 权限删除数据库主目录,且目标服务器没有明显的视觉或命令提示区分。生产主节点和从节点的终端提示符没有显著差异——这不是纯技术问题,而是运维环境的人机工程问题。

  5. 运维操作没有”双人复核”机制。 在执行 rm -rf 这种不可逆操作之前,没有强制其他人确认目标是否正确。

1.5 事故后的改进

GitLab 在 postmortem 中列出了一长串改进措施,其中与存储直接相关的包括:


二、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 拓扑的脑裂。

根本原因

  1. Orchestrator 的故障检测只依赖网络可达性。 当 Orchestrator 无法 ping 通主节点时,它直接假设主节点已死——但没有机制验证主节点是否真的不可用(如尝试从另一个网络路径连接,或检查后端存储状态)。

  2. 网络分区期间的写入冲突检测缺失。 当脑裂发生时,没有机制(如 epoch、fencing token)来识别”已被取代的旧主节点”并阻止它继续接受写入。

  3. proxy 层没有及时发现拓扑不一致。 ProxySQL 在将流量路由到两个主节点的过程中,没有意识到”应该只有一个主节点”。

  4. 运维操作窗口的爆炸半径评估不足。 计划中的网络维护(“仅 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 事故的核心教训——“五层备份全部失效”——不是个案。这是运维世界的普遍模式:团队相信备份存在,但没有人验证备份可用。

为什么这个模式反复出现:

  1. 备份的验证成本高(需要一台独立的恢复环境、需要实际的恢复时间窗口)。
  2. 备份的失败是静默的(没有业务指标会因为备份失败而恶化——直到你需要用它)。
  3. “上次验证过”给了虚假的安全感(半年前的验证不能保证今天的备份可用——配置可能被改了,磁盘可能坏了,脚本的输出格式可能变了)。

工程对策:

3.2 爆炸半径低估模式

两个事故都涉及对操作影响的严重低估:

工程对策:

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 的脑裂事故中提取的故障切换设计原则:

  1. Fencing(隔离)优先于 Promotion(提升)。 在提升一个从节点为新主节点之前,必须确认旧主节点已经被有效隔离(无法接受写入)。手段包括:STONITH(Shoot The Other Node In The Head)、修改网络策略阻止旧主节点通信、撤销旧主节点的共享存储访问权限。

  2. 多数派决策。 单点检测器(如单个 Orchestrator 实例)不应该独自做出故障切换决策。至少需要多数派检测器同意——正如 Raft/Paxos 协议要求的。

  3. Epoch 递增。 每次故障切换增加一个全局 epoch 编号。所有写操作附带当前 epoch。旧 epoch 的写入被识别为无效——即使脑裂发生,旧主节点的写入也被自动丢弃。

4.3 运维人机工程

GitLab 的 rm -rf 事故暴露的不只是操作流程问题,也是终端环境的人机工程问题。几个改进了的实践:

4.4 事故演练

定期的事故演练(Game Day / Fire Drill)和混沌工程(参考本站存储混沌工程)是验证备份可用性和故障切换逻辑的最有效手段,但它不能只停留在”磁盘挂了”这种简单故障上。

一些容易被忽略的演练场景:


五、行动清单

优先级 行动 对应事故模式
P0 备份成功/失败纳入告警;每次备份后自动校验和与大小合理性检查 备份不可信
P0 每周在隔离环境自动恢复最新备份并验证关键约束 备份不可信
P0 故障切换前必须先 fencing 旧主;单点检测器不得独自 promote 脑裂 / 爆炸半径
P1 生产终端与主机名视觉区分;危险命令二次确认(输入 hostname) 人为误操作
P1 计划维护评估最坏情况 RTO,而非预期中断时长 爆炸半径低估
P1 同时监控 df -hdf -i;Btrfs 单独监控元数据池 容量 / inode ENOSPC
P2 季度 Game Day:端到端恢复计时 +「误删数据能否找回」演练 全流程验证
P2 故障检测在目标协议层(SQL ping、fsync 探针),而非仅 ICMP 错误抽象层

底线:存储可靠性不取决于设计了几层保护,而取决于验证过几层保护真的能用。


参考资料


上一篇: POSIX 文件锁:flock、fcntl 与 NFS 锁的工程陷阱 下一篇: O_DIRECT 与 io_uring:固定缓冲区、register_buffers 与工程选型

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-10-08 · storage

【存储工程】备份策略工程

系统剖析存储备份策略——全量/差异/增量备份、逻辑 vs 物理备份、WAL 连续归档、备份验证与恢复测试、MySQL 与 PostgreSQL 备份方案实战

2025-08-19 · storage

【存储工程】Direct I/O 与 O_DIRECT:绕过缓存的得与失

在 Linux 的传统 I/O 路径中,应用程序通过 read() 和 write() 系统调用与文件交互时,数据并不会直接在用户空间缓冲区(User Buffer)和磁盘之间传输。内核会在两者之间插入一层页缓存(Page Cache),作为磁盘数据在内存中的缓存副本。一次典型的写入流程如下:


By .