系统挂了不可怕,可怕的是没人知道怎么把它救回来
凌晨两点,监控突然开始疯狂报警。
CPU 100%、接口 502、数据库连接池耗尽、消息堆积……值班同学第一反应往往是:先把服务重启了再说。
重启之后,指标真的恢复了。
于是大家松了一口气。
但问题来了:为什么它会挂?为什么重启能恢复?下次还会不会挂?如果数据库也挂了呢?如果只有一台机器异常呢?
这其实就是一个很容易被忽略的问题:
系统不仅要“能运行”,还得“出了问题自己能恢复,而且尽量别把故障扩散出去”。
这就是今天想聊的——可恢复性工程(Recoverability Engineering)。
一、真正成熟的系统,不是“永不故障”
我一直觉得,很多系统设计存在一个误区:
“我们要保证系统不能出故障。”
这句话听起来很正确,但现实里基本做不到。
机器会坏。
网络会抖。
数据库会慢。
磁盘会满。
第三方接口会超时。
甚至一行代码写错,都可能让整个服务直接趴下。
所以真正靠谱的目标应该换一下:
不是让系统永远不出故障,而是让系统出了故障之后,能够快速识别、快速隔离、自动恢复。
这三个词非常重要:
识别 → 隔离 → 恢复
如果一个系统只能报警:
数据库挂了
↓
监控报警
↓
运维收到短信
↓
登录服务器
↓
查看日志
↓
判断问题
↓
执行命令
↓
重启服务
↓
观察指标
这叫“有人会救”。
而如果能够做到:
异常
↓
自动检测
↓
判断故障类型
↓
隔离异常实例
↓
自动恢复
↓
验证恢复结果
↓
失败则升级人工
这才开始接近真正的可恢复系统。
二、自动重启,不等于自动恢复
很多公司都有这样的脚本:
if ! curl -f http://127.0.0.1:8080/health; then
systemctl restart my-service
fi
简单、粗暴,而且确实有用。
但我要泼一盆冷水:
这只是自动重启,不是真正意义上的自动恢复。
因为它只回答了一个问题:
服务挂了,要不要重启?
但真正的故障恢复至少应该回答五个问题:
- 发生了什么?
- 影响范围多大?
- 应该隔离谁?
- 应该恢复什么?
- 恢复之后真的正常了吗?
比如一个服务有 10 台机器:
Server01 正常
Server02 正常
Server03 正常
Server04 正常
Server05 CPU 100%
Server06 正常
Server07 正常
Server08 正常
Server09 正常
Server10 正常
这时候最蠢的操作是什么?
把整个服务重启。
因为真正有问题的是 Server05。
正确的方式应该是:
Server05 异常
↓
从负载均衡摘除
↓
停止接收新请求
↓
尝试恢复
↓
健康检查
↓
恢复成功
↓
重新加入集群
如果恢复失败:
Server05
↓
恢复失败
↓
保持隔离
↓
告警
↓
人工处理
这就是一个非常典型的故障隔离 + 自动恢复模式。
三、第一个设计模式:先隔离,再恢复
这是我特别推荐的一条原则:
不要一边让故障节点继续接流量,一边尝试修复它。
想象一下:
用户请求
↓
Load Balancer
↓ ↓ ↓
A B C
↑
已故障
如果 B 已经出现:
连接池耗尽
线程池耗尽
CPU 100%
内存异常
响应时间 20 秒
你还继续把请求发给 B,那就是:
一台机器生病,拉着所有用户一起陪葬。
所以第一步应该是:
def handle_instance_failure(instance):
remove_from_load_balancer(instance)
stop_new_requests(instance)
if recover(instance):
health_check(instance)
if is_healthy(instance):
add_to_load_balancer(instance)
else:
alert_operator(instance)
这里最关键的不是 recover()。
而是:
remove_from_load_balancer(instance)
隔离永远应该发生在恢复之前。
四、为什么“故障隔离”比“自动恢复”还重要?
因为生产环境里最危险的故障,往往不是单点故障。
而是:
一个小故障逐渐演变成全局故障。
比如:
一个实例响应变慢
↓
请求大量堆积
↓
线程池耗尽
↓
调用方超时
↓
调用方开始重试
↓
请求数量继续增加
↓
数据库连接数暴涨
↓
数据库变慢
↓
更多服务超时
↓
全链路雪崩
最开始可能只是:
一台服务器响应慢。
最后却变成:
整个系统不可用。
所以故障隔离的本质就是:
控制爆炸半径。
我很喜欢这个词。
不是要求系统完全没有爆炸,而是:
炸一台,别把十台都炸了。
五、第二个设计模式:熔断器
微服务里非常经典的设计就是 Circuit Breaker,也就是熔断器。
假设服务 A 调用服务 B:
A → B
正常情况下没问题。
但是 B 挂了:
A → B
×
如果 A 还不断请求:
A → B ×
A → B ×
A → B ×
A → B ×
A → B ×
...
很快 A 自己也会被拖死。
所以可以设计成:
正常
↓
失败率升高
↓
熔断
↓
停止调用
↓
等待恢复
↓
半开
↓
尝试少量请求
↓
成功 → 关闭熔断
失败 → 继续熔断
一个简单 Python 示例:
import time
class CircuitBreaker:
def __init__(self, threshold=5, recovery_time=30):
self.threshold = threshold
self.recovery_time = recovery_time
self.fail_count = 0
self.opened_at = None
def allow_request(self):
if self.opened_at is None:
return True
if time.time() - self.opened_at > self.recovery_time:
return True
return False
def record_success(self):
self.fail_count = 0
self.opened_at = None
def record_failure(self):
self.fail_count += 1
if self.fail_count >= self.threshold:
self.opened_at = time.time()
真正生产环境当然不会这么简单。
通常还会考虑:
- 时间窗口
- 错误率
- 慢请求比例
- 半开状态
- 并发限制
- fallback
- 最大恢复次数
但思想其实非常简单:
别让一个坏掉的依赖把自己一起拖死。
六、第三个设计模式:Bulkhead——舱壁隔离
这个设计模式特别值得运维人员理解。
Bulkhead 原本是船舶上的“舱壁”。
一艘船进水了,如果所有舱室连在一起:
进水
↓↓↓↓↓↓
整个船
船很快就沉了。
如果有多个独立舱室:
┌────┬────┬────┬────┐
│ A │ B │ C │ D │
│正常│进水│正常│正常│
└────┴────┴────┴────┘
B 舱进水:
只牺牲 B。
系统架构也是一样。
比如一个电商系统:
商品服务
订单服务
支付服务
推荐服务
消息服务
千万不要让所有服务:
共享一个连接池
共享一个线程池
共享一个资源池
否则推荐服务出了问题:
推荐服务疯狂请求
↓
占满线程
↓
订单服务拿不到线程
↓
支付服务也受影响
这就叫资源互相污染。
可以改成:
订单线程池:100
支付线程池:50
推荐线程池:30
消息线程池:30
推荐服务炸了:
推荐线程池:100%
订单线程池:正常
支付线程池:正常
这就是舱壁隔离。
资源隔离,本质上也是故障隔离。
七、第四个设计模式:重试一定要有“刹车”
运维里还有一个非常经典的坑:
自动重试。
程序员经常这样写:
for i in range(10):
try:
call_api()
break
except Exception:
pass
看起来挺稳。
但如果所有服务都这么干:
1000个请求
↓
第一次失败
↓
1000个重试
↓
继续失败
↓
1000个再次重试
↓
继续失败
原本 1000 个请求:
最后可能变成:
10000 个请求
这不是恢复。
这是:
拿重试把故障系统再捅几刀。
所以重试至少应该具备:
1. 次数限制
MAX_RETRIES = 3
2. 指数退避
delay = 2 ** retry_count
例如:
第1次:1秒
第2次:2秒
第3次:4秒
3. 随机抖动
避免所有客户端同时重试:
import random
delay = 2 ** retry_count + random.random()
4. 只重试“值得重试”的错误
例如:
网络超时 → 可以重试
连接断开 → 可以重试
HTTP 503 → 可以重试
参数错误 → 不要重试
权限错误 → 不要重试
业务校验失败 → 不要重试
一句话:
重试不是越多越好,重试必须有边界。
八、第五个设计模式:自动恢复必须“验证”
这个问题非常容易被忽略。
很多自动化脚本是这样的:
发现服务挂了
↓
systemctl restart
↓
脚本结束
然后监控:
服务进程:Running
于是认为:
恢复成功。
实际上:
进程活着 ≠ 服务正常
比如:
进程:Running
HTTP:500
数据库:连接失败
Redis:连接失败
MQ:连接失败
业务接口:超时
所以恢复之后一定要做恢复验证。
例如:
def verify_service():
checks = [
check_process(),
check_http(),
check_database(),
check_redis(),
check_business_api()
]
return all(checks)
甚至可以设计成分级健康检查:
L1:进程是否存在
L2:端口是否正常
L3:HTTP是否正常
L4:依赖是否正常
L5:核心业务是否正常
真正的恢复应该是:
重启
↓
L1
↓
L2
↓
L3
↓
L4
↓
L5
↓
重新接入流量
而不是:
重启
↓
Process Running
↓
宣布成功
九、自动恢复还有一个大坑:无限自愈
“自动恢复”听起来非常高级。
但如果设计不好,会出现一种很搞笑的情况:
服务启动
↓
5分钟后挂
↓
自动重启
↓
5分钟后挂
↓
自动重启
↓
5分钟后挂
↓
自动重启
然后:
监控:正常
监控:正常
监控:正常
运维人员甚至不知道系统已经处于“半死不活”状态。
所以自动恢复必须设置:
恢复预算。
例如:
MAX_RECOVERY_COUNT = 3
if recovery_count >= MAX_RECOVERY_COUNT:
disable_auto_recovery()
alert("自动恢复已达到上限")
更进一步,可以设置:
5分钟内重启 ≥ 3次
↓
认为系统持续异常
↓
停止自动重启
↓
隔离实例
↓
升级人工告警
这非常重要。
自动化不是让机器永远自己折腾,而是让机器知道什么时候应该把问题交给人。
十、真正成熟的恢复流程应该长这样
如果让我设计一套生产系统的自动恢复流程,我会更倾向于:
┌──────────────┐
│ 监控发现异常 │
└──────┬───────┘
↓
┌──────────────┐
│ 判断故障类型 │
└──────┬───────┘
↓
┌──────────────┐
│ 故障隔离 │
└──────┬───────┘
↓
┌──────────────┐
│ 自动恢复 │
└──────┬───────┘
↓
┌──────────────┐
│ 健康检查 │
└──────┬───────┘
成功 ↓ ↓ 失败
┌──────────┐ ┌──────────┐
│ 恢复流量 │ │ 再次恢复 │
└──────────┘ └────┬─────┘
↓
超过恢复阈值
↓
┌──────────┐
│ 人工介入 │
└──────────┘
注意这里有一个非常重要的思想:
自动恢复不是一个动作,而是一套闭环。
十一、再往前一步:让恢复动作本身也可以测试
这其实是我特别想强调的一点。
很多公司有:
故障恢复脚本
但是从来没真正测试过。
这就像消防系统:
灭火器挂墙上十年,第一次使用的时候才发现没压力。
所以可恢复性工程必须加入故障演练。
例如人为制造:
Kill Pod
↓
观察是否摘流
↓
观察是否自动拉起
↓
观察健康检查
↓
观察是否重新加入
或者:
断开 Redis
↓
观察应用
↓
是否熔断
↓
是否降级
↓
Redis 恢复
↓
是否自动恢复
甚至可以用 Python 写一个非常简单的故障注入脚本:
import subprocess
import time
def kill_service(service):
subprocess.run(
["systemctl", "stop", service],
check=False
)
time.sleep(10)
def check_service(service):
result = subprocess.run(
["systemctl", "is-active", service],
capture_output=True,
text=True
)
return result.stdout.strip() == "active"
def chaos_test(service):
print(f"开始故障演练:{service}")
kill_service(service)
if check_service(service):
print("自动恢复成功")
else:
print("自动恢复失败,需要人工介入")
chaos_test("my-service")
生产环境当然不能直接这么玩。
但这个思想非常重要:
恢复能力不是写在文档里的,是通过一次次故障演练证明出来的。
十二、运维真正应该关注的,不是“重启次数”
最后说一个我个人非常在意的观点。
很多运维指标喜欢统计:
CPU
内存
磁盘
QPS
TPS
错误率
这些当然重要。
但对于可恢复性工程,我更建议增加几个指标:
MTTR
平均恢复时间。
故障发生
↓
恢复成功
花了多久?
自动恢复率
自动恢复成功次数
----------------
总故障次数
这个数字越清楚,越知道自己的系统到底有多“自愈”。
故障隔离时间
故障发生
↓
发现
↓
摘流
这个时间有多长?
有时候真正致命的不是恢复慢,而是:
故障实例继续接了 10 分钟流量。
恢复失败率
如果:
100次自动恢复
20次失败
那就说明自动化机制本身还有问题。
十三、我越来越觉得:真正高级的运维,是“让人少救火”
以前我们理解运维:
系统挂了
↓
找运维
↓
运维登录
↓
敲命令
↓
恢复
后来开始自动化:
系统挂了
↓
脚本执行
↓
自动重启
再往后:
系统挂了
↓
自动识别
↓
自动隔离
↓
自动恢复
↓
自动验证
↓
自动重新接入
↓
失败才找人
这才是我理解的可恢复性工程。
它不是简单地多写几个 Shell 脚本。
也不是把所有事情都交给 AI。
更不是一句:
“我们做了高可用。”
真正的可恢复性,是把系统当成一个会受伤的生命体来设计:
它可以出问题,但问题要被限制;它可以失败,但失败要可恢复;它可以恢复,但恢复必须经过验证;自动化可以处理大多数情况,但必须知道什么时候该停下来叫人。
最后送给做运维、DevOps、SRE 的朋友一句话:
高可用解决的是“别轻易挂”,可恢复性解决的是“挂了也别怕”。
而真正成熟的系统,不是从来没有事故。
而是某一天凌晨两点,某个节点真的挂了——
系统自己把它隔离了,自己恢复了,自己验证了。
第二天早上你打开监控,发现昨晚发生过一次故障。
但用户甚至没有感觉到。
我觉得,这才是自动化运维真正值得追求的样子。