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% 利用率,坏一台之后剩下的机器顶不住,级联超时、队列堆积、最终整体雪崩——冗余在数学上存在,在容量上不存在。

工程上的两条硬约束:

  1. 基线利用率 × n/(n−1) ≤ 85%:接管后要留 15% 余量吸收突发与重试流量。
  2. 冗余系数单独核算:容量规划时把“故障转移后的容量”当作真实容量,而不是把“总容量”当真实容量(冗余系数的算法见 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%

三个读数,三条结论:

  1. N+1 的可靠性随集群规模下降:3 台 99.97%、10 台 99.57%、20 台 98.31%。同样的“多备一台”,集群越大越不管用,因为“同时坏两台”的概率在涨。
  2. 70% 利用率是 3 台集群的生死线:基线 70%、坏 1 台后 105%,直接雪崩。5 台集群可以撑到 70%(88%),10 台撑到 80% 左右。冗余能不能接管,是算出来的,不是配置出来的。
  3. 主备和双活的机器成本一样,可用性差 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 分钟内的数据一致性校验。

关联