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、一次时钟跳变就够。
为什么“通知旧主下台”不可靠:
- 分区时通知送不到,旧主根本不知道有人接管了。
- 旧主可能只是卡住(GC、IO hang),恢复后仍按本地状态继续当主。
- 通知和接收都是应用层逻辑,任何一侧的 bug 都会让通知静默失效。
三种手段,作用点完全不同:
| 手段 | 做什么 | 压住哪一段 | 失效条件 |
|---|---|---|---|
| 租约 | 主节点的写权限有时间上限,新主必须等它过期才能上任(21.11 领导者租约) | 把双主窗口压到“剩余租约 + 时钟偏差” | 时钟跳变、租约续期实现有 bug |
| fencing token | 每次当选领一个单调递增的 token(epoch / term),被保护的资源只接受 ≥ 当前 token 的写 | 让窗口内的旧主写入直接失败 | 存储不校验 token,等于没有 |
| STONITH | 直接断电 / 关机 / 关网卡,物理上让旧主写不了 | 窗口归零 | 电源、带外管理不可用 |
🔑 “关掉旧主”比“通知旧主”可靠,因为前者不依赖对方配合。一个已经 GC 停顿 20 秒的进程,收不到通知、也不会执行通知;但断电它立刻停。这就是 STONITH(Shoot The Other Node In The Head)存在的理由,不是运维暴力,是它绕过了“对方还活着但不可信”这个最坏情况。
fencing 有两个硬要求,缺一个就白做:
- token 必须单调递增且持久化。重启后从 1 开始重算的 token 会让旧主“复活”,比不做还危险。
- 校验必须在被保护资源那一侧。写在网关、存储、锁服务里都行,写在客户端里不行——客户端的校验可以被旧版本绕过。
窗口的公式很简单:
双主窗口 = 旧主剩余租约 + 新旧主时钟偏差
租约 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。所以回切窗口通常按小时排,而不是按分钟。
四条硬规矩:
- 切换单上写回切日期,默认 7 天,到期不写延期理由就升级。
- 回切前必须做数据比对(行数 + 校验和 + 关键业务口径),不做比对的回切是赌博。
- 回切也要演练,而且要演练“回切失败再切回去”——这是最容易漏的分支(56.1 混沌工程)。
- 回切要有独立开关,不能复用切换脚本的反向参数——反向参数在真实故障里几乎总有一个方向是错的。
自动重启:把 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。
三个读数:
- 检测 1500ms,端到端不可写 3750ms:多出来的 2250ms 全花在“等租约到期”。这就是判决的代价——它不产生日志、不跑脚本,只是等。想快,只能缩 TTL 或接受双写,没有第三条路。
- “只探测就切”快 2000ms,代价是 102 条双写、2520ms 的双主窗口:为了省 2 秒,在新主已经对外服务的同时,旧主还在以约 40 条/秒的速度继续接受写入(100 QPS × 40% 未切换的客户端),这些脏写只能靠人肉对账。102 条里只要有一条是余额或库存,对账成本就远超那 2 秒。
- 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、缓存、下游)出问题,重启只会让雪崩更快。此时应冻结重启、切到降级或摘流量。