外呼突然全挂了,追查 24 分钟后我发现了 etcd 最阴的一颗雷

一个正常上班的下午,我负责的外呼服务突然全线瘫痪,602 个坐席打不了电话。追了 24 分钟才发现,原因不是什么代码 bug 或网络故障,而是 etcd 的 lease 续约没开自动压缩,4000 万条历史版本撑爆了 8GB 存储。更绝望的是,等我们发现时已经恢复不了了------扩容没用、压缩拒绝执行、备份也恢复失败。最后只能把用户切回老系统。这篇文章是我对整件事的复盘。


📌 本文要点

  • 一个 --auto-compaction-retention 参数没配,如何让 etcd 无声地走向死亡
  • etcd 存储写满后为什么扩容都救不回来(Raft 一致性的一个常见误解)
  • 为什么"做了备份"不等于"灾难时能恢复"
  • 我们对 lease 续约频率的反思------有些"最佳实践"其实需要重新审视

☎️ 一个坐席打不出电话的下午

2024 年 11 月 28 日下午两点五分,告警群突然炸了。

"外呼异常!灰度用户无法拨打!"

我快速看了一眼监控------外呼服务的所有拨打节点全部失联。没有断网,没有宕机,但 etcd 里可用的节点列表就是空的。

外呼服务的逻辑很简单:拨打节点启动时向 etcd 注册自己,然后每隔 1 秒续约一次(lease TTL=5 秒)。外呼服务收到拨打请求时,从 etcd 拉取可用节点列表,选一个来执行拨打。

节点列表为空,意味着所有拨打请求都会失败。602 个灰度用户在那一刻打不出任何电话。

当时的第一反应是:etcd 挂了?先排查看看。


🔍 第一眼:etcd 不可写

查 etcd 集群状态------unhealthy。再细看,etcd 的 8GB 存储满了

不是被什么大文件撑满的,而是被 4000 多万条 revision 信息填满的。

这里需要先解释一个 etcd 的特性。


💡 先搞清楚:etcd 的 revision 是什么

etcd 每次数据变化,都会生成一个全局递增的版本号叫 revision。这个版本号不是"覆盖旧值",而是追加一个新的历史版本------类似 Git 每次 commit 都会生成一个新 hash,而不是覆盖上一个 commit。

我们的拨打节点每 1 秒续约一次 lease,每次续约就是一次写入,每次写入就产生一条新 revision。

做个算术:

yaml 复制代码
1 个节点 × 1 次/秒 × 3600 秒 × 24 小时 = 每天 86400 条 revision
如果我们有 10 个节点同时续约 → 每天 86.4 万条
运行几个月 → 4000 万条 revision → 8GB 撑爆

etcd 不自动清理旧 revision,它们就像日志一样只增不减。而我们的集群没有开启自动压缩(auto-compaction)。

一条配置就能防住的事:

ini 复制代码
--auto-compaction-retention=1h

只保留最近 1 小时的 revision,其他定期清理。但我们没配。


🔗 连锁反应:存储满了 → 全部崩盘

存储满了不只是"写不进去"这么简单。对 etcd 这种分布式共识系统来说,存储满意味着:

css 复制代码
正常:每秒续约 → 写入 → Raft 确认 → 三个节点数据一致

空间打满时:
  → 部分写入成功、部分失败(网络边界状态)
  → 各节点看到的 revision 开始不一致
  → 有的节点有 key A,有的没有
  → Raft 多数派被破坏
  → 选不出 leader → 集群 unhealthy

更重要的是,etcd 不可写之后,所有节点的 lease 无法续约。5 秒 TTL 一到,etcd 自动把它们全部删除。可用节点列表清空。所有外呼请求打到空列表上,全部失败。

从"存储快满了"到"全线瘫痪",中间没有任何人能感知到------没有告警,因为没有监控磁盘使用率。


🔧 艰难(且失败)的恢复尝试

14:12 我们开始尝试恢复。每一步都让人更绝望:

第一步:重启 etcd 节点。 不行。重启不会清理历史 revision,空间依然满,集群依然 unhealthy。

第二步:执行数据压缩。 等于是说"etcd,请把旧的 revision 删掉"。但 etcd 回复:"抱歉,我现在 unhealthy,没法执行管理操作。"------已经 unhealthy 了才想压缩,晚了。

第三步:扩容磁盘,8G → 16G。 空间够了吧?重启节点。节点进程起来了,显示"available"......过一会儿又变成"unavailable"。

这里有个很多人的误解。我们以为扩容 = 加空间 = 恢复。但对 etcd 来说,空间够了不等于数据对得上

三个节点的 revision 在存储打满的那段时间里已经变得不一致了。扩容给了更大的存储空间,但三本账本的数据还是对不上。Raft 拒绝接受:你不能拿一本跟别人不一样的账本当 leader。所以节点虽然能启动,但在 Raft 层面它无法加入集群,永远 unavailable。

第四步:用备份数据单节点启动。 我们做过备份,定期跑 etcdctl snapshot save 导出的文件。拿出来恢复------也失败了。

"做了备份"和"备份能恢复"是两回事。备份文件可能已经损坏了,也可能太旧了恢复出来节点列表是空的。关键在于------我们从来没验证过备份能不能恢复。它在磁盘上躺着,大小正常,看起来一切良好,但到用的时候才发现不行。

第五步(无奈之举):切回老系统。 14:29,我们放弃了恢复,把所有灰度部门切回科天(旧的外呼系统)。坐席重新扫码,恢复正常。恢复耗时 24 分钟。


💭 事后复盘:我学到的东西

1. 一行配置没配,4000 万 revision 就来了

回头看,整件事最憋屈的地方在于:一条配置就能防住。

ini 复制代码
--auto-compaction-retention=1h

就这么一行。而且 etcd 的官方文档里写得清清楚楚。但它不像 MySQL 的 auto_increment 那样人尽皆知,revision 在 etcd 里是个隐形的磁盘消耗者------你看不见它在涨,它也不报错,直到某一天空间满了。

我现在看任何一个新接入的系统,第一件事就是问:哪些数据是"只增不减"的?它们的清理策略是什么?

2. 存储满了的 etcd,比挂了更可怕

分布式系统和单机系统最大的区别之一是:单机系统挂了就是挂了,你知道该修;分布式系统可能会进入一种"半死不活"的状态,让人不知道该怎么下手。

arduino 复制代码
MySQL 磁盘满:
  → 抛错 "disk full"
  → 清理空间 → 重启 → 恢复 ✅

etcd 磁盘满:
  → 部分写入成功、部分失败
  → 各节点数据不一致
  → Raft 多数派破坏
  → 集群 unhealthy
  → 此时想压缩 → 拒绝执行
  → 扩容 → 数据还对不上
  → 备份 → 没验证过恢复不了
  → ❌ 等你发现的时候,自救已经晚了

别等 unhealthy 了再救。磁盘使用率设一个 75% 告警,到了就人工介入压缩。

3. 验证备份能不能恢复,比做备份更重要

"我们每天凌晨跑 snapshot save,备份文件都在。"

这话我以前也信。但这次事故之后,我的感受是完全不一样的:

bash 复制代码
备份流程:
  crontab 每天执行 snapshot save
  → 文件写入 /backup/ 目录
  → 磁盘空间监控显示文件存在、大小正常
  → 所有人都觉得"我们有备份了"

实际上没验证过:
  这个文件真的能用 etcdctl restore 恢复吗?
  恢复出来的数据是不是完整的?
  恢复后服务能不能正常启动?

我们现在的做法是:每个月选一台测试机器,跑一遍完整的 restore + 启动 + 数据校验流程。 如果 restore 脚本在这次演练中挂掉了,那不是"浪费时间",而是避了一次灾。

4. 每秒续约一次,真的有必要吗?

这次事故的触发源头之一是 lease 续约频率------1 秒一次。我后来想了很久:这个频率是谁定的?

最初可能觉得"越快越好,节点挂了能尽快感知"。但后果呢?每次续约都是 etcd 的一次写入,每条写入都是 revision 的一次增长。一天 86.4 万条 revision,几个月几千万条。

如果改成 10 秒续约一次、30 秒 TTL 呢?

方案 写入频率 etcd 压力 发现节点挂的时间
1s 续约 / 5s TTL 1 次/秒/节点 最多 5 秒
10s 续约 / 30s TTL 0.1 次/秒/节点 低 90% 最多 30 秒

对于一个外呼系统来说,30 秒知道一个节点挂了和 5 秒知道,真的有本质区别吗?但 90% 的 etcd 写入压力差,是实打实的。

高频不一定是好事,觉得"越快越好"时,算算它对下游的影响。


🔚 写在最后

事故过去之后,我一直觉得这件事挺讽刺的。

一个运行了几个月都没问题的系统,因为一行配置没配,在某个下午突然崩了。崩了之后还救不回来,因为 etcd 的设计让它陷入了一致性死锁。连备份都不管用,因为从没人试过。

一行配置、一个磁盘告警、一次恢复演练------任何一个环节做了,这次事故都不会发生。但三个环节都没做,它就精确地踩中了。

从那以后,我每次搭建 etcd 集群,都会先把这四件事做了:

  • --auto-compaction-retention=1h 配好
  • 磁盘使用率 > 75% 告警
  • 给 lease 续约频率设一个合理的值,而不是"越快越好"
  • 写一行验证备份可恢复的脚本,放到 CI 里定期跑

都是小事。但有时候,崩掉一个大系统的,就是这些小事。

相关推荐
何以解忧,唯有..1 小时前
Go语言变量的声明方式详解
开发语言·后端·golang
长栎1 小时前
MyBatis 缓存为啥总是失效?装饰器模式套娃的代价
后端
bright_ye1 小时前
setjmp & longjmp 深度详解 + 代码示例
后端
To_OC1 小时前
我一直以为 Ajax 是个黑盒,直到我写了这 50 行代码
前端·后端·全栈
她的男孩1 小时前
AI 自动化编写 SQL 脚本,更要守住 Flyway 版本管理的防线
人工智能·后端
卷无止境1 小时前
Python的ABC库探索:能不能在系统设计之初就定义好所有抽象类?
后端
卷无止境1 小时前
Python collections 库深度解析:那些被低估的数据结构利器
后端
XovH1 小时前
Redis 从入门到精通:分布式锁 —— 从 SETNX 到 Redlock
后端
用户329901675051 小时前
用 Web Speech API 给 AI 回答加"朗读"功能,边读边高亮 🔊
后端