主题
高可用套路:冗余、故障转移与降级
本文是系统设计系统学习的 L2 核心篇。前置:31. high-performance-patterns。 学完可以配合面试题食用:19-multi-region-active-active-system、07-限流系统设计
为什么单个服务不够用:可用性的数学
一台服务器挂掉只是时间问题。硬件故障率是物理规律:一台普通服务器年故障率约 2-5%,10 台机器里有 1 台在一年内出问题的概率接近 20%。
可用性(Availability)用几个 9 衡量:
| 可用性 | 年停机时间 | 月停机时间 | 典型场景 |
|---|---|---|---|
| 99% (2 个 9) | 87.6 小时 | 7.3 小时 | 内部工具 |
| 99.9% (3 个 9) | 8.76 小时 | 43.8 分钟 | 一般 SaaS |
| 99.99% (4 个 9) | 52.56 分钟 | 4.38 分钟 | 电商/支付 |
| 99.999% (5 个 9) | 5.26 分钟 | 26.3 秒 | 电信/金融核心 |
从 3 个 9 到 4 个 9,不是优化 10 倍,而是把年故障时间从 8.7 小时压到 52 分钟。单靠"不出错"做不到,唯一正解是冗余——让一个组件挂了,另一个立刻顶上。
冗余的三种半径
机器冗余(N+1)
每台服务器挂掉的概率是 p,N 台同时挂的概率是 p^N。N+1 部署让单台故障不影响整体。典型做法:每层服务至少 2 节点,流量经过 LB 分发,一台下线自动摘除。
同城双活
主备机房间物理距离 10-50km,光纤互连延迟 < 1ms。流量同时写入两个机房,任意一个挂掉能全量切到另一个。代价是流量翻倍(双写)和机房间带宽成本。
异地多活
机房跨城市(上海-北京-深圳),距离 1000km 以上,延迟 10-30ms。核心挑战是数据一致性——跨城延迟强制你做最终一致。典型做法:每个地域有独立副本,本地写本地读,异步复制到其他地域,用业务层的"对账"兜底。
mermaid
graph TB
subgraph "Zone A (上海)"
LB1[Load Balancer]
APP1[App Cluster]
DB1[(DB Primary)]
end
subgraph "Zone B (北京)"
LB2[Load Balancer]
APP2[App Cluster]
DB2[(DB Standby)]
end
subgraph "Zone C (深圳)"
LB3[Load Balancer]
APP3[App Cluster]
DB3[(DB Read Replica)]
end
DNS[Global DNS] --> LB1
DNS --> LB2
DNS --> LB3
DB1 -.->|异步复制| DB2
DB1 -.->|异步复制| DB3
DB2 -.->|仲裁| CP[Consensus/仲裁]故障转移三要素:检测、决策、执行
检测:别等用户报故障
心跳(Heartbeat)是最基础的检测手段:每 1-5 秒发一次 ping,连续 3 次没收到就标记疑似故障。问题在于误判——网络抖动可能让健康节点被摘掉。
好的做法是双向检测:节点也主动上报 "I'm alive"(带 load 信息),超时 + 高负载 同时触达才判定为挂。配合探针(Probe)做业务层面的健康检查,不做 ping 而做一次小查询。
决策:多人投票 vs 一人裁决
- Quorum 模式:多个节点互相投票,少数服从多数,需要至少 n/2+1 个节点一致。etcd 和 ZK 用这个,前提是节点数必须是奇数。
- Fencing 模式:发现节点挂掉后,先确保它真的不能再干活(用 STONITH 或租约过期),再切流量。防止"以为挂了但其实还在写数据"导致的脑裂。
- Raft 的 Lease 机制:Leader 定期续约,不续约就自动降级,Follower 超时后发起选举。不需要额外节点做仲裁。
执行:切换不是简单改 IP
切换涉及:
- DNS 刷新:切换域名指向,但 DNS 有 TTL 缓存,生效需要 30-300 秒
- VIP 漂移:虚拟 IP 从故障节点漂到新的主节点,推 ARP 通知交换机
- 连接迁移:已建立的 TCP 连接会断,需要客户端重连(重试+退避)
依赖治理:弱依赖就该弱处理
一个服务往往依赖多个下游(DB、Cache、MQ、外部 API)。一个弱依赖的雪崩能把整个系统拖死。
强依赖 vs 弱依赖梳理
画调用链路图,对每个依赖打标签:
- 强依赖:依赖挂了,服务不可用(如支付查余额依赖 DB)
- 弱依赖:依赖挂了,功能降级但不影响核心(如商品详情页的推荐列表)
弱依赖必须加超时+熔断+降级。超时默认 200ms,超过就返回兜底数据(空列表或缓存快照),不阻塞线程。
熔断器三状态
正常 -> 请求失败次数超阈值 -> 熔断开启(直接拒绝请求)
|
v 等待超时(如 5s)
|
半开 -> 允许少量请求探测
|
成功 -> 恢复,失败 -> 回熔断java
// 熔断器简化实现
public class CircuitBreaker {
enum State { CLOSED, OPEN, HALF_OPEN }
private State state = State.CLOSED;
private int failureCount = 0;
private int threshold = 5; // 连续失败 5 次熔断
private long lastFailureTime;
private long timeout = 5000; // 5 秒后尝试半开
public boolean allowRequest() {
if (state == State.OPEN) {
if (System.currentTimeMillis() - lastFailureTime > timeout) {
state = State.HALF_OPEN;
return true;
}
return false;
}
return true;
}
public void recordSuccess() {
if (state == State.HALF_OPEN) {
state = State.CLOSED;
}
failureCount = 0;
}
public void recordFailure() {
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (failureCount >= threshold) {
state = State.OPEN;
}
}
}过载保护:入口限流 + 优先级丢弃
排队 vs 拒绝
系统过载时,继续接受请求只会让事情更糟(队列积压,RT 飙升,最终所有请求都超时,雪崩)。两种策略:
- 放弃式:直接返回 503,客户端重试退避。适合短时间峰值的场景(如秒杀抢购)。
- 排队式:返回 202 + 排队号,处理完异步通知。适合可以等几秒的场景(如订单处理)。
优先级丢弃
有限资源留给高价值请求。典型的分级:
- P0(交易请求):保,降级任何其他依赖也要保
- P1(核心查询):能保尽量保,允许降级为缓存数据
- P2(辅助功能):超时即降级,不做重试
- P3(非核心):直接丢弃或限流,不计入 SLA
容灾演练:不演练的预案等于没有
运维界有句话:"你不演练,故障就帮你演练"。每个季度至少做一次:
- DB 主从切换:手动切一次,记录切换时间 + 数据一致性损失
- 机房断网模拟:拔一根核心交换机线,看流量是否自动切到另一个机房
- 依赖宕机模拟:关掉 Redis 集群,看服务降级逻辑是否生效,有没有未被兜底的查询打到 DB 把 DB 打挂
- 流量突增模拟:压测工具制造 3 倍峰值流量,看限流和熔断是否正常
每次演练产出:问题清单 + 改进项。演练发现问题比出故障时才发现好 100 倍。
常见误区与小结
- 误区:99.99% 靠优化代码达到。实际上代码优化最多把停机时间从 10 小时缩到 8 小时,从 8 小时到 52 分钟只能靠冗余。
- 误区:熔断和重试一起用。熔断后重试只会让熔断器一直开着,正确做法是熔断期间不重试,等恢复探测。
- 误区:异地多活所有节点都写。跨城无可靠的强一致方案,异地多活必须接受最终一致,业务层做冲突检测和对账。
- 误区:降级就是砍功能。好的降级不砍功能,而是用脏数据/缓存数据代替实时数据,用户感知不到。
- 误区:故障转移越快越好。切换太快容易误切换(网络抖动被判断为节点挂),建议设置 3 次心跳超时(约 15 秒)后再触发切换。
小结:高可用没有银弹,本质是冗余 + 自动检测 + 快速切换 + 依赖隔离的组合拳。设计任何系统都要问清楚"单一故障点在哪里"、"挂了怎么恢复"。下一篇(33)用 27 个案例串讲读多/写多/低延迟/强一致四类场景,看高可用和高性能怎么绑在一起用。
参考
参考:Google SRE 白皮书(第 4-6 章) 参考:Martin Kleppmann《Designing Data-Intensive Applications》第 8 章 参考:Nginx 健康检查与故障转移配置文档