切换命令执行了,为什么没人敢回切
支付高峰前,订单主库所在单元 sh-a 出现存储故障。值班同学按手册冻结写入、提升 sh-b 的副本、修改 DNS,四十分钟后业务恢复。两个小时后 sh-a 修好,团队准备回切,却发现新主库已经接受了一千多笔支付回调;没有反向同步,切回去就会丢掉这些写。于是系统以“临时”的双倍成本运行了三个月。
这个场景里有两个常见错误直觉。第一,把 RTO 理解成“执行提升命令用了多久”。实际 RTO 从故障发生开始计时,包含检测、决策、冻结写入、提升、DNS 生效、缓存预热和业务校验;提升往往不是最长的一段。第二,把回切当成切换的反向操作。提升之后新主已经产生写入,回切必须先补齐这些写入,否则就是一次有损切换。
站内 多活容灾 讨论过容灾架构的分层,本节不重复拓扑,只把切换拆成三个可计算的对象:RTO 的关键路径、RPO 的序号差、以及回切前必须满足的状态条件。所有跨机房数字都只是计划值或示例值,真实 RTO/RPO 必须在目标环境演练中测量。
RTO 是依赖图上的最长路径
一次切换可以写成若干步骤:检测、通知、冻结、提升、DNS 生效、预热、校验、放量。步骤之间有依赖,能并行的不要串行。例如提升数据库和等待 DNS TTL 可以同时进行,预热必须等提升和 DNS 都完成,校验可以和预热并行,最后放量要等两者都结束。
RTO 不是所有步骤耗时之和,而是从开始到所有步骤完成的最长路径。示例计划中,检测 15 秒、通知 5 秒、冻结 10 秒、提升 20 秒、DNS 40 秒、预热 15 秒、校验 10 秒、放量 5 秒;提升与 DNS 并行,预热等两者,校验只等提升。关键路径是 detect → notify → freeze → dns → warm → open,合计 90 秒。此时把提升从 20 秒优化到 10 秒,RTO 仍然是 90 秒,因为 DNS 的 40 秒才是瓶颈。
这个计算的价值在于避免优化错对象。想缩短 RTO,优先看关键路径上的步骤:提前降低 DNS TTL、预建连接池和缓存、把检测从人工确认改成自动判据。非关键路径上的优化只降低成本,不改变 RTO。
RPO 是已确认与已持久化的差
RPO 不是“复制延迟的平均值”。客户端收到成功响应,代表这笔写在主库已经提交;副本可能还没应用。故障发生时,真正会丢的是“已确认但副本未持久化”的那一段。用一个确认点表示:AckedSeq 是最后告知客户端成功的序号,AckedAt 是那个时刻;ReplicaSeq 是副本最后持久化的序号,ReplicaAt 是对应时刻。RPO 事务数是两者之差,RPO 时间窗口是时间戳之差。
三个边界必须显式报错,而不是算出一个看着合理的数。副本序号大于已确认序号,说明选错了副本或序号空间不一致;副本时间戳晚于确认时间,说明时钟偏移,时间窗口为负;两者都会让 RPO 看起来比实际更小。对资金系统,宁可报错停止切换,也不要带着错误的 RPO 估算继续。
RPO 事务数和时间窗口回答的是不同问题:前者决定要补多少笔,后者决定补这些写需要多久。演练时应同时记录,不能只报一个“延迟 1 秒”。
切换状态机与回切门槛
状态机只有四个阶段:NORMAL 正常服务,READ_ONLY 冻结写入,PROMOTED 副本已提升但未放量,SERVING 新主已承载流量。允许的迁移是 NORMAL → READ_ONLY → PROMOTED → SERVING;READ_ONLY 可以取消回到 NORMAL,这是提升前的安全退出。PROMOTED 之后没有直接回 NORMAL 的边,因为新主可能已经接受写入。
回切门槛单独判断:在 READ_ONLY 阶段随时可以取消;在 PROMOTED 或 SERVING 阶段,只有新主写入数为零,或者反向同步已经完成,才允许回切。这个条件不能靠“我记得没写”来判断,必须由状态机记录写入计数和反向同步标记。回切是另一次切换,不是一次状态回退。
可运行实现
实现包含三部分:带迁移表和回切门槛的切换状态机、把确认点换算成 RPO 的函数、把步骤依赖图换算成 RTO 与关键路径的求解器。求解器用拓扑排序加最长路径,遇到环、未知依赖、重复步骤或负耗时直接报错,避免一份错误的演练计划算出漂亮的数字。
// file: internal/platform/dr/switchover.go
// tier: L1
// verify: go test ./internal/platform/dr/ -count=1
package dr
import (
"fmt"
"sort"
"time"
)
// Phase is one stage of a controlled switchover.
type Phase string
const (
// Normal serves reads and writes from the original primary.
Normal Phase = "NORMAL"
// ReadOnly freezes writes at the original primary.
ReadOnly Phase = "READ_ONLY"
// Promoted has promoted the replica but not opened client traffic.
Promoted Phase = "PROMOTED"
// Serving sends clients to the new primary.
Serving Phase = "SERVING"
)
var allowed = map[Phase][]Phase{
Normal: {ReadOnly},
ReadOnly: {Normal, Promoted},
Promoted: {Serving},
Serving: {},
}
// Switch tracks the phase and the facts that decide whether a rollback is
// safe. It does not perform I/O; the runbook drives it.
type Switch struct {
phase Phase
writesOnNewPrimary int64
reverseSynced bool
}
// NewSwitch returns a switch in Normal.
func NewSwitch() *Switch {
return &Switch{phase: Normal}
}
// Phase returns the current phase.
func (s *Switch) Phase() Phase {
return s.phase
}
// Apply moves to next if the transition is allowed.
func (s *Switch) Apply(next Phase) error {
for _, candidate := range allowed[s.phase] {
if candidate == next {
s.phase = next
return nil
}
}
return fmt.Errorf("dr: transition %s -> %s not allowed", s.phase, next)
}
// RecordWrite counts a write accepted by the new primary. It is only valid
// after promotion, when the new primary can accept client writes.
func (s *Switch) RecordWrite() error {
if s.phase != Promoted && s.phase != Serving {
return fmt.Errorf("dr: write recorded in phase %s", s.phase)
}
s.writesOnNewPrimary++
return nil
}
// MarkReverseSynced records that writes accepted by the new primary were
// copied back to the original primary.
func (s *Switch) MarkReverseSynced() {
s.reverseSynced = true
}
// WritesOnNewPrimary returns the number of writes accepted after promotion.
func (s *Switch) WritesOnNewPrimary() int64 {
return s.writesOnNewPrimary
}
// RollbackAllowed reports whether traffic may return to the original primary.
// Before promotion a rollback is just leaving read-only mode. After promotion
// every write accepted by the new primary must exist on the old one, either
// because there were none or because reverse replication completed.
func (s *Switch) RollbackAllowed() error {
switch s.phase {
case ReadOnly:
return nil
case Promoted, Serving:
if s.writesOnNewPrimary == 0 || s.reverseSynced {
return nil
}
return fmt.Errorf("dr: rollback blocked by %d unsynced writes on new primary", s.writesOnNewPrimary)
default:
return fmt.Errorf("dr: no rollback path from phase %s", s.phase)
}
}
// AckPoint is the last write acknowledged to a client together with the
// replica position observed at that moment.
type AckPoint struct {
AckedSeq uint64
AckedAt time.Time
ReplicaSeq uint64
ReplicaAt time.Time
}
// RPO returns how many acknowledged transactions the replica may be missing
// and the width of the wall-clock window they were acknowledged in.
func (a AckPoint) RPO() (uint64, time.Duration, error) {
if a.ReplicaSeq > a.AckedSeq {
return 0, 0, fmt.Errorf("dr: replica seq %d ahead of acked seq %d", a.ReplicaSeq, a.AckedSeq)
}
window := a.AckedAt.Sub(a.ReplicaAt)
if window < 0 {
return 0, 0, fmt.Errorf("dr: replica timestamp ahead by %s, check clock skew", -window)
}
return a.AckedSeq - a.ReplicaSeq, window, nil
}
// Step is one runbook action with an estimated duration.
type Step struct {
Name string
Duration time.Duration
After []string
}
// Plan is a dependency graph of switchover steps.
type Plan struct {
Steps []Step
}
// RTO returns the earliest duration in which every step can finish and the
// critical path that determines it.
func (p Plan) RTO() (time.Duration, []string, error) {
if len(p.Steps) == 0 {
return 0, nil, fmt.Errorf("dr: empty plan")
}
steps := make(map[string]Step, len(p.Steps))
for _, step := range p.Steps {
if step.Name == "" {
return 0, nil, fmt.Errorf("dr: step with empty name")
}
if _, dup := steps[step.Name]; dup {
return 0, nil, fmt.Errorf("dr: duplicate step %q", step.Name)
}
if step.Duration < 0 {
return 0, nil, fmt.Errorf("dr: step %q has negative duration", step.Name)
}
steps[step.Name] = step
}
indegree := make(map[string]int, len(steps))
successors := make(map[string][]string, len(steps))
for name := range steps {
indegree[name] = 0
}
for _, step := range p.Steps {
for _, dep := range step.After {
if _, ok := steps[dep]; !ok {
return 0, nil, fmt.Errorf("dr: step %q depends on unknown %q", step.Name, dep)
}
successors[dep] = append(successors[dep], step.Name)
indegree[step.Name]++
}
}
for name := range successors {
sort.Strings(successors[name])
}
finish := make(map[string]time.Duration, len(steps))
start := make(map[string]time.Duration, len(steps))
previous := make(map[string]string, len(steps))
ready := make([]string, 0, len(steps))
for name := range steps {
if indegree[name] == 0 {
ready = append(ready, name)
}
}
sort.Strings(ready)
done := 0
for len(ready) > 0 {
name := ready[0]
ready = ready[1:]
finish[name] = start[name] + steps[name].Duration
done++
for _, next := range successors[name] {
if finish[name] > start[next] {
start[next] = finish[name]
previous[next] = name
}
indegree[next]--
if indegree[next] == 0 {
ready = append(ready, next)
sort.Strings(ready)
}
}
}
if done != len(steps) {
return 0, nil, fmt.Errorf("dr: dependency cycle across %d steps", len(steps)-done)
}
names := make([]string, 0, len(steps))
for name := range steps {
names = append(names, name)
}
sort.Strings(names)
last := ""
var total time.Duration
for _, name := range names {
if last == "" || finish[name] > total {
last, total = name, finish[name]
}
}
path := []string{last}
for {
parent, ok := previous[last]
if !ok {
break
}
path = append(path, parent)
last = parent
}
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
path[i], path[j] = path[j], path[i]
}
return total, path, nil
}
求解器里有一个容易写错的地方:步骤的完成时间不是“初始耗时”与“前驱完成加自身耗时”取最大,而是先等所有前驱完成,再加自身耗时。实现用 start 记录所有前驱的最晚完成时间,节点出队时才计算 finish,这样即使某条路径恰好等于当前值,前驱指针也会正确建立,关键路径不会断在中间。
本机运行结果
测试覆盖状态迁移、回切门槛、RPO 正常值与两种异常、RTO 关键路径、依赖图错误和提升前写入。命令与原始输出:
$ go test ./internal/platform/dr/ -v -count=1
=== RUN TestSwitchoverPhases
switchover_test.go:17: rejected NORMAL -> SERVING: dr: transition NORMAL -> SERVING not allowed
switchover_test.go:24: phase=READ_ONLY
switchover_test.go:24: phase=PROMOTED
switchover_test.go:24: phase=SERVING
=== RUN TestRollbackRequiresReverseSync
switchover_test.go:55: rollback_blocked writes=2 err=dr: rollback blocked by 2 unsynced writes on new primary
switchover_test.go:60: rollback_allowed writes=2 reverse_synced=true
=== RUN TestRPO
switchover_test.go:78: rpo_transactions=4 window=700ms
switchover_test.go:85: replica_ahead rejected: dr: replica seq 1050 ahead of acked seq 1042
switchover_test.go:93: clock_skew rejected: dr: replica timestamp ahead by 300ms, check clock skew
=== RUN TestPlanRTO
switchover_test.go:122: rto=1m30s critical_path=[detect notify freeze dns warm open]
=== RUN TestPlanRejectsInvalidGraphs
switchover_test.go:141: unknown_dep rejected: dr: step "a" depends on unknown "missing"
=== RUN TestRecordWriteBeforePromotion
switchover_test.go:150: write_in_normal rejected: dr: write recorded in phase NORMAL
PASS
ok archlab/internal/platform/dr 0.258s
读数要分开看。RPO 示例中,已确认序号 1042、副本序号 1038,差是 4 笔,时间窗口 700 毫秒;副本序号超前被拒绝,副本时间超前 300 毫秒被判定时钟偏移,而不是算出一个负数窗口。RTO 示例的计划值是 90 秒,关键路径包含 DNS,和前面的手工推导一致。回切测试中,新主接受两笔写后回切被拒绝,标记反向同步后才允许。这些数字来自注入时间的单元测试和示例计划,不是生产机房测量;真实 RTO/RPO 必须在目标环境按下面的脚本演练。
演练脚本模板
脚本只记录时间线和门槛,不替人做决策。所有 *_ctl 命令是占位符,需要替换成实际控制面工具;脚本未在本机跨机房执行,不能把它的输出当作生产实测。
#!/bin/sh
## Switchover drill template. Replace *_ctl with the real control-plane CLI.
set -eu
PRIMARY=${PRIMARY:?}; REPLICA=${REPLICA:?}; TARGET_RTO=${TARGET_RTO:-180}
start=$(date +%s); echo "start=$(date -u +%FT%TZ)" >> timeline
frozen=0
trap 'echo "abort at $(date -u +%FT%TZ) frozen=$frozen" >> timeline' EXIT
seq_ctl capture "$PRIMARY" > pre.seq ## stage 0: RPO baseline
write_ctl freeze "$PRIMARY" && frozen=1 ## stage 1: stop writes
write_ctl assert_rejected "$PRIMARY" ## gate: no write may pass
replica_ctl promote "$REPLICA" ## stage 2: promote
replica_ctl assert_writable "$REPLICA"
dns_ctl switch "$REPLICA" ## stage 3: traffic switch
dns_ctl assert_all_resolvers "$REPLICA"
app_ctl verify orders,payment,inventory ## stage 4: business checks
elapsed=$(( $(date +%s) - start ))
[ "$elapsed" -le "$TARGET_RTO" ] || echo "RTO overrun: ${elapsed}s" >> timeline
echo "elapsed=${elapsed}s rpo=$(seq_ctl gap pre.seq "$REPLICA")" >> timeline
脚本的退出条件是显式的:冻结后仍能写、提升后新主不可写、任一解析器未切换、业务校验失败、RTO 超目标,都必须中止并保留现场。演练结束后不要立刻回切;先确认新主写入数,再决定是反向同步还是保持现状。
演练验收表
| 检查项 | 判据 | 证据 | 不通过动作 |
|---|---|---|---|
| 检测延迟 | 从注入故障到告警在目标内 | 告警时间戳与注入时间戳 | 调整阈值或自动检测 |
| 冻结确认 | 旧主拒绝所有写 | 写入拒绝计数与抽样请求 | 立即中止,检查路由 |
| RPO | 已确认未复制事务数不超过预算 | 切换前序号快照 | 停止切换,补齐或放弃 |
| 提升 | 新主可写且旧主保持只读 | 状态机阶段与探针 | 回退到只读 |
| DNS 生效 | 所有解析器指向新主 | 各区域解析结果 | 延长观察或回滚 DNS |
| 业务校验 | 下单、支付、库存预占均通过 | 校验用例与订单号 | 不回切,继续排查 |
| 回切门槛 | 新主写入为零或反向同步完成 | 写入计数与同步位点 | 禁止回切 |
验收表要在演练前确定,而不是演练后补。判据写得越具体,越不容易被“看起来恢复了”带过去。
常见错误与边界
第一,RTO 只计算提升命令。检测、决策、DNS TTL 和业务校验往往占大头;不测这些,得到的 RTO 只是控制面操作的耗时,不是业务恢复时间。
第二,RPO 用复制延迟的平均值。平均值会掩盖“已确认但未复制”的尾部;必须用确认序号与副本序号的差,并显式处理副本超前和时钟偏移。
第三,提升后直接反向切换。新主已经接受写入,回切前必须清零或完成反向同步;否则回切就是一次有损切换。状态机的回切门槛要把这个条件固化成代码,而不是写在手册里靠人记。
第四,演练只演切换不演回切,或只演单点不演共享依赖。DNS、配置中心、支付通道、专线都可能成为单点;至少要在真实环境做一次包含回切和依赖故障的演练,才能对 RTO/RPO 下结论。
小结
RTO 是依赖图上的最长路径,不是某一条命令的耗时;RPO 是已确认序号与副本序号的差,不是复制延迟的平均值。状态机把切换和回切分开:提升前可以取消,提升后必须先处理新主写入。演练脚本和验收表的作用,是把这些条件变成可观测、可中止的检查点,而不是事后解释。
至此,多活的拓扑、数据同步和切换流程已经连成一条线。下一章把前面所有机制放进同一个订单系统,从单库开始做分片改造,验证跨库下单时这些不变量是否仍然成立。
阅读导航:上一节:数据同步与冲突:双向复制、回环与归属 · 下一节:订单系统的分布式改造:从单库到分片 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。