35.9 自动切换与故障恢复

一句话

自动切换 = 检测 + 判决 + 执行 + 收敛四段串联,慢的从来不是脚本,而是“判决需要多数派同意”和“切换后没有人验证”。

四段时间预算

端到端不可用时间不是“脚本跑了多久”,而是四段相加:

T_total = T_detect + T_decide + T_act + T_conv
阶段 谁在决定 典型值 压缩手段 压过头的代价
T_detect 检测 心跳 / 探针 / 健康检查周期 × 连续失败次数 500ms × 3 次 = 1.5s 缩短周期、提高探测并发 误判率上升,抖动就切(35.4 故障检测)
T_decide 判决 租约到期、多数派确认 3–10s,常见 4s 缩短 TTL 续租流量翻倍,时钟偏差占比变大,双写风险上升
T_act 执行 摘流量、排空连接、选主、拉起进程 250ms(热备)–60s(冷启动容器 + JVM) 预热池、蓝绿、连接排空脚本 常驻成本翻倍
T_conv 收敛 缓存预热、连接建立、JIT、DNS 生效 5–30s 预热、提前降 TTL、连接池预建 预热本身可能打爆下游

两组真实组合:

热备 + 已有进程:1.5s + 3.5s + 0.25s + 5s  ≈ 10.3s
冷备 + 容器拉起:1.5s + 3.5s + 45s   + 30s ≈ 80s

差距全在 T_act 和 T_conv,可占 87%。所以优化切换速度的第一刀不是“把脚本写快点”,而是消掉 T_act(预热)和 T_conv(验证)。压缩 T_decide 是最后才动的一刀,因为它买的是安全性。

无状态切换 vs 有状态切换

维度 无状态(HTTP 服务、网关) 有状态(MySQL 主、Kafka 分区 leader、Redis 主)
切换动作 摘流量 → 切 → 放流量,任意实例等价 加 fencing → 追数据 → 提升 → 客户端重连
数据丢失 0 取决于复制方式:半同步 ≈ 0,异步 = 复制延迟
幂等性 天然可切,重复请求无害 必须防双写,写路径要带 token 校验
收敛代价 秒级(连接建立 + 缓存) 分钟级(从库追日志、分区 leader 迁移、客户端重连风暴)
最常踩的坑 DNS 未生效、连接池指向旧地址 旧主仍在写、从库落后导致丢数据

有状态切换里最容易被忽略的是**“谁替客户端判断新主”**。数据库切换完了,客户端连接池里还躺着 200 条到旧主的连接,它们不会自己断——要等 TCP 超时(默认 15 次重传,分钟级)或者被服务端 RST。所以有状态切换后面必须跟一次“强制断连 + 重连风暴”的预算。

Kafka 是个好例子:分区 leader 由 controller 从 ISR 里挑,正常切换不丢数据;一旦把 unclean.leader.election.enable 打开(默认 false),允许非 ISR 副本当 leader,切换就变成“用丢数据换可用”。这是一个显式的开关,不是一个可以忽略的默认值。Redis 主从是异步复制,哨兵切换时必然丢掉最后一段写入,RPO 等于切换瞬间的复制延迟。

脑裂与 fencing

脑裂只有一句话:两个节点同时认为自己是主,都接受写。它不需要“网络全断”,一次单向分区(22.1 故障类型)、一次 GC 停顿 20s、一次时钟跳变就够。

为什么“通知旧主下台”不可靠:

  1. 分区时通知送不到,旧主根本不知道有人接管了。
  2. 旧主可能只是卡住(GC、IO hang),恢复后仍按本地状态继续当主。
  3. 通知和接收都是应用层逻辑,任何一侧的 bug 都会让通知静默失效。

三种手段,作用点完全不同:

手段 做什么 压住哪一段 失效条件
租约 主节点的写权限有时间上限,新主必须等它过期才能上任(21.11 领导者租约) 把双主窗口压到“剩余租约 + 时钟偏差” 时钟跳变、租约续期实现有 bug
fencing token 每次当选领一个单调递增的 token(epoch / term),被保护的资源只接受 ≥ 当前 token 的写 让窗口内的旧主写入直接失败 存储不校验 token,等于没有
STONITH 直接断电 / 关机 / 关网卡,物理上让旧主写不了 窗口归零 电源、带外管理不可用

🔑 “关掉旧主”比“通知旧主”可靠,因为前者不依赖对方配合。一个已经 GC 停顿 20 秒的进程,收不到通知、也不会执行通知;但断电它立刻停。这就是 STONITH(Shoot The Other Node In The Head)存在的理由,不是运维暴力,是它绕过了“对方还活着但不可信”这个最坏情况。

fencing 有两个硬要求,缺一个就白做:

  1. token 必须单调递增且持久化。重启后从 1 开始重算的 token 会让旧主“复活”,比不做还危险。
  2. 校验必须在被保护资源那一侧。写在网关、存储、锁服务里都行,写在客户端里不行——客户端的校验可以被旧版本绕过。

窗口的公式很简单:

双主窗口 = 旧主剩余租约 + 新旧主时钟偏差

租约 4s、时钟偏差 800ms,窗口就是最坏 4.8s 量级的“两个主都自认为合法”。fencing 不缩短这个窗口,它让窗口内的旧主写入被拒绝——所以 fencing 的收益不是“少一个主”,而是“脏写变成可检测的失败”。

自动化等级 L0–L4

级别 谁决策 谁执行 典型形态 适合 主要风险
L0 人 人 值班打电话,照着 runbook 敲命令 低频、可停机、灾备演练 深夜无人,MTTR 以小时计
L1 系统告警 人确认后一键 告警 + 一键切换脚本 大多数业务的起点 人成了瓶颈,确认环节常被跳过
L2 系统 系统切,人兜底 检测 + 租约 + fencing 自动切,人验证与回切 核心交易链路 切了但没人验证,故障“看起来恢复了”
L3 系统 系统切 + 系统回切 带冷却、健康校验、双跑对比的自动回切 平台化成熟、实例多 抖动导致来回切,震荡比故障更伤
L4 系统 自愈闭环 切换 + 隔离坏实例 + 自动扩容 + 自动降级 大规模、多租户 自动化放大错误配置,一次错全站错

有状态的主库切换,我的判断是停在 L2:自动切,但回切必须有人按。因为“旧主到底恢复没有”这个判断,系统拿不到足够证据——从库延迟、数据一致性、隐患是否消除,都需要人看。把回切也自动化,等于赌“健康检查能代表数据正确”。参考备份形态的选择(36.3 冷备 / 温备 / 热备):热备让 L2 变得便宜,冷备只能停在 L1。

切换后的次生故障

切换成功的那一刻,故障才刚开始。四类次生问题都有明确的量级:

次生故障 机理与数字 对策
流量回灌 10 万客户端同时重连,SYN 队列(net.core.somaxconn 常见默认 4096)溢出,丢 SYN → 客户端 1s 后重试,再来一波 重连加 jitter(0–30s 随机)、令牌桶放行、服务端慢启动
缓存全冷 5000 QPS,命中率从 95% 掉到 0,DB 从 250 QPS 涨到 5000 QPS(20 倍) 预热脚本、请求合并、DB 前先限流
连接风暴 500 实例 × 200 连接 = 10 万连接;MySQL max_connections 默认 151,直接 Too many connections 按 DB 容量反算池大小,切流分批放行
DNS / VIP 未生效 TTL 60s → 切完 60s 内仍有流量打旧地址;TTL 300s 就是 5 分钟 切之前先把 TTL 降到 30–60s 并等过一个周期,或用服务发现推送(8.9 四层与七层负载均衡)

再加两条不在表里但同样致命:

  • 时钟:新主时钟慢 500ms,所有租约、TTL、令牌过期时间全部错位。
  • 监控缺口:新实例没接 dashboard 和告警,故障从“可见”变成“不可见”,恢复判定只能靠用户投诉。

把这些流量先关进小盒子再逐步放大,比一次性全放安全得多(35.8 故障隔离):按 1%、5%、25%、100% 四档放,每档停留 2–5 分钟看错误率和 P99 再决定下一档。

回切比切换更难

“切得过去、切不回来”不是段子,是三个不对称叠出来的:

不对称 切换时 回切时
驱动力 有明确故障,不做就挂 没有故障,只有“应该回去”,永远可以明天再说
数据 新主从旧主追平后接手 新主已经写了数据,回切要倒灌,还可能冲突
状态 目标端预热过 又要冷一次:缓存、连接、JIT 全部重来
心理 有人盯着,有 deadline 没人盯,于是“临时切换”变永久

回切的时间成本可以直接算:200GB 数据做一次全量校验,在 100MB/s 的校验带宽下约 34 分钟;再加上一次完整的 T_conv。所以回切窗口通常按小时排,而不是按分钟。

四条硬规矩:

  1. 切换单上写回切日期,默认 7 天,到期不写延期理由就升级。
  2. 回切前必须做数据比对(行数 + 校验和 + 关键业务口径),不做比对的回切是赌博。
  3. 回切也要演练,而且要演练“回切失败再切回去”——这是最容易漏的分支(56.1 混沌工程)。
  4. 回切要有独立开关,不能复用切换脚本的反向参数——反向参数在真实故障里几乎总有一个方向是错的。

自动重启:把 MTTR 从 10 分钟压到 1 分钟

“自动切换” 讲的是主备/多活;还有一类更常见的自动恢复是单机重启——进程假死、线程池打满、GC 卡住,重启往往比排查快。

方式 从发现到恢复 问题
人工介入 10 分钟以上(接警、登录、确认、执行) 半夜没人接、误操作风险
自动重启 1 分钟内,快则秒级 治标不治本,可能掩盖根因

三条判据决定它会不会变成事故放大器:

判据 取值(微博口径) 为什么
触发条件 接口平均耗时超阈值(不是进程存在与否) 进程活着但假死时,“存活探测”完全看不出来
防抖 10s 采一个点,连续 5 个点里 ≥3 个点超阈值才动手 单点抖动就重启,会把正常机器一起重启,形成雪崩
同时重启比例 不超过总量的 10% 一次性重启太多,剩余机器扛不住流量,重启变成“人为故障”

还有两条容易被忽略的护栏:

⚠️ 必须设最大重启次数。同一天重启 5 次以上,说明这是 OOM/内存泄漏这类“重启无法解决”的问题,应转人工排查根因;否则会变成一台机器整夜重启、监控上看起来“一直在自愈”。

⚠️ 重启前后都要摘流量。重启中的实例不该有流量进来(35.3 冗余 的优雅上下线),重启完成后先放少量流量观察错误率,再全量。

实测:租约 + fencing 的切换状态机

场景:租约 4000ms、每 1000ms 续租,t=2500ms 网络分区(最后一次续租成功在 t=2000),看门狗 500ms 探一次、连续 3 次失败判失联,旧主本地时钟比真实慢 800ms,写入 100 QPS。

package main

import "fmt"

// 全部时间单位:毫秒
const (
	leaseTTL    = 4000 // 租约有效期
	renewEvery  = 1000 // 主节点续租间隔
	partitionAt = 2500 // 网络分区发生的时刻(最后一次续租成功是 t=2000)
	clockSkew   = 800  // 旧主本地时钟比真实慢 800ms,它以为租约到 6800 才过期
	probeEvery  = 500  // 看门狗探测间隔
	probeFailN  = 3    // 连续失败几次判定失联
	execDelay   = 250  // 判决之后:选主 + 摘流量 + 拉起进程的耗时
	clientTTL   = 3000 // 40% 客户端要等本地缓存 TTL 过期才切到新主
	writeQPS    = 100  // 写入速率
	simEnd      = 10000
)

var (
	detectAt   = int64(partitionAt + probeEvery*probeFailN) // 4000:看门狗判定失联
	leaseUntil = int64(2000 + leaseTTL)                     // 6000:租约到期
	oldStopAt  = leaseUntil + clockSkew                     // 6800:旧主本地租约到期
)

type write struct {
	t     int64
	by    string
	token int64
	ok    bool
}

// storage 只认单调递增的 token:旧主的写入会被拒。
type storage struct{ maxToken int64 }

func (s *storage) apply(w *write) bool {
	if w.token < s.maxToken {
		return false
	}
	s.maxToken = w.token
	return true
}

type result struct {
	doubleAccepted int
	rejectedByOld  int
	dblWindowMs    int64
	firstDbl       int64
	lastDbl        int64
	unavailableMs  int64
	writes         []write
}

// run 跑一次切换仿真。useLease=false 表示“只靠探测就切”,完全不理会旧租约。
func run(useLease, fencing bool) result {
	var (
		r         result
		st        storage
		promoteAt int64
	)
	st.maxToken = 1 // 旧主上任时拿到的 token
	if useLease {
		promoteAt = leaseUntil + execDelay // 必须等旧租约过期,新主才被允许上任
	} else {
		promoteAt = detectAt + execDelay // 探测判定失联就切,赌旧主已经停了
	}
	r.unavailableMs = promoteAt - partitionAt

	promoted := false
	for i := 0; i*1000/writeQPS < simEnd; i++ {
		t := int64(i * 1000 / writeQPS)
		if fencing && t >= promoteAt && !promoted {
			st.maxToken = 2 // 上任即领新 token(term 单调递增),存储从此只认 ≥2
			promoted = true
		}
		w := write{t: t}
		switch {
		case t < promoteAt:
			w.by, w.token = "L1", 1
		case t < promoteAt+clientTTL && i%5 < 2:
			w.by, w.token = "L1", 1 // 40% 客户端还打旧主(服务发现缓存没过期)
		default:
			w.by, w.token = "L2", 2
		}
		if w.by == "L1" && t >= oldStopAt {
			continue // 旧主本地租约到期,自己停写了
		}
		switch {
		case w.by == "L1" && !fencing:
			w.ok = true // 无 fencing:存储不校验 token,谁写都收
		default:
			w.ok = st.apply(&w)
		}
		if w.ok {
			if w.by == "L1" && t >= promoteAt {
				r.doubleAccepted++
				if r.firstDbl == 0 {
					r.firstDbl = t
				}
				r.lastDbl = t
			}
		} else {
			r.rejectedByOld++
		}
		r.writes = append(r.writes, w)
	}
	if r.doubleAccepted > 0 {
		r.dblWindowMs = r.lastDbl - r.firstDbl + 1000/writeQPS
	}
	return r
}

func main() {
	fmt.Println("时间线(租约 4000ms / 续租 1000ms / 探测 500ms × 连续 3 次失败)")
	fmt.Printf("  %5dms  最后一次续租成功,租约到 %dms\n", 2000, leaseUntil)
	fmt.Printf("  %5dms  网络分区,旧主续租全部超时\n", partitionAt)
	fmt.Printf("  %5dms  看门狗判定失联(T_detect=%dms)\n", detectAt, detectAt-partitionAt)
	fmt.Printf("  %5dms  租约到期,新主才被允许上任(T_decide=%dms)\n", leaseUntil, leaseUntil-partitionAt)
	fmt.Printf("  %5dms  选主+摘流量+拉起完成,新主写 token=2(T_act=%dms)\n", leaseUntil+execDelay, execDelay)
	fmt.Printf("  %5dms  旧主本地时钟才认为租约到期(时钟慢 %dms)\n\n", oldStopAt, clockSkew)

	type row struct {
		name string
		r    result
	}
	rows := []row{
		{"只探测,不等租约", run(false, false)},
		{"等租约,无 fencing", run(true, false)},
		{"等租约 + fencing", run(true, true)},
	}
	fmt.Printf("%-22s %8s %10s %12s %14s\n", "方案", "双写条数", "旧主被拒", "双主窗口", "端到端不可写")
	for _, x := range rows {
		fmt.Printf("%-22s %8d %10d %10s %12s\n", x.name, x.r.doubleAccepted,
			x.r.rejectedByOld, fmt.Sprintf("%dms", x.r.dblWindowMs),
			fmt.Sprintf("%dms", x.r.unavailableMs))
	}

	fmt.Println("\n第一条路(不等租约)的双写长这样:")
	n := 0
	for _, w := range rows[0].r.writes {
		if w.by == "L1" && w.ok && w.t >= detectAt+execDelay {
			fmt.Printf("  t=%5dms  旧主 token=%d  →存储接受(新主已经在服务了)\n", w.t, w.token)
			n++
			if n >= 4 {
				break
			}
		}
	}
	fmt.Println("\n第三条路(等租约 + fencing)同样的写:")
	n = 0
	for _, w := range rows[2].r.writes {
		if w.by == "L1" && !w.ok && w.t >= leaseUntil+execDelay {
			fmt.Printf("  t=%5dms  旧主 token=%d  →存储拒绝(maxToken=2)\n", w.t, w.token)
			n++
			if n >= 4 {
				break
			}
		}
	}
	fmt.Printf("\n检测 %dms 远快于租约 %dms;租约把窗口压到时钟偏差 %dms,fencing 再压到 %dms。\n",
		detectAt-partitionAt, leaseUntil-partitionAt, clockSkew, rows[2].r.dblWindowMs)
}

GO111MODULE=off go run main.go 的真实输出:

时间线(租约 4000ms / 续租 1000ms / 探测 500ms × 连续 3 次失败)
   2000ms  最后一次续租成功,租约到 6000ms
   2500ms  网络分区,旧主续租全部超时
   4000ms  看门狗判定失联(T_detect=1500ms)
   6000ms  租约到期,新主才被允许上任(T_decide=3500ms)
   6250ms  选主+摘流量+拉起完成,新主写 token=2(T_act=250ms)
   6800ms  旧主本地时钟才认为租约到期(时钟慢 800ms)

方案                         双写条数       旧主被拒         双主窗口         端到端不可写
只探测,不等租约                    102          0     2520ms       1750ms
等租约,无 fencing                22          0      520ms       3750ms
等租约 + fencing                 0         22        0ms       3750ms

第一条路(不等租约)的双写长这样:
  t= 4250ms  旧主 token=1  →存储接受(新主已经在服务了)
  t= 4260ms  旧主 token=1  →存储接受(新主已经在服务了)
  t= 4300ms  旧主 token=1  →存储接受(新主已经在服务了)
  t= 4310ms  旧主 token=1  →存储接受(新主已经在服务了)

第三条路(等租约 + fencing)同样的写:
  t= 6250ms  旧主 token=1  →存储拒绝(maxToken=2)
  t= 6260ms  旧主 token=1  →存储拒绝(maxToken=2)
  t= 6300ms  旧主 token=1  →存储拒绝(maxToken=2)
  t= 6310ms  旧主 token=1  →存储拒绝(maxToken=2)

检测 1500ms 远快于租约 3500ms;租约把窗口压到时钟偏差 800ms,fencing 再压到 0ms。

三个读数:

  1. 检测 1500ms,端到端不可写 3750ms:多出来的 2250ms 全花在“等租约到期”。这就是判决的代价——它不产生日志、不跑脚本,只是等。想快,只能缩 TTL 或接受双写,没有第三条路。
  2. “只探测就切”快 2000ms,代价是 102 条双写、2520ms 的双主窗口:为了省 2 秒,在新主已经对外服务的同时,旧主还在以约 40 条/秒的速度继续接受写入(100 QPS × 40% 未切换的客户端),这些脏写只能靠人肉对账。102 条里只要有一条是余额或库存,对账成本就远超那 2 秒。
  3. fencing 不缩短不可写时间(还是 3750ms),它把 22 条双写变成 22 条被拒:这 22 条写必须由客户端重试到新主,否则就是丢单。所以 fencing 必须配“写失败可重试 + 幂等键”,否则你只是把脑裂换成了静默丢数据。

🔑 切换的四个数里,只有 T_act 是“工程问题”,其余三个都是“权衡问题”:检测快慢换误判,判决快慢换双写,收敛快慢换打爆下游。切换剧本写得再漂亮,也改不了这三个交换比。

反模式

反模式 后果
只靠“通知旧主下台”,没有 fencing 分区或 GC 停顿时旧主继续写,脑裂静默发生
健康检查只看端口通 端口通但 DB 连不上、线程池已满,切过去照样不可用
DNS TTL 设 300s 切完 5 分钟内仍有流量打旧地址,等于切换推迟了 5 分钟
切换后立刻放开 100% 流量 缓存全冷 → DB 打穿 20 倍 + 10 万连接同时建连
没有回切预案 切得过去切不回来,临时方案永久化,成本永久化
切换脚本没人练过 第一次执行就在生产,参数、权限、依赖全是新的
自动回切没有冷却 健康检查抖一下就来回切,震荡比故障更伤
切完不验证业务成功率 端口活着、机器在跑,但错误率 30%,故障“看起来恢复了”
只看机器状态不看数据落后 主库切了但从库落后 30s,切过去直接丢 30 秒数据

Q5 自动重启的防抖为什么要“5 个点里 ≥3 个点超阈值”,而不是“连续 3 个点”?

▸ 答案:两种规则的差别在对偶发尖峰的容忍度。5 取 3 允许中间有一个点回落(GC 抖动、瞬时尖峰),仍然判定为持续异常;连续 3 个点 只要被打断一次就重新计数,在噪声大的系统上会迟迟不触发,故障拖成事故。代价是 5 取 3 对“刚开始异常”的反应慢一个采样周期(10s 级),这个延迟换来的是避免误重启。

自检

Q1 你们的切换脚本实测只跑 300ms,但从故障发生到业务恢复用了 40 秒。这 40 秒花在哪?按四段拆一下。

▸ 答案:脚本只是 T_act 的一部分。典型拆法:T_detect 1.5s(探测周期 × 连续失败)+ T_decide 3.5s(等租约到期或多数派确认)+ T_act 0.3s(脚本本身)+ T_conv 30s+(缓存冷、连接建立、DNS 生效、监控确认)。要缩短,先砍 T_conv(预热 + 提前降 TTL)和 T_act(热备),最后才动 T_decide,因为它的收益是用双写风险换来的。

Q2 为什么说“关掉旧主”比“通知旧主”可靠?给出一个通知必然失效的场景。

▸ 答案:通知要求对方还活着并能正确执行,旧主在 GC 停顿 20 秒、IO hang、单向分区(能收不能发)时都收不到或执行不了通知,而且恢复后会按本地状态继续当主。STONITH 直接断电/关网卡,不依赖对方配合,窗口直接归零。代价是需要带外管理(IPMI/PDU)和更硬的运维纪律。

Q3 有 fencing 了,是不是就不会脑裂了?fencing 之后还剩什么问题?

▸ 答案:脑裂本身(两个主都自认为主)还在,fencing 只是让旧主的写在存储侧失败。剩下的问题有三个:① 被拒的写如果客户端不重试就是丢单,所以必须配幂等键 + 重试;② token 必须持久化且单调,重启从 1 开始算等于没有 fencing;③ 校验必须在被保护资源侧(存储、网关、锁),客户端自校验会被旧版本绕过。

Q4 切换后你立刻放开全部流量,5 分钟内会发生什么?给出至少两个数字。

▸ 答案:① 缓存全冷,命中率从 95% 掉到 0,5000 QPS 全打 DB(250 → 5000,20 倍),DB 先慢后挂;② 500 实例 × 200 连接 = 10 万连接同时建,MySQL 默认 max_connections 151,直接 Too many connections;③ DNS TTL 60s 未过期,前 60 秒还有流量打旧地址。正确做法是分档放量(1%/5%/25%/100%),每档看错误率和 P99 再决定,同时预热缓存与连接池。

Q6 自动重启为什么要限制“同时重启不超过 10%”?

▸ 答案:因为重启期间实例不提供服务,等于瞬间减少容量。一次性重启太多,剩余实例要承接全部流量,很可能直接过载,把“自愈”变成“人为故障”。限制比例的另一个作用是给观察留窗口:每批重启后看错误率与延迟,异常就停手。

Q7 什么情况下必须停止自动重启、转人工?

▸ 答案:两类信号。① 频次:同一实例一天内重启超过设定次数(如 5 次),说明是 OOM/内存泄漏/死锁这类重启解决不了的问题,继续重启只是在掩盖根因;② 范围:多个实例同时触发重启,说明是共性依赖(DB、缓存、下游)出问题,重启只会让雪崩更快。此时应冻结重启、切到降级或摘流量。

关联