《后端架构设计·分布式与数据一致性》12.3 容灾切换:RTO/RPO、演练与回切

从一次只读降级后没人敢回切的订单故障出发,把 RTO 拆成依赖图上的关键路径、把 RPO 拆成已确认序号与副本序号的差,给出可运行的状态机、RPO 计算与 RTO 关键路径 Go 实现,附本机测试输出、演练脚本、验收表与回切前置条件,并明确跨机房数字必须由真实演练校准。

切换命令执行了,为什么没人敢回切

支付高峰前,订单主库所在单元 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 是已确认序号与副本序号的差,不是复制延迟的平均值。状态机把切换和回切分开:提升前可以取消,提升后必须先处理新主写入。演练脚本和验收表的作用,是把这些条件变成可观测、可中止的检查点,而不是事后解释。

至此,多活的拓扑、数据同步和切换流程已经连成一条线。下一章把前面所有机制放进同一个订单系统,从单库开始做分片改造,验证跨库下单时这些不变量是否仍然成立。

阅读导航:上一节:数据同步与冲突:双向复制、回环与归属 · 下一节:订单系统的分布式改造:从单库到分片 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查