多活与容灾:切换才是最危险的那次变更 原创
本文是 SRE 实战系列第 26 篇,第三循环的第二篇。
第 25 篇讲的是系统在被压垮的那几秒里怎么自己刹车。这一篇讲的是另一个方向:当一整边不可用了,怎么把流量搬到另一边——以及为什么"搬过去"这件事,比灾难本身更容易把系统彻底弄死。
选择这个题目的原因,和第 25 篇一样:它是被前面几篇引用出来的空位。第 19 篇把"变更"写透了,第 23 篇把"数据"写透了,剩下的最后一段是"切换"——而切换本身就是一次变更,并且是全年最危险的那一次:它同时改动流量、连接、缓存、会话和写入方向,一次动五个,而且往往在凌晨、由最累的人、在没有演练过的路径上执行。
全文约 1.5 万字。从一次 47 分钟的故障开头:机房 B 真的坏了,切换脚本在 4 分 12 秒内跑完,控制台上两边的指标都很漂亮,然后用户开始投诉——因为那 4 分钟只完成了"路由改了",剩下的 43 分钟花在 DNS、连接池、缓存预热和会话上。
然后回答六个问题:四种容灾形态的选型边界在哪、RTO 承诺为什么几乎都是编的、切换的真实下限由什么决定、"切换 5 分钟、TTL 30 分钟"为什么是自欺、切换之后那 12 分钟才是真正的危险区、以及为什么回切比切换更难。
这一篇的立场:容灾的能力不是"能切过去",而是"切过去之后敢不敢切回来"。只有单向的切换不叫容灾,叫一次性的赌博。
一、47 分钟:4 分钟切完,43 分钟在补漏
一个出行平台,双机房部署:机房 A 主,机房 B 备(同城,40 公里)。
周四 02:14,A 机房的核心交换机故障,A 内所有服务在 90 秒内失联。值班的同学姓韩,这个月第 5 次被叫醒。
02:15,他确认了 A 侧失联(不是单机故障,是整片),按第 22 篇的定级树定 SEV-1,直接跳到容灾预案。
02:17,他开始执行 failover.sh——这份脚本两年前写过一次,中间改过三次,最近一次修改是半年前。
02:21,脚本报"完成"。共 4 分 12 秒。
02:22,控制台上两边都很好:
| 指标 | 机房 A | 机房 B |
|---|---|---|
| Pod Ready 比例 | —(不可达) | 100% |
| CPU | — | 38% |
| API 错误率 | — | 0.01% |
| 数据库主从状态 | — | 已提升为主,复制正常 |
韩同学在群里发了"已切换,机房 B 承接全量流量,指标正常"。02:23 上报 Comms Lead 对外发状态页。
02:25,客服群转进来第一条用户投诉:"登录失败,一直转圈"。
然后是第二条、第三条,两分钟内十七条。
02:31,投诉内容开始出现分化,分成三类:
- 登录转圈(最多)——请求有响应,但慢到超时
- 下单提示"系统繁忙"——是 B 侧网关返回的 503
- 有一部分用户完全正常——这部分最容易被忽略,但它其实是破案的关键
到这里,摆在他面前的事实很矛盾:监控说 B 侧 100% 健康、错误率 0.01%、CPU 只有 38%,而客服那边在十分钟里收到了一百多条投诉。
这就是容灾切换的第一种典型现场:自动化工具认为切换成功了,因为工具定义的"成功"是"配置项已应用到目标状态"。 它检查的是它改的那些东西——负载均衡的后端池、数据库的主从角色、DNS 记录、路由表。这些确实都对。
但用户访问系统要经过六个环节,工具只改动了其中一个:
| 环节 | 谁负责 | 切换脚本改了吗 |
|---|---|---|
| DNS 解析 | 权威 DNS + 递归 DNS | 改了记录,但旧记录还在各级缓存里 |
| 入口 LB / CDN | 云厂商 | 改了后端池 |
| 网关会话校验 | 应用 | 没改 |
| 连接池 | 应用 + 中间件 | 没改 |
| 本地缓存 / 分布式缓存 | 应用 + Redis | 没改 |
| 数据写入方向 | 数据库 | 改了主从 |
六格里改了三格,三格没改。而用户感知的是六格串联的结果。
1.1 破案:那 17% 正常的用户
02:38,韩同学做了一件正确的事:他没有继续查"为什么慢",而是先问"为什么不是所有人都慢"。
他把客服的投诉按用户 ID 抽样,和访问日志做 join,发现"完全正常"的用户有一个共同特征:他们的客户端在 02:14 之前就已经建立过长连接,并且在这段时间里没有重新解析过 DNS。
反过来,所有出问题的用户都是这样来的:客户端重新解析 DNS → 拿到了新 IP → 建新连接 → 请求打到 B 侧网关 → 网关校验 session → B 侧没有这个 session 的存储(会话数据在 A 侧的 Redis)→ 网关降级为"重新登录"→ 用户登录 → 登录接口又要写 A 侧的用户库(A 侧库已不可达,B 侧刚提升为主,但连接池还指向旧地址)→ 超时。
这条链上没有一个环节报错。每个环节都做了"合理的降级":网关说你重新登录,登录服务说上游超时,客户端说再试一次。三个合理的降级串起来,就是"登录一直转圈"。
02:44,真正的原因被找出来了,是三件事:
- DNS TTL 是 300 秒,但没有做"提前下调 TTL"。02:14 出事,02:15 改记录,但各级递归 DNS 还在用旧记录,有效到 02:20。这 5 分钟里,新用户还在往 A 打——打不通,然后客户端重试、超时、再解析。这个"打不通"不会产生应用侧错误,因为请求根本没到应用(第 25 篇说过:看不见的失败最贵)
- 会话存储是单机房 Redis,B 侧没有。切换脚本里有一条
redis-failover,但 B 侧的 Redis 是从 A 侧异步复制的,丢了大约 40 秒的数据(RPO 40s),而且复制延迟在故障前 2 分钟已经涨到 8 秒 - 连接池没有预热。B 侧的应用 Pod 是"Ready"的,但它的数据库连接池是空的(min-idle 只有 2),流量一上来要建 800 个连接,MySQL 那边
max_connections是 500,剩下的排队。这就是"CPU 38% 但 503"的来源——CPU 不忙,连接不够
02:52,三件事都处理完了:TTL 提前下调、会话存储改成两边都写、连接池预热脚本执行完。投诉停止增长。
03:01,最后一条投诉进来,事故结束。47 分钟。
事后复盘时,最扎心的一句话不是"我们丢了 40 秒数据",而是:
切换脚本用了 4 分钟,用户影响了 47 分钟。差的 43 分钟,不在脚本里。
这 43 分钟不是"处理故障"的时间,是"发现预案的假设是错的"的时间。而每一次真容灾,都是在第一次发现这个假设错误的情况下进行的。
二、RTO 承诺:全公司最像数字、最不像事实的一个数
事故之后,第一件该做的事是把 RTO 承诺拿出来核对。这家公司的承诺写在 SLA 里:
核心服务,单机房故障,RTO ≤ 15 分钟,RPO ≤ 1 分钟。
实测:47 分钟,RPO 40 秒(那次运气好)。
2.1 RTO 承诺是编出来的六种方式
几乎每一份 RTO 承诺都能归到下面六种之一。它们都"看起来是算出来的",实际上都是填上去的。
| # | 编法 | 典型话术 | 破在哪里 |
|---|---|---|---|
| 1 | 从厂商抄 | "云厂商说双AZ RTO 15 分钟" | 厂商的 RTO 是它的基础设施恢复,不含你的应用、数据、DNS |
| 2 | 按最优路径估 | "手工切过,8 分钟" | 手工切是在已知故障、有人盯着、B 侧干净的情况下做的 |
| 3 | 只算 T_restore | "恢复 800G 要 40 分钟" | 第 23 篇已经算过这件事:T_restore ≈ D / R 只是七段里的一段 |
| 4 | 把演练当验收 | "演练过了,通过" | 多数演练的道具是"B 侧健康、DNS 已热身、数据无差",这不是演练,是彩排 |
| 5 | 不算"人"的时间 | "5 分钟切完" | 从告警响到有人按下回车,凌晨要 8 分钟 |
| 6 | 算的是"服务起来",不是"用户可用" | "Pod 全 Ready" | Pod Ready ≠ 有连接 ≠ 缓存热 ≠ 会话在 ≠ DNS 生效 |
第 6 种是这次事故撞到的那个。它也是最普遍的一种,因为它和所有监控面板的口径一致——所有监控都说"服务在跑",没有一块面板说"用户能用"。
2.2 把 RTO 拆成十段,才知道 15 分钟是假的
第 22 篇把 MTTR 拆成 MTTD / MTTA / MTTK / MTTR-R 四段,第 23 篇把数据层恢复拆成七段。容灾切换的 RTO 需要拆得更细,因为它跨越了"组织、网络、应用、数据"四层。
这是这次事故的实测拆解:
| # | 段 | 定义 | 实测 | 能不能压缩 | 靠什么压 |
|---|---|---|---|---|---|
| 1 | T_detect | 系统发现"A 侧整片不可用" | 90s | 能 | 主动探测(合成拨测),别等用户投诉 |
| 2 | T_page | 告警到人接 | 60s | 能 | 值班表 + 电话升级(第 8 篇) |
| 3 | T_decide | 人判断"这是要切的故障" | 120s | 半能 | 切换判据树 + IC 预授权(第 22 篇) |
| 4 | T_script | 切换脚本执行 | 252s | 能 | 并行化、去掉人工确认点 |
| 5 | T_dns | 新记录在绝大多数客户端生效 | 360s | 只能提前 | 出事前把 TTL 从 300 降到 30 |
| 6 | T_conn | 连接池重建到稳态 | 420s | 能 | 预热 + min-idle + 慢启动 |
| 7 | T_cache | 缓存预热到命中率可用 | 300s | 能 | 主动预热 + 热点优先 |
| 8 | T_session | 会话可用 | 180s | 能 | 会话共置或改为无状态 token |
| 9 | T_write | 写入方向切换 + 一致性确认 | 150s | 半能 | 中继日志追平再提升 |
| 10 | T_verify | 确认"用户能用" | 240s | 能 | 业务级拨测,不是指标看板 |
串行加起来是 2172 秒,36 分钟;实测 47 分钟,差的 11 分钟是这些段之间的间隙(人切窗口、确认、找日志)。
注意第 5 段:DNS 生效时间是 6 分钟,比整个切换脚本还长。 这就是下一节要讲的东西。
2.3 一个可以当天就算出来的公式
RTO_实际 ≈ Σ T_i (串行,绝大多数团队的真实形态)
RTO_目标 ≈ max(T_dns, T_conn, T_cache, T_session, T_write) + T_detect + T_decide + T_verify
(并行 + 提前动作,这才是"多活"与"冷备"的分水岭)两行只差一个 Σ 和 max,含义天差地别:
- Σ 形态:每一段都等上一段完成。备份机房就是这样——它的 RTO 永远是所有段之和,因为你必须等数据追平才能开始预热,等预热完才能放流量
- max 形态:各段同时进行,因为 B 侧本来就热着。多活的全部价值就在这个 max 上
所以"我们做了多活,RTO 能到 5 分钟"这句话,只在下式成立时才不是吹牛:
max(T_dns, T_conn, T_cache, T_session) + 300s ≤ 承诺值把这次事故的数字代进去:max(360, 420, 300, 180) + 90 + 120 + 240 = 870 秒 = 14.5 分钟。
也就是说,如果 B 侧本来就是热的(连接池、缓存、会话都在),这套系统理论上是能兑现 15 分钟的。它之所以变成 47 分钟,是因为那三段没并行、也没预热——Σ 形态伪装成了 max 形态。
三、四种容灾形态:选型的边界只有三条
行业里通常把容灾分成四档。每一档的宣传话术都很漂亮,但真正的选型边界只有三条:数据能不能双写、切换能不能自动、成本能扛几倍。
| 形态 | 平时 B 侧在干什么 | RPO | RTO(真实) | 成本 | 什么时候它是对的 |
|---|---|---|---|---|---|
| 冷备 Cold Standby | 关机或最小集群 | 上次备份 | 小时级 | 1.05x | 强合规要求、业务能接受小时级中断、数据量巨大 |
| 温备 Warm Standby | 最小集群在跑,数据在复制,不放流量 | 复制延迟 | 15~60 分钟 | 1.3x | 绝大多数公司应该停在这:RTO 要求分钟到小时、不想改应用 |
| 热备 Hot Standby(主备/单写) | 全量集群在跑,随时可接,只有一个写入口 | 复制延迟 | 2~15 分钟 | 1.8~2x | RTO 要求分钟级、写入一致性要求高(金融账务) |
| 双活/多活 Active-Active | 两边都接流量、两边都能写 | 取决于冲突消解 | 秒~分钟 | 2.5~3.5x | 业务必须全球低延迟,或者单边容量不够撑全量 |
这张表有四个坑,每一个都在真实项目里踩过:
坑一:把"温备"当"热备"卖。 温备的 B 侧在跑,看板上是绿的,于是有人觉得它就是热备。区别只在连接池和缓存:温备的连接池是冷的、缓存是空的,而这两件事恰好是 T_conn 和 T_cache 的来源。判断标准不是"B 侧 Pod 起没起",是"B 侧能不能在 60 秒内接住 100% 流量并且 p99 不翻倍"。
坑二:以为多活就是"两边都能写"。 双写的代价不是技术复杂度,是业务规则复杂度:同一个用户的两笔订单落在两个机房,库存怎么扣?同一张优惠券被用了两次怎么算?余额扣减要不要跨机房加锁?这些问题没有通用解,每一个都要业务方签字。
坑三:忘记"多活"的意思是每一活都要能扛全量。 50/50 分流的双活,一边挂了另一边瞬间要接 100%。如果 B 侧的容量只按 50% 规划(这是省钱的本能),那它就不是多活,是延迟发生的单点。第 21 篇的水位线在这里有个直接推论:多活架构下,常态水位必须 ≤ 50%,才谈得上"能接住对面"。
坑四:容灾等级和业务等级不匹配。 一套系统里,订单是 SEV-1、评价是 SEV-3,但两者用了同一套容灾方案——结果评价服务的双活成本摊在了订单服务上,而当订单真要切时,评价服务那边的写冲突反而拖住了整个切换。容灾等级要按业务分级给,不要按系统统一切。
3.1 一个实用的选型判据
选型不从"哪种最先进"开始,从三个问题开始,答案唯一的组合只有一两个:
Q1. 业务能接受多长的中断?(<= 5min / <= 30min / <= 4h / 可以一天)
-> <= 5min:热备或双活;<= 30min:温备;其余:冷备
Q2. 能接受丢多少数据?(0 / <= 1min / <= 5min / 可以回到昨天)
-> 0:只有双活 + 同步复制 + 冲突消解;<= 1min:半同步热备;其余:异步复制
Q3. 单机房的容量余量够不够撑全量?(够 / 不够)
-> 不够:先做容量(第 21 篇),再谈多活。否则切换等于把故障转移给另一个更小的机房Q3 是这三问里最容易被跳过、也最容易致命的一个。第 25 篇讲"过载",这一篇要把它接上:切换不是把流量搬走,是把流量搬进一个从来没接过它的系统。 一个常态跑 50% 的系统,第一次吃到 100% 时,最容易先倒的不是 CPU,是连接池、线程池和下游。
四、切换的真实下限:五个"不在脚本里"的东西
第二节拆出的十段里,前四段(检测、呼叫、判断、脚本)都能靠工程和流程压缩。真正构成下限的,是后面这五个物理事实。它们不受你的努力影响,只受你提前多久开始准备影响。
4.1 DNS TTL:唯一"提前做才有用"的一项
DNS 是容灾切换里最大的谎言来源。原因很直白:你改了记录,但全世界几十万个递归 DNS 还在用旧的。
各级缓存的实际行为:
| 层级 | 谁在缓存 | 你控制得了吗 | 生效时间 |
|---|---|---|---|
| 权威 DNS | 你 | 是 | 立即 |
| 递归 DNS(运营商/LocalDNS) | 运营商、公司网络 | 否 | TTL 到期,或被人手动 flush |
| 操作系统/浏览器 DNS 缓存 | 用户 | 否 | 各自 TTL(Windows 有独立缓存) |
| 客户端连接复用 | 你的 App / SDK | 半能 | 直到连接被关闭 |
| HTTP/2 连接 | 网关 | 半能 | 长连接不断,就一直打到旧 IP |
所以"切换 5 分钟、TTL 30 分钟"的组合意味着:脚本 5 分钟跑完,但最长 30 分钟内有相当比例的客户端还认为系统在 A 机房。 这段时间里你的切换是"半成品",而所有监控面板都显示切换完成了——因为监控的探针是新解析的。
可以当天就做的三件事:
# 1) 查清楚你真实的 TTL,别信配置,以线上为准
dig +nocmd app.example.com A +noall +answer
# 例:app.example.com. 300 IN A 1.2.3.4 <- 300 秒,不是你在配置里写的 30
# 2) 看一下域名被缓存了多久(不同递归行为不同,抽查几个)
for ns in 8.8.8.8 223.5.5.5 114.114.114.114; do
echo "== $ns"; dig @$ns app.example.com A +noall +answer +stats | grep -E "IN\s+A|Query time"
done
# 3) 检查有没有第二级 DNS 缓存(很多公司内部还有一套 LocalDNS)
# 这些缓存不会因为权威 TTL 短就跟着短正确做法只有一条:把下调 TTL 变成切换预案的第一步,而不是切换本身的一步。
T-0 正常运行,TTL = 300s
T-提前 检测到 A 侧"高风险"(不是坏了,是有坏的可能:告警、演练、大促前)
-> 把 TTL 下调到 30s,等一个老 TTL 周期(300s)
T-切换 这时切换,DNS 30 秒内大面积生效这就是为什么"演练"比"预案"值钱:演练的目的不只是验证脚本,更是验证"你有能力在出事之前 5 分钟就把 TTL 降下来"。而绝大多数团队在真出事时,第一反应是切流量,不是降 TTL——因为降 TTL 不会让任何面板变绿。
一个细节:TTL 也不宜设成 5 秒。太短会放大权威 DNS 的 QPS(成本 + 限流风险),并且在故障时权威 DNS 本身也可能是瓶颈。30~60 秒是常用区间。
4.2 连接池:切换瞬间最硬的物理约束
B 侧从"Ready"变成"能接 100% 流量",中间隔着一个连接池。
这次事故里最典型的数字:B 侧 40 个 Pod,每个 min-idle=2,所以切换前 B 侧只有 80 条数据库连接。切换瞬间并发上来了,每个请求要建连接,而建一条 MySQL 连接(TCP + 三次握手 + 认证 + 会话初始化)在本网络里是 2~4ms,跨机房是 8~15ms。800 个连接按每 Pod 20 并发建,每个 Pod 要 20 × 3ms = 60ms——听起来不长。
问题在下游:max_connections=500。第 500 个连接之后,剩下的请求开始排队等 connect_timeout(默认 10 秒)。这就是"CPU 38%、错误率 0.01%、用户超时"的全部解释。
三个修法,按性价比排:
# 1) 把 min-idle 抬到"切换瞬间够用"的量级(最有效、最便宜)
# 不是按常态 QPS 算,是按"对面挂了、我接全量"那一刻算
spring.datasource.hikari.minimum-idle=20 # 常态
spring.datasource.hikari.maximum-pool-size=50 # 每个 Pod,合计不超过 DB max_connections / 0.8
# 2) 连接池预热:应用启动/切换时主动建连,而不是等第一个请求# warmup.sh —— 切流前跑,2 分钟内把连接建到目标水位
TARGET=${TARGET_CONN:-600} # 目标连接水位(= 期望 QPS × 平均持有时间,见第 23 篇的 conn-budget)
DB="mysql-b.internal:3306"
cur=0
while [ $cur -lt $TARGET ]; do
# 并发建连:用固定数量进程,每个持有一批连接不放(sleep 保持)
for i in $(seq 1 50); do
( mysql -h"${DB%:*}" -P"${DB#*:}" -e "SELECT SLEEP(120)" & ) 2>/dev/null
done
cur=$((cur+50))
sleep 5
done
echo "warmed to $cur"注意这不是"压力测试",是把连接提前建好并保持住——它和压测的区别是:压测关心 QPS,预热只关心"连接数已经在那了"。用压测代替预热是常见错误:压测会跑业务逻辑,产生真实订单,还可能把下游打爆。
3) 慢启动:给 B 侧一个爬坡期。 不是一上来给 100%,而是 30 秒内从 10% 爬到 100%。K8s 里可以用网关的权重渐进:
# 用网关权重做慢启动,30 秒爬完
apiVersion: v1
kind: ConfigMap
metadata:
name: failover-ramp
data:
steps: |
- at: 0s weightB: 10
- at: 10s weightB: 30
- at: 20s weightB: 60
- at: 30s weightB: 100爬坡的意义是让 T_cache 和 T_session 也一起被"喂"起来,而不是在 0 秒时被一记闷棍打懵。
4.3 缓存预热:命中率不是你可怜它,它就会好的
B 侧的 Redis 是空的(或者几乎空的)。流量一过来,命中率从 0 开始爬。缓存击穿的经典后果是打穿数据库——而此时数据库刚提升为主,还在追中继日志。
预热要解决三个问题:预热什么、按什么顺序、怎么验证。
# precache.sh —— 从 A 侧的访问日志里取热点 key(A 挂了就从最近的归档取)
# 原则:只热 top 20% 的 key,它们通常覆盖 80% 的请求
set -euo pipefail
TOP_N=${TOP_N:-20000}
# 1) 从日志/慢查询/CH 里导出热点 key(这里用离线的 key 统计结果)
# 真实项目里这一步是读一张 daily_hot_keys 表,别在事故时去扫日志
psql -At -c "select key from daily_hot_keys order by hits desc limit $TOP_N" > /tmp/hot.txt
# 2) 分批灌入,控制速率,别把源库打挂
BATCH=500
while mapfile -t keys && [ ${#keys[@]} -gt 0 ]; do
# 从只读副本或对象存储里的快照取值,不要直连刚提升的主库
for k in "${keys[@]}"; do echo "GET $k"; done | redis-cli --pipe >/dev/null
sleep 0.2
done < <(split -l $BATCH /tmp/hot.txt - /tmp/hotpart. && cat /tmp/hotpart.*)
# 3) 验证命中率,不达标就继续热,达标才放全量
redis-cli info stats | grep -E "keyspace_hits|keyspace_misses"这里有个容易忽略的点:预热的源不能用"刚提升为主的那个库"。它正在追日志、正在被写入,再给它加一层扫描压力,就是在切换的第一分钟里制造第二个故障。预热数据的来源应该是:A 侧的无状态快照、对象存储里的缓存转储、或者 B 侧自己的从库。
4.4 会话:为什么"用户被迫重新登录"是最大的错误率来源
这次事故最大的投诉量来自"登录转圈",根因是会话。
会话有四种存法,容灾属性完全不同:
| 方案 | 切换时发生什么 | 用户体验 | 适用 |
|---|---|---|---|
| 会话在单机房 Redis | B 侧无此会话,全部失效 | 全部要重新登录 | ❌ 不该用于容灾场景 |
| 会话在 Redis,但 A/B 双写或跨机房同步 | 基本无损(延迟范围内) | 无感 | ✅ 首选 |
| 会话在 Cookie(加密 token) | 无状态,B 侧能验 | 无感 | ✅ 首选(但撤销难度高) |
| 会话粘性(Sticky Session) | 切换后粘性规则失效,同一个用户可能落到不同实例 | 部分失效、部分正常 | ⚠️ 只在单机房内有效 |
"全部要重新登录"这件事的代价被严重低估了。 它不只是体验问题,它是一次流量尖峰:所有离线用户在同一分钟内涌向登录接口,而登录接口要写库、发短信、校验密码 hash(CPU 密集)。这次事故里登录 QPS 在 02:26 冲到常态的 11 倍——切换制造了一次自 DDoS。
所以容灾设计里有一句话:会话不是"顺便带上"的,它是切换剧本里的第一等公民。 要么让它无状态,要么让它跨机房同步,没有第三条路。
4.5 写入方向:中继日志追平之前,切过去是自伤
这一条和第四节的其余四条并列,是因为它决定 RPO,而 RPO 又是回切的前提。
主从切换的核心纪律只有一条:先追平,再提升;追不平,就明确说"要丢多少"。
# 切库前必须看的三个数(MySQL)
mysql -e "SHOW REPLICA STATUS\G" | grep -E "Seconds_Behind|Relay_Master_Log_File|Exec_Master_Log_Pos"
# ^复制延迟 ^已收到的位点 ^已执行的位点
# 判断"还有多少没执行完":收到的位点和执行的位点之间就是缺口
# 缺口对应的字节数 / 回放速率 = 还需要的秒数三个数分别意味着:
Seconds_Behind:一个粗糙且经常骗人的指标。它在 SQL 线程空闲时会显示 0,哪怕其实还有大量中继日志没执行Relay_Master_Log_File+Exec_Master_Log_Pos:真正准确的缺口。用 A 侧的 binlog 位置减去它,才是"差多少"- 第三个数是"回放速率":缺口字节数 ÷ 回放速率 = 追平所需时间。第 23 篇的
T_replay ≈ binlog 时长 × k在这里直接用得上
经验值:缺口超过 30 秒的 binlog,就该停止等待,转入"接受损失 + 明确告知"的路径。 因为继续等下去是在用 RTO 换 RPO,而这个交换必须由业务方决定,不是值班同学的现场判断(第 22 篇的 IC 决策权就是为这种时刻准备的)。
五、"切换 5 分钟、TTL 30 分钟":一句听起来很专业的话,实际是自欺
把前面几节的数字摆在一起,这句话就露出原形了。
假设某团队宣称:
双机房热备,切换 RTO 5 分钟。
同时他们的 DNS TTL 是 600 秒(10 分钟)——这是很常见的默认值。
真出事时会发生什么:
| 时间 | 脚本 | 用户 |
|---|---|---|
| T+0 | 开始执行 | 全部打在 A(挂了) |
| T+5m | 完成,报告成功 | 仍然全部打在 A(DNS 未生效) |
| T+10m | — | 第一批用户开始解析到 B |
| T+20m | — | 大部分用户到达 B,但 B 的连接池、缓存是冷的 |
| T+30m | — | 服务开始恢复正常 |
定义上的 RTO 是 5 分钟,用户感知的 RTO 是 30 分钟。这两个数之间没有任何人可以辩解的空间——因为用户只会看到第二个数。
而且这个自欺有一个恶性的自我强化:因为"RTO 5 分钟"看上去很好,团队就不会去优化 DNS、不会去预热、不会去买"提前降 TTL 的自动化"。指标越好看,动作越不会做。
5.1 三种诚实的写法
如果只能选一种,选第三种:
写法 A(自欺):RTO 5 分钟。
写法 B(拆开):切换脚本 5 分钟,DNS 生效 10 分钟,端到端可用 30 分钟。
写法 C(可验收):端到端 RTO ≤ 15 分钟,前提条件:故障前 TTL 已降至 30s、
B 侧连接池常态保持 ≥ 60% 目标水位、缓存命中率预热后 ≥ 85%。
任一前提不成立,RTO 降级为 30 分钟并在状态页标注。写法 C 的好处是它把 RTO 变成了一个带前提的承诺,而前提是可检查的——出事时值班的人按前提条一条勾,就知道自己该按 15 分钟还是 30 分钟对外说。这才是 RTO 该有的样子:不是一句期望,是一张查表。
六、切换之后的 12 分钟:真正的危险区
这次事故里有一个数字,事后看最惊心:切换到故障结束的 47 分钟里,最危险的其实是 02:21 到 02:33 这 12 分钟。
这 12 分钟里,系统处于一种没有任何面板描述的状态:
- A 侧:挂了(指标缺失)
- B 侧:指标全部漂亮(因为它只看自己)
- 用户:正在经历最差体验
"部分切换"是分布式系统里最难观测的状态,因为两边的监控都是"各自视角",而没有任何一方在看"端到端"。
6.1 用业务级拨测替代指标看板
切换验证必须问"用户能不能完成一件事",而不是"服务指标好不好"。
# probe.sh —— 业务级拨测,切换后每 10 秒跑一次,跑 15 分钟
# 关键:走公网 DNS、走完整链路、真的下一个小额订单(影子用户)
set -uo pipefail
FAIL=0
probe_once() {
local sid
# 1) 从公网 DNS 解析(这是唯一能反映"用户拿到哪个 IP"的方式)
mid=$(dig +short app.example.com A | head -1)
[ -z "$mid" ] && { echo "DNS_FAIL"; return 1; }
# 2) 用新解析出来的 IP 走完整链路:登录 -> 查价 -> 下单 -> 查单
sid=$(curl -s -m 5 -X POST "https://$mid/api/login" \
-d '{"u":"probe_bot","p":"'"$PROBE_PW"'"}' | jq -r .session)
[ "$sid" = "null" ] && { echo "LOGIN_FAIL"; return 1; }
curl -s -m 3 -H "Cookie: sid=$sid" "https://$mid/api/quote?sku=probe-001" >/dev/null || { echo "QUOTE_FAIL"; return 1; }
curl -s -m 5 -H "Cookie: sid=$sid" -X POST "https://$mid/api/order" \
-d '{"sku":"probe-001","qty":1,"shadow":true}' | jq -e '.orderId' >/dev/null || { echo "ORDER_FAIL"; return 1; }
echo "OK $(date +%T) mid=$mid"
}
for i in $(seq 1 90); do
probe_once || FAIL=$((FAIL+1))
sleep 10
done
echo "failures=$FAIL / 90"三个要点,每一个都对应这次事故的一段:
- 必须走公网 DNS。从集群内部探针探测 B 侧是看不出来 DNS 缓存的(第 5 段)
- 必须真的登录、真的下单。只探 healthz 会 100% 通过,因为 healthz 不碰会话、不碰连接池
shadow: true:拨测订单要能被业务侧过滤掉,否则一次切换会造出几百个真实订单
6.2 切换清单:一份必须在切换过程中同步勾选的表
# failover-checklist.yaml —— 每次切换都要打一遍,打完入档
failover:
meta:
triggered_by: "SEV-1 / A机房不可用"
decided_by: "oncall-han"
started_at: "02:17"
pre: # 切换前(如果有时间,哪怕 60 秒)
- ttl_lowered_before_10m: false # 这次没做,就是 360s 的代价
- target_side_warmed: false # 连接池冷
- write_direction_confirmed: true
- team_notified: true
during:
- script_completed: "02:21:12"
- dns_resolved_to_target: false # 用公网 dig 验证,不是看控制台
- pool_warmed: false
- cache_hit_ratio: 0.0
- session_ok: false
post: # 切换后 15 分钟
- probe_business_flow_ok: false
- error_rate_user_side: 0.31
- tickets_from_cs: 118
- decision: "keep_going" # keep_going / rollback / partial
rollback_ready:
- write_can_revert: true
- reverse_replication_ok: false # 回切的前提
- data_loss_window_s: 40这份表的三个 false 就是那 43 分钟的来源。如果切换过程中强制要求勾这张表,02:24 就会有人发现"dns 没生效、池子是冷的",而不是等到 02:25 客服转投诉。
一份清单的价值不在它记录了成功,在于它强迫记录失败。第 19 篇的 20 项发布自检清单是这个逻辑,第 22 篇的事故准备清单也是这个逻辑。
七、回切:比切换难一倍的那一半
几乎所有容灾预案都只写了"怎么切过去",没写"怎么切回来"。而现实是:切换之后,你迟早必须切回来。
7.1 为什么回切更难
| 维度 | 切换(A→B) | 回切(B→A) |
|---|---|---|
| 数据方向 | 单向,B 侧提升为主 | 要先把 B 的数据反向同步回 A,再确认追平 |
| 业务状态 | B 侧是干净的 | B 侧已承接了 N 小时的真实写入,A 侧不知道 |
| 人 | 凌晨、清醒度最低,但有心理准备 | 往往在工作时间,"修好了就切回来吧",最缺紧迫感也最缺警惕 |
| 时间压力 | 有 | 没有(这反而更危险,容易在没准备好时"顺手切一下") |
| 演练覆盖 | 有(虽然质量参差) | 几乎没有 |
| 失败后果 | 服务不可用 | 数据分叉(两边都有写入,无法合并) |
第 6 行是最要命的。切换失败是"还是不能用",回切失败是"数据两边都有了,谁也不知道该信哪边"。 前者是可用性问题,后者是正确性问题,而后者的修复成本经常是前者的十倍。
7.2 回切的四个硬前提
#!/usr/bin/env bash
# rollback-gate.sh —— 回切前必须四条全绿,任何一条红了就不许动
set -uo pipefail
fail=0
# 1) A 侧的故障真的修好了吗?—— 不是"恢复了",是"修了"
# 连续 30 分钟无故障、且根因已定位(不是"自己好了")
uptime_ok=$(curl -s -m 3 http://a-internal/healthz | jq -r .uptime_sec)
[ "${uptime_ok:-0}" -ge 1800 ] || { echo "A: uptime < 30min"; fail=1; }
# 2) 反向复制建立了吗?—— 这是回切和多活的分水岭
# B 是当前主,A 必须先成为 B 的从,并且追平
lag=$(mysql -h a-internal -e "SHOW REPLICA STATUS\G" | awk '/Seconds_Behind/{print $2}')
[ "${lag:-999}" -le 5 ] || { echo "A: replica lag ${lag}s > 5s"; fail=1; }
# 3) 反向复制稳定了吗?—— 至少要跑过一个"最长事务"的时长
# 这次设 10 分钟,且期间 lag 不得出现尖峰
spike=$(mysql -h a-internal -e "select max(lag) from replica_lag_history where t > now()-interval 10 minute" -N)
[ "${spike:-999}" -le 30 ] || { echo "A: lag spike ${spike}s"; fail=1; }
# 4) 在 A 侧做一次业务拨测了吗?—— 用读流量做,不要放写
./probe.sh --target a-internal --readonly --iterations 30 || { echo "A: probe failed"; fail=1; }
# 5) 数据一致性抽样:随机 200 条近 24h 写入的记录,B 有、A 也应该有(反同步后)
./consistency-sample.sh --since "24h" --limit 200 || { echo "data: sample mismatch"; fail=1; }
[ $fail -eq 0 ] && echo "ROLLBACK ALLOWED" || echo "ROLLBACK BLOCKED ($fail checks)"四条之外,还要加一条流程前提:
- 回切必须是一次"有窗口的变更",不能是"顺手切一下"。它要走第 19 篇的发布流程:有窗口、有观察期、有回滚预案(回滚到"继续留在 B")、有 IC 签字。这次事故之后,这家公司的规定是:任何回切动作必须在工单里留痕,且必须有一个"不同意就留在 B"的备选。
7.3 数据分叉的三种防法
回切最坏的结果是分叉。三种防法,按成本排序:
- 单向写入纪律(最便宜):切换期间绝对不允许 A 侧有任何写入路径可用——不是"没人会写",是"写不了"。做法是把 A 侧的写入入口在网络层断开,或者把 A 的应用降级为只读模式(它连的那条链路本来就断着,但恢复时必须先让它以只读身份启动)
- 写入 fencing token(中等):每次切换生成一个单调递增的 epoch,写请求带上 epoch,旧 epoch 的写被拒绝。这需要存储层支持(Redis/etcd 可以做,MySQL 要在应用层实现)
- 合并(最贵,几乎不该用):双边写入后做冲突合并。只有在多活架构下才需要,而且必须有业务级冲突消解规则(谁的出价高、谁的时间戳新、积分取哪个)
绝大多数团队的正确答案是第 1 种,因为它不需要改代码,只需要改流程和权限。而它经常被跳过,理由恰恰是"我们不会在切换期间写 A 侧的"——所有数据分叉事故都始于这句话。
八、把容灾变成可以被验证的东西:切换演练的四个档
第 20 篇讲混沌工程时说过,演练的价值在于"首次可用率"。容灾演练有同样的结构,但它的门槛更高,因为它必须动"整个机房"。
四个档,按代价递增,建议一年走完前三档:
| 档 | 做法 | 代价 | 能验证什么 | 不能验证什么 |
|---|---|---|---|---|
| L1 桌面推演 | 拉齐人,按剧本走一遍,不出手 | 2 小时 | 决策链、命令、谁改哪 | 所有技术项 |
| L2 单组件切换 | 切 DNS / 切 DB / 切 Redis,逐个做 | 每次 1 小时 | 每段的下限(TTL、追平、预热) | 段与段之间的相互影响 |
| L3 影子切换 | 把 5% 真实流量切到 B,跑 30 分钟再切回 | 半天 | 预热、会话、连接池在真实流量下的表现 | 100% 流量下的容量 |
| L4 全量切换 | 凌晨全量切到 B,业务在 B 上跑一整天,次日晚回切 | 2 天 + 一次停服窗口 | 全部 | 唯一不能验证的:A 真挂了(还是会有人偷偷看 A) |
L4 是唯一能验证"回切"的档位,也正因为如此,它是唯一能真正兑现 RTO 承诺的档位。一年至少一次。
L4 的关键设计有三个坑:
- 必须真的"假装 A 不存在":不断开的话,值班会下意识去 A 看日志。做法:把 A 的访问权限临时收走(或者至少让它变得很不方便)
- 必须走完整的回切:"切过去很顺,就算了"是最常见的失败——回切才是这一档的核心。很多团队做了三年 L4,从来没做过回切
- 必须有业务方在场:容灾演练的验收标准不是"切成功了",是"业务跑了一天没出问题"。只有业务方能判定这一条
8.1 演练的产出:一份数字,不是一份报告
drill:
id: DR-2026-Q4-01
level: L4
scenario: "A 机房整体不可用(真断网,非模拟)"
started: "2026-11-12 01:00"
timeline:
detect_s: 88
page_s: 55
decide_s: 140
script_s: 231
dns_effective_s: 42 # ← TTL 提前降到 30s 之后
pool_steady_s: 96 # ← 预热之后
cache_hit_85pct_s: 180
business_probe_ok_s: 300
rto_actual_min: 11.5 # 端到端,业务可完成
rto_promised_min: 15
rpo_actual_s: 12
rpo_promised_s: 60
blockers: # 这份清单是最有价值的产出
- "网关配置未随切换同步,40% 请求打到旧实例(出现 2 次)"
- "客服话术未更新,对外说了'系统维护'引发二次投诉(1 次)"
- "预热的 key 集合是 7 天前的,命中率只到 71%(1 次)"
rollback:
window_start: "2026-11-13 02:00"
reverse_repl_lag_max_s: 8
consistency_sample: "200/200 一致"
rollback_ok: true
rollback_min: 26 # ← 回切比切换慢一倍以上,这是常态最后那个数字 rollback_min: 26 是这份记录里最重要的:回切永远是切换的 2~3 倍耗时,因为它多了"建立反向复制 + 追平 + 一致性抽样"三段。任何声称"回切和切换一样快"的预案,都是没做过回切的人写的。
九、五个度量:容灾到底有没有用
| 度量 | 计算方式 | 目标 | 为什么是它 |
|---|---|---|---|
| 端到端 RTO(实测 P90) | 演练记录里"业务可完成"的时间点 − 故障开始时间 | ≤ 承诺值 | 不是脚本耗时,是用户可用的时间 |
| RTO 兑现率 | min(实测, 承诺) / 实测 | ≥ 0.9 | 兑现率低于 0.9 说明承诺从来没对上过(第 23 篇同款逻辑) |
| 切-回切耗时比 | T_rollback / T_switch | ≤ 3,且有数 | 没做过回切的团队这一格是空的,空白本身就是结论 |
| DNS 提前下调覆盖率 | 演练/故障中"切换前已降 TTL"的比例 | = 100% | 这一项 0% 的团队,RTO 一定被 DNS 拖着 |
| 切换期间用户侧错误率峰值 | 从边缘网关/客户端上报看,不看集群内指标 | < 常态 10 倍 | 集群内指标在部分切换时会"全绿"(第六节) |
第三个度量值得单独说一句:很多团队的这一格是空的,而空着比数字难看更糟——它说明回切从来没被走过。
十、容灾切换自检清单(24 项)
形态与承诺
- 1. 每个核心服务都标了容灾形态(冷备/温备/热备/双活),不只是"有容灾"
- 2. RTO / RPO 承诺写明了前提条件(写法 C),不是光秃秃一个数字
- 3. 容灾等级按业务分级给,不是全系统一刀切
- 4. 多活架构下,常态水位 ≤ 50%,且经过压测验证"单边能扛全量"
切换路径
- 5. 切换脚本能一条命令跑完,且没有交互式确认(凌晨没人陪你按 y)
- 6. 切换脚本的执行结果可判定(不是"没报错"就算成功)
- 7. DNS TTL 在出事前就能下调,并且有"降 TTL → 等一个周期 → 切"的固定流程
- 8. 切换判据树写清楚了"什么情况该切",不需要现场讨论
- 9. IC 有"直接切"的预授权,不需要层层审批(第 22 篇)
B 侧的热度
- 10. B 侧连接池
min-idle按"接全量"算,不按常态算 - 11. 有预热脚本,能在切流前把连接建到目标水位
- 12. 缓存预热脚本存在,且预热的 key 集合是每天更新的(不是半年前导的)
- 13. 会话要么无状态,要么跨机房同步,不能是单机房 Redis
- 14. 慢启动/爬坡配置存在(10% → 100%,30 秒内)
数据
- 15. 切库前检查
Relay_Master_Log_File+Exec_Master_Log_Pos,不是只看Seconds_Behind - 16. "缺口超过 N 秒就停止等待"的阈值写进预案,且由业务方签字
- 17. 切换期间 A 侧写入在网络层断开(不只是"约定不写")
- 18. 有 fencing token 或者明确的"降级为只读启动"流程
验证与回切
- 19. 业务级拨测走公网 DNS、真的登录、真的下单(影子单)
- 20.
failover-checklist.yaml在切换过程中实时勾选并留档 - 21. 回切有独立门禁脚本,四条硬前提全绿才允许动
- 22. 回切走变更流程(有窗口、有观察期、有"留在 B"的备选)
- 23. 一年至少一次 L4 全量切换演练,且包含回切
- 24. 演练产出的是时间线数字 + blockers 清单,不是一份"通过"报告
十一、六个真实踩过的坑
坑一:max_connections 是全局的,但每个人只算自己那一份。 B 侧的 40 个 Pod 每个觉得"我要 50 个连接",40 × 50 = 2000,而库上配的是 500。切换瞬间的排队,就是各 pod 的算盘之和超过了库的账本。第 23 篇的 conn-budget 不等式要在切换场景下重新算一遍——因为切换时是"所有 Pod 同时要满"。
坑二:健康检查通过 ≠ 能接流量。healthz 只看进程活着,readyz 只看依赖能连上。两者都不检查"连接池水位"和"缓存命中率"。切换之后最该看的指标是 pool_active / pool_max 和 cache_hit_ratio,而不是 up。
坑三:把 TTL 设成 5 秒"反正便宜"。 权威 DNS 的 QPS 会和 TTL 成反比。一个日均 100 万次解析的域名,TTL 从 300 降到 5,权威侧 QPS 涨 60 倍,撞上云厂商的免费额度限流,DNS 自己成了故障点。30~60 秒是甜点。
坑四:切换之后立刻回切。 "A 修好了,切回来吧"——最典型的分叉起点。切换和回切之间必须有足够长的稳定期(经验值:至少 1 小时,最好过一个业务高峰),否则你不知道 B 侧是不是也有问题,而回切会把两个未知叠在一起。
坑五:会话粘性和多活天然冲突。 粘性会话意味着"某个用户固定落在某台机器上"。跨机房之后,这个映射表要么不存在、要么过期。多活架构下不要用粘性会话做正确性保证(可以用于性能优化,但业务逻辑不能依赖它)。
坑六:演练时所有人都盯着屏幕,真出事时没人盯。 演练的成功率高,很大一部分来自"所有人都在场"。真故障时,值班只有一个人,其余人在睡觉。所以演练应该加一条:主切换人必须是当周值班的那个人,他不许提前知道准确时间。
十二、从零开始:四阶段落地顺序
和前面几篇一样,顺序比技术更重要。
阶段一:把承诺改成真的(1~2 周) 不需要改任何架构。把 RTO 拆成十段,实测每一段(没有数据就填"未知"),重写成写法 C 那种带前提的承诺。产出:一张表 + 一份诚实的对外口径。 价值:多数团队在这一步就会发现自己承诺的 15 分钟是 40 分钟。
阶段二:把五个下限补上(1~2 个月) DNS 提前降 TTL 的流程、连接池 min-idle + 预热脚本、缓存预热(含每日热点更新)、会话去单点、切库检查脚本。这些全是配置和脚本,不改业务代码。 价值:这一阶段通常能把实测 RTO 压掉一半以上(这次事故里,五条全做的话 47 分钟能到 15 分钟左右)。
阶段三:把切换变成一次有清单的变更(1 个月)failover-checklist.yaml 进预案,业务级拨测上线(走公网 DNS、真下单),切换走 IC 预授权。演练从 L2 做到 L3。 价值:切换从"赌"变成"执行",出错能被清单抓住。
阶段四:把回切补上(持续) 反向复制、回切门禁脚本、L4 全年演练(含回切)、写入 fencing。 价值:这一步做完,容灾才算闭环。在此之前,你们有的只是"能过去",没有"能回来"。
四阶段的关键依然是顺序:先改承诺,再补下限,再上清单,最后练回切。 反过来——先做 L4 演练,再发现 RTO 承诺是假的、TTL 没降过、回切没设计过——演练会变成一次公开的尴尬,然后被"下次再说"搁置三年。
十三、和前面几篇的关系
- 第 19 篇(变更安全体系):本篇是它的极端形态——切换是一次影响面最大的变更。第 19 篇的五道闸门(分级、灰度、判别指标、回滚、预算门禁)在这里全部要用,而且要更严:因为切换的"回滚"就是回切,成本高一个量级
- 第 20 篇(混沌工程):本篇的 L2/L3 演练是它的延续。第 20 篇注入的是"组件故障",本篇注入的是"一整边故障"。同样是爆炸半径控制,只是半径大了一整圈
- 第 21 篇(容量规划):多活的前提是"单边能扛全量",这就是第 21 篇的水位线问题。没有容量余量的多活,是把故障从机房 A 转移到容量更小的机房 B
- 第 22 篇(事故指挥):切换是 IC 的决策,且需要预授权。本篇给了"该不该切"的判据树,第 22 篇给了"谁有权切"
- 第 23 篇(数据库 SRE):第七节回切的四个硬前提,全部来自第 23 篇的数据层视角(
T_restore、binlog 缺口、可恢复性验证)。本篇是第 23 篇在"方向"上的延伸:那边讲怎么恢复,这边讲恢复到哪一边 - 第 24 篇(AI 排障):切换期间是 AI 最不该给建议的时刻——它能看见 B 侧的指标全绿,看不见 DNS 缓存和会话丢失。第 24 篇的"视野由工具清单决定"在这里有最坏的例子
- 第 25 篇(限流熔断降级):切换的瞬间是 B 侧最接近过载的时刻。第 25 篇的四道闸门在这里必须提前配好——否则你切过去的不是容灾,是把 100% 流量灌进一个冷启动的系统
- 仓库里的《异地多活架构设计》《基于 K8s 的容灾方案》:那两篇讲的是"架构长什么样",本篇讲的是"切换那天会发生什么"。两者不冲突,但它们回答的是完全不同的问题
写在最后
如果这篇只带走一句:
容灾的能力不是"能切过去",是"切过去之后敢不敢切回来"。
只有单向的切换不叫容灾,叫一次性的赌博。因为真出事之后,你必须在某个时刻回到常态,而那个时刻恰恰是数据最容易分叉、人最容易松懈、演练覆盖最少的时候。
如果只带走一个动作:
打开你的 DNS 记录,看 TTL 是多少,再看你的 RTO 承诺是多少。如果 TTL > RTO,你的 RTO 就是假的。
这个检查 30 秒就能做完,它不需要任何架构改动、不需要任何预算、不需要任何人签字。而它会告诉你一件很具体的事:你承诺的 15 分钟里,有多少已经注定被 DNS 吃掉了。
切换脚本那 4 分钟是漂亮的。而容灾真正的功夫,全在那 43 分钟里——在 TTL 提前降下来的那个决定里,在连接池的 min-idle 从 2 改成 20 的那行配置里,在会话从单机房 Redis 挪出去的那个改造里,以及在那个没人愿意写的回切预案里。
本文是 SRE 实战系列第 26 篇,第三循环第二篇。上一篇问的是"系统在被压垮的那几秒里会不会自己刹车",这一篇问的是"当一整边不可用了,系统能不能安全地搬过去,再安全地搬回来"。下一篇会回到更日常的一件事:Toil——那些"每天都要做、做了没成长、不做就出事"的工作,怎么量化、怎么削减。