35.3 冗余 (主备 / 双活 / 多活)
一句话
冗余买的是MTBF(少坏几次),买不到 MTTR(坏了多快好);主备、双活、多活的价格差,90% 花在“切换要多久”和“写冲突怎么解”上,而不是花在机器上。
冗余买的是 MTBF,不是 MTTR
回到 35.1 可用性定义与 9 的换算 的公式:A = MTBF/(MTBF+MTTR)。冗余的作用点只有一个——当一台挂了,另一台还在,所以“系统故障”这件事没有发生,MTBF 被拉长。
它没有改变 MTTR:那台坏掉的机器仍然要修 30 分钟,只是这 30 分钟里用户没感觉。所以冗余能不能兑现,完全取决于“切换有多快”:
| 冗余形态 | 单次切换耗时 | 年故障 4 次的年停机 | 可用性 | 用户感知 |
|---|---|---|---|---|
| 主备(人工确认后切) | 30 分钟 | 2h08m00s | 99.97565% | 每次都有半小时报错 |
| 双活(秒级摘除) | 10 秒 | 8m40s | 99.99835% | 几乎无感 |
| 三单元互备(秒级摘除) | 10 秒 | 8m40s | 99.99835% | 几乎无感 |
同样 4 次故障,主备和双活差了 15 倍的年停机。钱花在机器上是一样的(都是 2 份资源),差的是切换能力——而切换能力是软件工程,不是采购。
🔑 判断一个冗余方案值不值,先问“故障到切换完成要多久”,再问“要几份资源”。倒过来问,就会买到一堆永远不会被切过去的备机。
五种冗余形态
| 形态 | 数据副本 | 流量分配 | 资源利用率 | 切换耗时 | 关键难点 | 典型场景 |
|---|---|---|---|---|---|---|
| 主备(active-passive) | 同步或异步 | 备机不接流量 | 50% | 分钟级 | 备机是否真的可用、切换脚本无人练 | 数据库主从、老系统容灾 |
| 主从读写分离 | 同步/半同步 | 主写从读 | 60%–80% | 分钟级(提升从为主) | 复制延迟、读到旧数据 | 读多写少业务 |
| 池化 N+1 | 无状态,靠多副本 | 负载均衡全接 | (n)/(n+1) | 秒级(摘除) | 故障后过载 | 无状态服务、网关 |
| 双活(active-active) | 双向复制 | 两边都接 | 100% | 秒级 | 写冲突、会话粘性 | 同城双机房 |
| 多活(multi-active) | 单元化/分片 | 按用户路由 | 100% | 秒级–分钟级 | 跨单元事务、全局数据 | 异地多地域 |
机房怎么摆、RPO 与成本怎么跟着变,见 36.4 两地三中心与多活;这张表只回答“冗余用什么形态”。
这张表里最容易被低估的是“资源利用率”那一列:
- 主备的 50% 不是浪费,是“买到 30 分钟不可用”的代价。它换来的是一份随时可以接管的完整副本。
- 双活的 100% 有个陷阱:两个单元各跑 50%,一个挂了另一个瞬间变 100%,没有任何余量。这就是下一节要算的过载。
N+M 的数学:集群越大,N+1 越不够
n 台机器、单台可用性 99%、最多容忍 m 台同时故障,集群可用性服从二项分布:
A_cluster = P(同时故障台数 ≤ m) = Σ_{k=0..m} C(n,k) · (1−a)^k · a^(n−k)
| 台数 | N+0 | N+1 | N+2 |
|---|---|---|---|
| 3 | 97.0299% | 99.9702% | 99.9999% |
| 5 | 95.0990% | 99.9020% | 99.9990% |
| 10 | 90.4382% | 99.5734% | 99.9886% |
| 20 | 81.7907% | 98.3141% | 99.8996% |
这张表有一个反直觉的结论:同样的 N+1,3 台集群(99.97%)反而比 20 台集群(98.31%)更可靠。
原因很直接:单台故障率 1% 时,20 台里“同时坏两台”的概率远高于 3 台里同时坏两台。N+1 只能防“坏一台”,集群越大,坏两台变成常态。
⚠️ 所以大集群的冗余方案不能照抄小集群:20 台以上的集群要么上 N+2,要么把单台可用性从 99% 提到 99.9%(故障率降 10 倍,同时坏两台的期望次数降 100 倍),要么像 K8s 那样按“可用区”而不是“单机”做故障域。
故障转移的过载:冗余能不能真的接管
冗余节点要接管,前提是它还有余量。基线利用率 u、n 台中坏 m 台,幸存节点的利用率是:
u' = u × n / (n − m)
| 台数 | 基线利用率 | 坏 1 台后 | 结论 |
|---|---|---|---|
| 3 | 50% | 75% | 可接管 |
| 3 | 70% | 105% | 被打满 → 雪崩 |
| 5 | 70% | 88% | 可接管,但没余量 |
| 5 | 90% | 112% | 被打满 → 雪崩 |
| 10 | 90% | 100% | 正好卡在 100%,抖动即挂 |
这就是“有冗余还是挂了”的最常见原因:机器确实多备了一台,但业务已经跑到 80% 利用率,坏一台之后剩下的机器顶不住,级联超时、队列堆积、最终整体雪崩——冗余在数学上存在,在容量上不存在。
工程上的两条硬约束:
- 基线利用率 × n/(n−1) ≤ 85%:接管后要留 15% 余量吸收突发与重试流量。
- 冗余系数单独核算:容量规划时把“故障转移后的容量”当作真实容量,而不是把“总容量”当真实容量(冗余系数的算法见 57.3 冗余系数)。
双活的三种做法,代价完全不同
“双活”这个词被用得很泛,实际有三种,难度依次上升:
| 做法 | 写怎么处理 | 一致性 | 代价 | 何时可用 |
|---|---|---|---|---|
| 双活读、单边写 | 全部写路由到一边,另一边只读 | 强(读可能旧) | 低,只需路由与延迟容忍 | 读多写少、可容忍跨城延迟 |
| 双写 + 冲突检测 | 两边都写,按版本号/LWW 合并 | 最终一致 | 中,但会静默丢更新 | 计数、点赞、非资金数据 |
| 单元化双活 | 按用户分片,同一用户只落一边 | 强(单元内) | 高,要重构路由与跨单元流程 | 交易、账务、大规模 To C |
第二种最危险:LWW(最后写入者胜)在时钟偏差下会丢更新,而丢更新是静默的——没有报错、没有告警,只有对账时才能发现。资金与库存不能这么干。
第三种是把冲突“从根上消掉”:只要保证同一个用户永远落同一个单元,就根本不存在同一行被两边改的情况。展开见 36.5 单元化 / Set 化架构。
🔑 判断一个“双活”是不是真的:问一句“同一个用户的同一笔写,会不会同时落到两个机房”。会,就必须有冲突解决方案;不会,才叫单元化。
假冗余的五种形态
买了一倍的机器,可用性却没涨,通常是踩了下面五种之一:
| 形态 | 表现 | 为什么是假的 |
|---|---|---|
| 共享故障域 | 两个副本在同一台物理机/同一个可用区/同一个 K8s 集群 | 一次故障同时带走两个,ρ≈1(见 35.1 可用性定义与 9 的换算 的相关性模型) |
| 共享依赖 | 应用双活,但共用一个数据库主库 / 一个 Redis / 一个 DNS | 依赖挂了两边一起挂,冗余只覆盖了应用层 |
| 备机从未启动 | 冷备机器上的版本落后 3 个 release,配置是两年前的 | 切过去也起不来,等于没有(见 36.3 冷备 / 温备 / 热备) |
| 切换路径未验证 | 有备机,但切换要人工改 DNS、改连接串、找 DBA 提权 | 真出事时切不动,MTTR 等于“找人 + 试错”的时间 |
| 容量不冗余 | 机器多一台,但基线利用率 90% | 接管后 100% 打满,雪崩(见上一节) |
一个可操作的检查清单:对着每一个“冗余”,问“我能独立地把这一半干掉吗”。答案是否定的,就不是冗余,是心理安慰。
冗余的副作用
冗余不是免费的,它带来四个新问题:
| 副作用 | 机制 | 缓解 |
|---|---|---|
| 成本翻倍 | 2 份资源只产生 1 份有效容量 | 无状态服务用 N+1 池化(只多 1/n),不要用主备 |
| 一致性变难 | 多副本就有复制延迟与冲突 | 读己之写用会话粘性;写冲突用单元化(见 18.2 同步、半同步、异步) |
| 脑裂 | 网络分区后两边都以为自己是主 | 租约 + fencing,见 35.9 自动切换与故障恢复 |
| 配置漂移 | 两个副本的配置/版本慢慢不一致 | 不可变制品 + 声明式部署;定期做一致性巡检 |
可运行的冗余算账
把 N+M 可用性、转移过载、三种形态的停机账一次算出来:
package main
import (
"fmt"
"math"
"time"
)
// binom 返回 C(n,k) · p^k · (1−p)^(n−k)
func binom(n, k int, p float64) float64 {
c := 1.0
for i := 0; i < k; i++ {
c = c * float64(n-i) / float64(i+1)
}
return c * math.Pow(p, float64(k)) * math.Pow(1-p, float64(n-k))
}
// clusterAvail: n 台、单台可用性 a、最多容忍 m 台同时故障。
func clusterAvail(n, m int, a float64) float64 {
pFail, s := 1-a, 0.0
for k := 0; k <= m; k++ {
s += binom(n, k, pFail) // 同时坏 k 台的概率
}
return s
}
// overload: 基线利用率 u、n 台中坏 m 台后,幸存节点的利用率。
func overload(u float64, n, m int) float64 { return u * float64(n) / float64(n-m) }
func yearDown(a float64) time.Duration {
return time.Duration((1 - a) * 365 * 24 * float64(time.Hour))
}
func dur(d time.Duration) string {
d = d.Round(time.Second)
return fmt.Sprintf("%dh%02dm%02ds", int(d.Hours()), int(d.Minutes())%60, int(d.Seconds())%60)
}
func main() {
fmt.Println("== 1. N+M 集群可用性(单台 99%)==")
for _, n := range []int{3, 5, 10, 20} {
for _, m := range []int{0, 1, 2} {
a := clusterAvail(n, m, 0.99)
fmt.Printf("%2d 台 N+%d → %10.6f%% 年停机 %s\n", n, m, a*100, dur(yearDown(a)))
}
}
fmt.Println("\n== 2. 故障转移后的过载(坏 1 台)==")
for _, n := range []int{3, 5, 10} {
for _, u := range []float64{0.5, 0.7, 0.9} {
o := overload(u, n, 1)
verdict := "可接管"
if o >= 1.0 {
verdict = "被打满→雪崩"
} else if o >= 0.9 {
verdict = "无余量,抖动即挂"
}
fmt.Printf("%2d 台 基线 %2.0f%% → 坏 1 台后 %3.0f%% %s\n", n, u*100, o*100, verdict)
}
}
fmt.Println("\n== 3. 主备 / 双活 / 三单元(年故障 4 次)==")
type form struct {
name string
units int
util float64
switchS int // 单次切换耗时(秒)
}
for _, f := range []form{
{"主备", 2, 0.50, 1800},
{"双活", 2, 0.50, 10},
{"三单元互备", 3, 0.667, 10},
} {
down := 4 * time.Duration(f.switchS+120) * time.Second // 每次 = 检测 120s + 切换
a := 1 - down.Hours()/(365*24)
fmt.Printf("%-6s 单元 %d 利用率 %3.0f%% 故障后 %3.0f%% 年停机 %8s 可用性 %.5f%%\n",
f.name, f.units, f.util*100, overload(f.util, f.units, 1)*100, dur(down), a*100)
}
}
输出(GO111MODULE=off go run main.go,本机实测):
== 1. N+M 集群可用性(单台 99%)==
3 台 N+0 → 97.029900% 年停机 260h10m51s
3 台 N+1 → 99.970200% 年停机 2h36m38s
3 台 N+2 → 99.999900% 年停机 0h00m32s
5 台 N+0 → 95.099005% 年停机 429h19m38s
5 台 N+1 → 99.901985% 年停机 8h35m10s
5 台 N+2 → 99.999015% 年停机 0h05m11s
10 台 N+0 → 90.438208% 年停机 837h36m47s
10 台 N+1 → 99.573380% 年停机 37h22m19s
10 台 N+2 → 99.988615% 年停机 0h59m50s
20 台 N+0 → 81.790694% 年停机 1595h08m07s
20 台 N+1 → 98.314066% 年停机 147h41m16s
20 台 N+2 → 99.899642% 年停机 8h47m29s
== 2. 故障转移后的过载(坏 1 台)==
3 台 基线 50% → 坏 1 台后 75% 可接管
3 台 基线 70% → 坏 1 台后 105% 被打满→雪崩
3 台 基线 90% → 坏 1 台后 135% 被打满→雪崩
5 台 基线 50% → 坏 1 台后 62% 可接管
5 台 基线 70% → 坏 1 台后 88% 可接管
5 台 基线 90% → 坏 1 台后 112% 被打满→雪崩
10 台 基线 50% → 坏 1 台后 56% 可接管
10 台 基线 70% → 坏 1 台后 78% 可接管
10 台 基线 90% → 坏 1 台后 100% 被打满→雪崩
== 3. 主备 / 双活 / 三单元(年故障 4 次)==
主备 单元 2 利用率 50% 故障后 100% 年停机 2h08m00s 可用性 99.97565%
双活 单元 2 利用率 50% 故障后 100% 年停机 0h08m40s 可用性 99.99835%
三单元互备 单元 3 利用率 67% 故障后 100% 年停机 0h08m40s 可用性 99.99835%
三个读数,三条结论:
- N+1 的可靠性随集群规模下降:3 台 99.97%、10 台 99.57%、20 台 98.31%。同样的“多备一台”,集群越大越不管用,因为“同时坏两台”的概率在涨。
- 70% 利用率是 3 台集群的生死线:基线 70%、坏 1 台后 105%,直接雪崩。5 台集群可以撑到 70%(88%),10 台撑到 80% 左右。冗余能不能接管,是算出来的,不是配置出来的。
- 主备和双活的机器成本一样,可用性差 15 倍(99.9757% vs 99.9984%)。差别全在那 1790 秒的切换时间上——这 1790 秒买不回来,只能靠自动化去省。
反模式
| 反模式 | 后果 |
|---|---|
| 用主备做无状态服务的冗余 | 花了 2 倍资源,只用到 50%,且切换是分钟级;应该用 N+1 池化 |
| 双活但两个单元各跑 50% | 单边故障后另一边 100%,没有任何余量,抖动即挂 |
| 基线与冗余一起算进“总容量” | 故障转移时才发现容量不够,级联雪崩 |
| 双活做成“双写 + LWW” | 时钟偏差下静默丢更新,对账才能发现 |
| 备机不参与日常流量 | 备机上的配置/版本/依赖漂移,切过去起不来 |
| 冗余不做故障域隔离 | 同一可用区/同一 K8s/同一 DB,一次故障全带走 |
| 大集群照抄小集群的 N+1 | 20 台 N+1 只有 98.31%,比 3 台的 99.97% 差一个数量级 |
| 只算机器成本,不算切换能力 | 买到的是“心理上的高可用”,真出事还是要人肉切 |
| 没有回切方案 | 切得过去切不回来,长期跑在备机上,容量与性能都缩水 |
自检
Q1 你们有 10 台应用服务器,N+1 冗余,每台可用性 99%。老板问“这样够不够 99.9%”。答案是什么?怎么改最省钱?
▸ 答案:不够,10 台 N+1 是 99.5734%(年停机 37h22m),离 99.9%(8h45m)差 4 倍多。三个改法按性价比排序:① 把单台可用性从 99% 提到 99.9%(少一半故障源:升级内核、加健康检查、消灭单点依赖),N+1 就能到 99.95% 以上;② 从 N+1 改 N+2(10 台 N+2 = 99.9886%,年停机 59 分 50 秒),成本只多 1 台;③ 把集群拆小(比如 3 台一组 × 4 组),每组 N+1 是 99.97%。我的判断是 ①+② 组合最划算:先降单台故障率,再加一台冗余,比盲目扩到 20 台有效得多。
Q2 你们做了同城双活,两个机房各承载 50% 流量,基线 CPU 利用率 45%。某天一个机房整体断电。会发生什么?请给出数字。
▸ 答案:幸存机房要立刻承担 100% 流量,利用率从 45% 涨到 90%(45% × 2/1)。90% 利用率下排队延迟按 M/M/1 会涨到服务时间的 9 倍,P99 直接打穿 SLO,随后是超时—重试—流量放大的正反馈,很可能整体雪崩。所以“双活各 50%”不是高可用,是“没有余量的高可用”。正确做法:要么两个机房各跑 40% 留 20% 余量(故障后 80%),要么按 3 个单元部署(每单元 33%),要么准备“故障时降级非核心功能”的预案,把 CPU 需求砍掉 30%。
Q3 一个团队说“我们做了多活,三个地域都能写”。作为架构评审,你只问一个问题来判断它是否真的可行。问什么?
▸ 答案:问“同一个用户的同一笔写,会不会同时落到两个地域”。如果会,就必须有冲突解决方案(LWW/版本向量/CRDT/业务补偿),而 LWW 会静默丢更新,资金与库存不可接受;如果不会,说明它其实做了单元化路由(按 user_id 固定落一个地域),这才叫多活。第二个要追问的是“跨单元的那 10% 操作怎么办”——转账、库存扣减、全局计数不会因为分了单元就消失,它们才是多活的真实成本所在。
Q4 你们的主备数据库每年演练一次切换,每次都很顺利。这个冗余可信吗?还缺什么验证?
▸ 答案:不够可信。“演练顺利”只验证了切换动作本身,没验证三件事:① 切换耗时是否达标:要掐表,看检测 + 决策 + 提升 + 切流分别是多少秒,能不能落在 RTO 内(见 36.1 RTO / RPO);② 切过去之后的容量与延迟:备机平时不接流量,缓存是冷的、连接池是空的,切过去的前 5 分钟往往是性能最差的时候;③ 回切:很多团队切得过去切不回来,长期跑在备机上,等于把冗余用成了单点。建议加两项:随机时间的无预告切换(验证值班响应),以及切换后 30 分钟内的数据一致性校验。
关联
- 前置:35.1 可用性定义与 9 的换算(并联公式与相关性模型)
- 并列:36.3 冷备 / 温备 / 热备(按“备到什么程度”分类)、36.4 两地三中心与多活(按“机房怎么摆”分类)
- 下游:35.9 自动切换与故障恢复(把切换时间压下来)、36.5 单元化 / Set 化架构(消掉写冲突)
- 下游:57.3 冗余系数(故障转移后还剩多少容量)