36.4 两地三中心与多活

一句话

两地三中心解决“机房没了怎么办”,多活解决“机房没了业务还在跑”;拓扑每加一层,成本翻倍、写延迟按光速上涨,而真正的门槛不是机房数量,是数据冲突方案、流量调度和演练能力。

先分清五个东西

概念 部署 故障域 主要解决
单机房多可用区 1 地域、3 AZ 单 AZ 故障 服务器/机架/电力故障
备份 + 冷备 1 主 + 异地存储 机房整体故障 数据不丢(RTO 小时级)
同城双活 2 个机房,距离 < 50km 单机房故障 RTO 分钟级、RPO≈0
两地三中心 同城 2 + 异地 1 城市级故障 城市级灾难
多活 ≥ 2 个地域同时承载流量 地域级故障 业务连续,用户无感

“两地三中心”是拓扑,“多活”是流量的使用方式:三个中心都待命但只有一套在跑,那是灾备;三套都在接流量,才是多活。

四种拓扑的账:延迟、RPO、可用性、成本

拓扑 写延迟 RPO RTO 年停机 年可用性 成本 单中心故障后
单中心 + 异地冷备 0.5ms 24h 522.6min 34.8h 99.6023% 1.0× 整体不可用
同城双活 + 异地冷备 1.5ms 30s 30min 2.0h 99.9772% 2.1× 切到同城另一中心
两地三中心(异地同步) 30ms 0 1min 0.1h 99.9992% 3.1× 切到同城或异地
三地五中心 30ms 0 0.5min ≈0 99.9996% 5.2× 存活地域各担 50%

三个读数:

  1. 写延迟是物理代价:同城双活只多 1ms;异地同步直接到 30ms。这不是软件优化能消掉的——光在光纤里约 200km/ms,30ms 往返意味着两个机房相距约 3000 公里,往返一趟就是 30ms。
  2. RPO=0 与低延迟不可兼得:要么同步(吃 RTT),要么异步(丢最后一段)。异步复制的 RPO 由积压 ÷ 带宽决定:4 GiB 积压、200 MB/s 带宽 = 滞后 20 秒。
  3. 成本不是线性买 9:从 2.4 个 9 到 3.6 个 9 只要多花 1.1×(结构改动),从 5.1 个 9 到 5.4 个 9 要多花 2.1×(五中心)。最后半个 9 最贵。

9 的边际成本:什么时候该停

从 → 到 可用性 多买的 9 成本变化 每多一个 9 约花
单中心 → 同城双活 99.60% → 99.977% +1.24 +1.1× 0.9×
同城双活 → 两地三中心 99.977% → 99.9992% +1.48 +1.0× 0.7×
两地三中心 → 三地五中心 99.9992% → 99.9996% +0.30 +2.1× 7.0×

🔑 结论很直接:同城双活是性价比最高的那一步(花 1.1 倍成本,年停机从 34.8h 降到 2h);两地三中心再花 1 倍换来“城市级灾难”的保险;三地五中心只多 0.3 个 9 却要 2.1 倍成本,只有“每分钟停机都按金额计损”的业务才值得。

RPO=0 的实现代价:同步、多数派与写延迟

复制方式 写延迟 RPO 可用性影响
异步(主→备) 本地写,最低 滞后量(秒~分钟) 主挂了可能丢最后一段
同步(主→备,备确认才返回) +1 个 RTT 0 备挂了写不进去(除非降级)
半同步(多数派确认) + 最快的多数派 RTT 0(不丢已提交) 容忍少数派故障
三副本跨 3 AZ,法定多数 + 同城 RTT 0 丢 1 个 AZ 仍可写

关键约束:跨地域同步写,等于把异地 RTT 加到每一次写操作上。30ms 的 RTT 对下单接口是灾难(一次请求 3 次写 = +90ms),所以异地通常只能异步;要 RPO=0 又不想吃延迟,就得把“写”收敛在同城,异地只承接读和灾备切换。

多活的三道门槛

门槛一:数据冲突

两个地域同时接受写,同一份数据就可能被两边改:

方案 做法 代价
单元化(Set) 按用户/租户分片,同一用户永远落同一单元 跨单元查询与转账要专门设计
单写多读 某类数据只在一个地域写 写请求要路由,延迟上升
冲突检测(版本号/LWW) 后写覆盖先写 静默丢更新
CRDT 设计成可合并的数据类型 只适合计数、集合、点赞等
业务补偿 冲突后人工/自动对账 复杂,且不适合资金

⚠️ 没有单元化的“多活”往往等于双写:同一个用户的订单可能落在两个地域,库存各扣一次,最后靠对账修。多活的复杂度主要在这里,不在机房。

门槛二:流量调度

层 手段 关键参数
DNS / GSLB 按地域解析、按健康状态切 TTL 60–300s(TTL 太长,切流慢;太短,DNS 压力大)
入口网关 按 header/地域/灰度比例路由 需要能“一键切回”
数据层 按分片键路由 路由表要能热更新

切流最容易出事的不是“切过去”,而是切回来:主机恢复后流量回切,缓存冷启动 + 连接风暴 + 数据追上,往往比故障本身更糟。

门槛三:容量与演练

  • 容量必须 N+1:双活各承载 50%,一台挂了另一台要扛 100% → 容量按 2× 预留;三活各 33%,一个地域挂了存活地域各扛 50% → 容量按 150% 预留(每地域多留 50%)。
  • 演练:不演练的多活,切换时长的真实值是估算的 2–3 倍;且“部分切换”(只切 10% 流量)才是可验证的方式。

异地多活怎么落地:四步 + 四技巧

多活的难点不在机房,而在决定放弃什么。一套可执行的顺序:

步 做什么 判据
① 业务分级 只给核心业务做多活 三个标准:访问量最大、核心链路、产生收入;三者冲突时以 “影响用户数” 为准
② 数据分类 逐类数据打 5 个标签 数据量、唯一性、实时性、可丢失性、可恢复性
③ 数据同步 按标签选同步方式 存储同步 / 消息队列 / 重复生成 / 回源读取
④ 异常处理 假设同步一定会失败 多通道、同步+访问结合、日志记录、只保核心 + 安抚

② 数据分类:五个维度决定方案

维度 问什么 影响
数据量 增量(增删改)有多大 越大,同步延迟的几率越高
唯一性 两个机房能否产生同类数据 要求唯一 → 只能单点产生,或引入全局 ID(44.24 发号器与全局 ID)
实时性 改完多久必须到对端 越高,方案越复杂(同城同步 vs 异地异步)
可丢失性 丢了业务会不会出事 session 可丢,账户不可丢
可恢复性 丢了能不能补回来 微博可重发、密码可找回、账号不可恢复

③ 四种同步方式

方式 适合 例子 代价
存储系统同步 有事务/时序要求、改动不频繁 密码、用户信息(MySQL 主从) 通常只有一条同步通道,量大时延迟到分钟级
消息队列同步 无事务、无时序要求 新注册账号 顺序与幂等要自己做
重复生成 可再生数据 session、验证码、缓存 用户需要重新登录/重新获取
回源读取 数据有明确归属机房 跨机房读 session / 账号 多一次跨机房调用,依赖路由正确

🔑 最典型的一次选择:消息队列适合 “账号”(只创建,先后无所谓),不适合 “密码”(必须先 m 后 n)。同一套系统里不同数据走不同通道,才是常态。

四技巧:每一条都是主动放弃

技巧 放弃什么 换到什么 具体做法
只保核心业务 注册、资料修改的多活 登录 100% 可多活 登录只需账号密码全量复制到每个中心;注册受 “手机号唯一” 约束,跨机房无法判重,硬做会产生不可修复的冲突
只保核心数据最终一致 实时一致 方案可落地 session 不同步(重登即可);只同步核心表;一致性窗口按分钟算
多种手段同步 “只依赖存储同步” 的简单 极端场景仍可用 账号走 MQ + 数据库双通道;本地读不到时按路由二次读取对端;session 走回源
只保绝大部分用户 100% 可用 整体方案可行 接受千分之几的受损用户;强一致业务(转账)改成 “申请 + 异步重试”;挂公告 + 事后补偿

算例一:为什么 “保登录、弃注册” 是算出来的

业务 每天的量级(日活 1000 万的系统) 多活难度
登录 ≈ 1000 万次 低:每个中心都有全量账号密码,换个中心重登即可
注册 几万次 高:A 机房注册未同步、A 挂掉后用户在 B 注册,A 恢复即冲突,且不能 “以后一次为准”
改资料 不到 1 万次 中:可用 LWW,但要求多机房时钟误差 < 1 秒,异地做不到

结论:把有限的复杂度投在 “影响 1000 万人” 的登录上,而不是 “影响几万人” 的注册上。顺带一提,为了多活去改 “一个手机号只能注册一个账号” 这条核心业务规则,代价远大于收益。

算例二:银行转账为什么不能实时多活

小明有 1 万元,北京中心转给张三 1 万、上海中心转给李四 1 万——两地同时成功就是双花。所以 “实时转账” 只能单中心写。

可行的降级是把它变成 “转账申请”:上海中心只记录申请 → 后台异步向北京中心发起 → 北京不可用就重试等待 → 恢复后余额不足则置为失败。代价是用户体验从 “一次操作” 变成 “提交 + 等结果”,补救手段是短信通知结果。

④ 异常处理清单

手段 做法 关键点
多通道同步 DB 同步 + MQ 同步并行 两条通道不能走同一条网络(一公网一内网);数据必须可重复覆盖(账号可以,密码不行)
同步 + 访问结合 本地优先,读不到按路由访问对端接口 接口通道与同步通道分离;路由规则要能推断数据归属
日志记录 关键操作前后记流水 用于事后修正数据,日志本身要跨机房可取
只保核心 + 安抚 挂公告、事后补偿(代金券/礼包)、补充体验(短信通知) 要明确写清 “哪部分用户会被牺牲”

可运行的拓扑账本

package main

import (
	"fmt"
	"math"
)

type topo struct {
	name         string
	writeRTT     float64 // 一次同步写要等的跨节点 RTT (ms)
	rpoSec       float64
	rtoHour      float64
	cost         float64 // 相对成本(单中心 = 1)
	after        string  // 单中心/单地域故障后的状态
}

func rpo(sec float64) string {
	if sec >= 3600 {
		return fmt.Sprintf("%.0fh", sec/3600)
	}
	return fmt.Sprintf("%.0fs", sec)
}

func main() {
	const incidents = 4.0
	const hours = 365 * 24.0

	list := []topo{
		{"单中心 + 异地冷备", 0.5, 24 * 3600, 8.71, 1.00, "整体不可用"},
		{"同城双活 + 异地冷备", 1.5, 30, 0.50, 2.10, "切到同城另一中心"},
		{"两地三中心(异地同步)", 30, 0, 1.0 / 60, 3.10, "切到同城或异地"},
		{"三地五中心", 30, 0, 0.5 / 60, 5.20, "存活地域各担 50%"},
	}

	fmt.Printf("%-24s %7s %6s %7s %7s %9s %5s  %s\n",
		"拓扑", "写延迟", "RPO", "RTO", "年停机", "可用性", "成本", "故障后")
	for _, t := range list {
		down := t.rtoHour * incidents
		avail := 1 - down/hours
		fmt.Printf("%-24s %5.1fms %6s %6.1fmin %6.1fh %8.4f%% %4.1fx  %s\n",
			t.name, t.writeRTT, rpo(t.rpoSec), t.rtoHour*60, down, avail*100, t.cost, t.after)
	}

	// 每多买一个 9 的边际成本
	fmt.Println("\n每多买一个 9 的边际成本:")
	prevNines, prevCost := 0.0, 0.0
	for i, t := range list {
		avail := 1 - t.rtoHour*incidents/hours
		nines := -math.Log10(1 - avail)
		if i == 0 {
			fmt.Printf("    %-24s %.2f 个 9,成本 %.1fx\n", t.name, nines, t.cost)
			prevNines, prevCost = nines, t.cost
			continue
		}
		dn, dc := nines-prevNines, t.cost-prevCost
		fmt.Printf("    %-24s %.2f 个 9(+%.2f),成本 %.1fx(+%.1fx)→ 每多一个 9 约 %.1fx\n",
			t.name, nines, dn, t.cost, dc, dc/dn)
		prevNines, prevCost = nines, t.cost
	}

	// 异地异步复制的滞后:突增的积压 / 跨域带宽
	backlogGiB, bwMBs := 4.0, 200.0
	lag := backlogGiB * 1024 / bwMBs
	fmt.Printf("\n跨域异步复制:%0.0f GiB 积压、带宽 %.0f MB/s → 滞后 %.0f 秒(这段时间的数据会丢)\n",
		backlogGiB, bwMBs, lag)

	// 单地域 33% 流量的三活:故障后每个存活地域从 33% 涨到 50%
	fmt.Printf("三活各承载 33%%:一个地域故障后,存活地域各承载 50%%,需按正常负载的 %.0f%% 预留容量(每地域多留 50%% 余量)\n",
		50.0/33.3*100)
}

输出:

拓扑                           写延迟    RPO     RTO     年停机       可用性    成本  故障后
单中心 + 异地冷备                 0.5ms    24h  522.6min   34.8h  99.6023%  1.0x  整体不可用
同城双活 + 异地冷备                1.5ms    30s   30.0min    2.0h  99.9772%  2.1x  切到同城另一中心
两地三中心(异地同步)               30.0ms     0s    1.0min    0.1h  99.9992%  3.1x  切到同城或异地
三地五中心                     30.0ms     0s    0.5min    0.0h  99.9996%  5.2x  存活地域各担 50%

每多买一个 9 的边际成本:
    单中心 + 异地冷备               2.40 个 9,成本 1.0x
    同城双活 + 异地冷备              3.64 个 9(+1.24),成本 2.1x(+1.1x)→ 每多一个 9 约 0.9x
    两地三中心(异地同步)              5.12 个 9(+1.48),成本 3.1x(+1.0x)→ 每多一个 9 约 0.7x
    三地五中心                    5.42 个 9(+0.30),成本 5.2x(+2.1x)→ 每多一个 9 约 7.0x

跨域异步复制:4 GiB 积压、带宽 200 MB/s → 滞后 20 秒(这段时间的数据会丢)
三活各承载 33%:一个地域故障后,存活地域各承载 50%,需按正常负载的 150% 预留容量(每地域多留 50% 余量)

注意表中的“每多一个 9 约 0.7×”看着划算,是因为这一段同时减少了同城的切换时间——拓扑的收益常常来自“结构”,不是“更多副本”。而最后一行每 9 花 7 倍,才是多活的真实成本墙。

参数怎么给

你的情况 选 关键动作
只要求数据不丢 单中心 + 异地备份 备份跨区、不可变、定期恢复演练
机房级故障要 30 分钟内恢复 同城双活 同步复制 + 自动切流 + 容量 2×
城市级灾难也要能开门 两地三中心 异地异步(RPO 秒级),定期演练切异地
全国用户、地域级故障无感 单元化多活 按用户分片、单元内闭环、跨单元异步
全球用户、写延迟敏感 多地域单元化 + 本地写 数据按用户归属地域,跨地域只读
资金/库存类强一致 单写多读 + 同城同步 不要用 LWW 做多活写

反模式

反模式 后果
三个中心都在跑,但没有单元化 等于双写,库存/余额靠对账修
异地同步写为了 RPO=0 每次写 +30ms,下单/支付接口直接变慢一个数量级
DNS TTL 设 1 小时 切流要等 1 小时才全量生效
只演练“切过去” 回切时缓存冷启动 + 数据追上,二次故障
双活各按 50% 容量建设,故障时才发现不够 单边扛 100% 直接崩
用 LWW 处理库存/资金 静默丢更新,账对不上
多活但监控按单机房看 一个地域的异常被平均掉,没人发现
把“两地三中心”当成“两地三活” 预算按灾备批,承诺按多活给
给注册/资料修改也做多活 手机号唯一性跨机房无法判重,冲突不可修复
想用 LWW 合并用户资料 多机房时钟误差 > 1 秒就乱序,先改的反而不生效
两条同步通道走同一条专线 专线一断,两条通道同时失效,等于单通道
追求 100% 多活 为最后千分之几做完美方案,把 99% 拖下水

自检

Q1 同城双活能把 RPO 做到 0、写延迟只多 1ms;两地三中心用异地同步,写延迟立刻变 30ms。这 30ms 能靠优化网络消掉吗?

▸ 答案:消不掉,它是物理下限。光在光纤中的速度约 200km/ms,30ms 是往返时间,对应单程约 3000km——北京到广州、上海到新加坡都是这个量级。优化的空间只有“减少同步跳数”(只同步到一个异地副本,而不是所有副本)和“把同步写收敛到少数关键数据”。要本地写延迟又要 RPO=0,只能把写流量按用户归属收敛到某个地域(单元化),让异地同步发生在后台。

Q2 同城双活 + 异地冷备:RPO=30s 是怎么来的?如果跨域带宽从 200 MB/s 降到 50 MB/s,RPO 会变成多少?

▸ 答案:异步复制下 RPO ≈ 积压 ÷ 带宽。4 GiB 积压、200 MB/s → 20 秒(取整说 30s 是留了余量)。带宽降到 50 MB/s → 4×1024/50 ≈ 82 秒,RPO 变成约 1.5 分钟;如果写入峰值更高,积压会继续涨,RPO 还会恶化。所以 RPO 不是配置项,是带宽与写入速率的函数,大促前要按峰值重算。

Q3 三地五中心只比两地三中心多 0.3 个 9(99.9992% → 99.9996%),成本却从 3.1× 涨到 5.2×。什么业务值得?什么业务不值得?

▸ 答案:值得的是“停机按分钟计损且损失与地域强相关”的业务:支付清算、证券交易、大型电商的核心交易(每分钟损失可量化到几十万以上),且已有单元化与自动化演练能力。不值得的是:内部系统、内容站、SaaS 的非核心模块——它们的 4 个 9 已经足够,多出来的预算放在“减少故障次数”(稳定性工程)上回报更高。

Q4 单元化多活和“两个地域都接流量”有什么区别?为什么说后者常常等于双写?

▸ 答案:单元化按 分片键(用户/租户) 把流量和数据绑定到单元:同一用户的请求永远进同一单元,单元内部是一个完整闭环(网关、服务、分库),跨单元只有异步同步。这样单地域故障只影响 1/N 用户,且不存在同一用户被两地同时写。“两个地域都接流量”如果没有绑定规则,同一个用户的两次请求可能落到不同地域,各自写各自的库,库存/余额就靠对账修——这就是双写。

Q5 双活按 50/50 分流,为什么容量要按 2 倍建?如果按 1.2 倍建会发生什么?

▸ 答案:单机房故障后,存活机房要承接 100% 流量。如果每个机房只按 60% 容量建(总计 1.2 倍),故障瞬间存活侧要扛 100%,超出其 60% 的上限 → 过载 → 超时 → 重试 → 雪崩,故障从“一个机房不可用”变成“两个机房都不可用”。所以双活的容量冗余必须是“任一机房能独立承载全量”,即 2 倍;三活是 1.5 倍(存活两地域各 50%)。

Q6 为什么 “登录” 能做异地多活,“注册” 却做不了?

▸ 答案:因为约束不同。登录只需要全量账号密码已复制到每个中心,用户在哪个中心都能登;注册要满足 “一个手机号只能注册一个账号” 这条唯一性约束,而 A 机房的新注册还没同步到 B 机房时,B 机房无法判重——让用户注册会冲突,不让注册则等于没多活,而冲突一旦发生无法用 “以后一次为准” 修复。所以合理选择是:登录多活、注册单点(或只在主中心可用)。

Q7 银行转账要做异地多活,为什么只能做成 “申请 + 异步” 而不是实时转账?

▸ 答案:实时转账在两地都接写时会出现双花(同一笔 1 万元在北京和上海各转一次都成功)。所以实时写必须收敛到单一中心;多活只能通过 “先记录转账申请、后台异步向归属中心发起、失败重试” 实现,把实时一致降级为最终一致。代价是用户要多一步确认,因此需要短信通知等体验补偿。

跨篇相关:RTO/RPO 的换算见 36.1 RTO / RPO;冷温热三种形态的恢复步骤见 36.3 冷备 / 温备 / 热备;按用户分片的实现见 36.5 单元化 / Set 化架构;切换时的数据一致性见 36.6 切换时的数据一致性;跨地域读写的一致性模型见 72.5 跨地域读写与一致性(待补)。