《后端架构设计·分布式与数据一致性》13.3 进入卷四:演进与治理

卷三的 L2 实验只能证明状态机和故障处理,不能证明共识性能、跨单元冲突率、演练覆盖和组织成本。本节把四类未证明假设整理成带责任、指标、阈值、窗口和截止日期的交接表,并给出一个 Go 校验器,拒绝没有验收口径、缺少截止日期或把未证明项标成已接受的清单。

测试通过不等于可以上线

第 13.2 节的跨库下单实验跑过了六种故障注入,库存不超卖、订单状态一致、支付不重复,所有场景的对账差异为 0。这是一个可重复的 L2 结论,但它证明的范围很窄:进程内、确定性 broker、注入延迟、没有真实磁盘 fsync、没有网络分区、没有跨可用区时钟漂移。

如果拿这份测试报告去回答“能不能上生产”,至少还有四类问题没有答案:Raft 在真实磁盘和网络下的写延迟是多少;双活单元对同一个订单并发写入时冲突率有多高;容灾切换演练有没有在真实流量下跑过;双写、对账和 on-call 每月要花多少人力。这些问题不是再写一个内存组件就能回答的,它们需要真实环境、真实流量和组织投入,属于卷四“演进与治理”的范围。

这一节不再引入新机制,而是把卷三的假设逐条翻出来,做成一份可以验收的交接表:每一项必须有责任方、指标、阈值、观察窗口和截止日期;未证明的项必须写清残余风险和到期时间;未证明的项不允许标成“已接受”。

已经证明的与没有证明的

卷三的 L2 实验覆盖的是状态机与故障处理:

已经证明证据不能推出
下单链路在重复、超时、崩溃下收敛13.2 的 6 个故障场景加基线,差异为 0真实 broker 的持久化与顺序
迁移切换门能拦住未回填和双写失败13.1 的 CanCutover 测试真实数据库的复制延迟
版本向量能检测并发修改第 12.2 节的单测真实双活流量的冲突率
选主与日志复制在确定性网络下安全第 4 章的迷你 Raft 测试真实磁盘 fsync 的吞吐与尾延迟

“不能推出”这一列才是交接的重点。把 L2 结论直接外推成生产结论,是分布式项目最常见的验收错误。

四类交接问题

共识性能。 迷你 Raft 统计的是消息数和提交位置,没有磁盘。真实环境里一次 AppendEntries 的尾延迟由 fsync、批量大小、网络 RTT 和领导者负载共同决定;三节点和五节点的写放大也不同。需要测的是 3/5 节点、批量 fsync 开启与关闭时的写 p99、吞吐和领导者故障切换窗口,而不是“消息发了几轮”。

跨单元冲突。 第 12.2 节用版本向量避免了互相覆盖,但单测里的并发是构造出来的。真实双活要回答同键冲突率、热点键分布、仲裁由谁做、冲突后的补偿成本。没有这些数字,就无法判断“归属单元”策略是否比“最后写入获胜加人工修复”更便宜。

演练覆盖。 第 12.3 节给出了切换脚本和 RTO/RPO 验收表,但桌面演练不等于真实切换。需要至少一次带 1% 真实流量的演练,覆盖主库故障、单元隔离、回切和数据校验;要记录实测 RTO、RPO、回切时长和差异条数,而不是计划值。

组织成本。 双写期间谁负责修目标分片的失败写入,对账告警夜里谁处理,变更窗口谁来批,跨单元冲突谁仲裁。这些不是技术债,是运营债;如果不写进交接表,系统上线后会以“临时支持”的名义长期占用一个团队。把 on-call 班表、升级路径和月度人力写进验收口径,才能让治理成本可见。

这四类问题有一个共同点:它们都不能靠再写一个内存组件来回答。共识性能需要真实磁盘和网络,冲突率需要真实双写流量,演练需要真实流量和故障注入,组织成本需要真实的排班和工单。卷四的价值不在于再讲一遍 Raft 或 Saga,而在于把这些“机制之外”的变量变成可测量、可验收的治理动作。

相关背景可以对照容灾切换:RTO/RPO、演练与回切 和跨库下单的一致性验证 ;本节只负责把问题变成可验收的条目。

交接表

下表是卷三交给卷四的初始清单。状态只有三种:proven 表示有可复现证据且达到验收口径;partial 表示有部分证据;unproven 表示只有设计或单测。accepted 只能在 proven 时勾选。

编号问题当前证据验收口径状态责任截止
H-013 节点批量 fsync 的写 p99L2 消息计数,无磁盘p99 < 20ms,7 天unproven平台组2026-12-31
H-02领导者故障切换窗口确定性选主测试< 5s,连续 20 次unproven平台组2026-12-31
H-03双活同键冲突率版本向量单测< 0.1%,30 天unproven订单组2027-01-31
H-04冲突仲裁与补偿成本单次仲裁原型单次 < 50ms,p99partial订单组2027-01-31
H-05真实流量切换演练桌面演练脚本1% 流量,RTO ≤ 5minunprovenSRE2027-03-31
H-06回切与数据校验无回切 ≤ 10min,差异 0unprovenSRE2027-03-31
H-07双写与对账人力无≤ 2 人天/月,90 天unproven架构组2027-03-31
H-08变更审批时延无P50 ≤ 1 天,30 天unproven架构组2027-03-31

这张表本身也有验收标准:每一行的“验收口径”必须能写成指标、阈值和窗口;写不出来的,说明问题还没有被定义清楚。评审时按领域逐行过,任何人不能以“先上线再说”跳过 unproven 项。

交接不是一次性动作。H-01 到 H-04 属于技术验证,按月复核;H-05 到 H-08 属于演练与运营,按季度复核。每次复核只允许两种状态变化:补上证据后改成 proven,或者写清为什么仍然未证明并顺延截止日期。状态不允许因为“项目上线”自动变成已接受。

校验器:拒绝模糊的交接

人工评审很容易放过“尽快完成”“持续观察”这类措辞。下面的 handoff 包把交接表变成结构化数据,并检查五类问题:必填字段缺失、四个领域缺项、验收口径不可计算、未证明项没有残余风险或截止日期、未证明项被标成已接受。

// file: internal/handoff/handoff.go
// tier: L1
// verify: go test ./internal/handoff/ -count=1
package handoff

import (
	"errors"
	"fmt"
	"sort"
	"strings"
)

// Status says how much of an item is actually proven.
type Status string

const (
	Proven   Status = "proven"
	Partial  Status = "partial"
	Unproven Status = "unproven"
)

// Item is one question handed to the next volume.
type Item struct {
	ID       string
	Area     string
	Question string
	Owner    string
	Evidence string
	// Metric, Threshold and Window make acceptance machine-checkable.
	Metric    string
	Threshold string
	Window    string
	Status    Status
	// ResidualRisk and Due are mandatory unless the item is proven.
	ResidualRisk string
	Due          string
	Accepted     bool
}

// Checklist is the handoff document.
type Checklist struct {
	Version string
	Items   []Item
}

var requiredAreas = []string{"consensus_perf", "cross_cell_conflict", "drill_coverage", "org_cost"}

// Validate returns every reason the checklist is not acceptable, not just
// the first one. An empty slice means the handoff can be reviewed.
func (c Checklist) Validate() []error {
	var errs []error
	if strings.TrimSpace(c.Version) == "" {
		errs = append(errs, errors.New("version is empty"))
	}
	seen := make(map[string]bool)
	areas := make(map[string]bool)
	for i, it := range c.Items {
		where := fmt.Sprintf("item[%d]", i)
		if strings.TrimSpace(it.ID) == "" {
			errs = append(errs, fmt.Errorf("%s: id is empty", where))
		} else if seen[it.ID] {
			errs = append(errs, fmt.Errorf("%s: duplicate id %q", where, it.ID))
		}
		seen[it.ID] = true
		if it.Area == "" {
			errs = append(errs, fmt.Errorf("%s %s: area is empty", where, it.ID))
		} else {
			areas[it.Area] = true
		}
		if it.Owner == "" {
			errs = append(errs, fmt.Errorf("%s %s: owner is empty", where, it.ID))
		}
		if it.Question == "" {
			errs = append(errs, fmt.Errorf("%s %s: question is empty", where, it.ID))
		}
		if it.Metric == "" || it.Threshold == "" || it.Window == "" {
			errs = append(errs, fmt.Errorf("%s %s: acceptance is not machine-checkable", where, it.ID))
		}
		switch it.Status {
		case Proven:
			if it.Evidence == "" {
				errs = append(errs, fmt.Errorf("%s %s: proven without evidence", where, it.ID))
			}
		case Partial, Unproven:
			if it.ResidualRisk == "" {
				errs = append(errs, fmt.Errorf("%s %s: %s without residual risk", where, it.ID, it.Status))
			}
			if it.Due == "" {
				errs = append(errs, fmt.Errorf("%s %s: %s without due date", where, it.ID, it.Status))
			}
		default:
			errs = append(errs, fmt.Errorf("%s %s: invalid status %q", where, it.ID, it.Status))
		}
		if it.Accepted && it.Status != Proven {
			errs = append(errs, fmt.Errorf("%s %s: accepted while only %s", where, it.ID, it.Status))
		}
	}
	for _, area := range requiredAreas {
		if !areas[area] {
			errs = append(errs, fmt.Errorf("required area %q has no item", area))
		}
	}
	return errs
}

// Summary is the one-line state printed before a review.
func (c Checklist) Summary() string {
	byStatus := map[Status]int{}
	byArea := map[string]int{}
	for _, it := range c.Items {
		byStatus[it.Status]++
		byArea[it.Area]++
	}
	areas := make([]string, 0, len(byArea))
	for a := range byArea {
		areas = append(areas, a)
	}
	sort.Strings(areas)
	return fmt.Sprintf("items=%d proven=%d partial=%d unproven=%d areas=%s",
		len(c.Items), byStatus[Proven], byStatus[Partial], byStatus[Unproven], strings.Join(areas, ","))
}

校验器不判断阈值是否合理,那是评审人的工作;它只保证“没有验收口径的条目进不了交接”,以及“未证明的条目不能伪装成已接受”;同一 module 的测试用 6 个变异用例验证这些拒绝原因。这两条规则能挡掉大部分形式化交接。

  • 结构之外还要检查依赖:H-05 依赖 H-01,H-06 依赖 H-05。
  • 阈值是否合理、窗口是否足够长,仍由领域负责人签字。

进入卷四前的三个动作

第一,把交接表里的 unproven 项按责任方拆成工单,每张工单只写一个指标、一个阈值和一个观察窗口,避免“提升一致性”这种无法关闭的条目。第二,为 H-05 和 H-06 安排一次带真实流量的演练,没有演练的 RTO/RPO 只是计划值。第三,给 H-07 和 H-08 建立月度统计,把双写修复、对账告警和变更审批的耗时记下来,作为卷四治理章节的输入。

这三件事没有做完,卷三的结论就只能停留在“机制正确”,不能升级为“系统可上线”。

实测:合法清单与六种拒绝

命令:go test ./internal/handoff/ -count=1 -v。环境为 macOS darwin/arm64、Go 1.26.0。完整输出在 .local/reports/be-arch/be-arch-dist-13-3-into-volume-four-run.txt。

=== RUN   TestValidate
    handoff_test.go:31: items=4 proven=0 partial=1 unproven=3 areas=consensus_perf,cross_cell_conflict,drill_coverage,org_cost
=== RUN   TestValidate/missing_owner
=== RUN   TestValidate/accepted_unproven
=== RUN   TestValidate/missing_area
=== RUN   TestValidate/unproven_without_due
=== RUN   TestValidate/duplicate_id
=== RUN   TestValidate/threshold_missing
--- PASS: TestValidate (0.00s)
PASS
ok  	archlab/internal/handoff	0.267s

样例清单包含四个领域各一项,Summary 输出 items=4 proven=0 partial=1 unproven=3,说明它虽然通过了结构校验,但没有任何一项达到已证明状态。六个子测试分别删掉责任人、把未证明项标成已接受、删掉一个领域、去掉截止日期、复制编号、清空阈值,校验器都返回了对应错误。这就是交接表该有的行为:结构完整不等于风险已关闭,未证明的项必须显式保留。

常见错误与边界

  • 把 L2 测试通过当成生产就绪。进程内确定性测试证明状态机,不证明磁盘、网络、时钟和真实流量。
  • 交接表只写问题不写验收口径。“提升共识性能”不是条目,“3 节点批量 fsync 写 p99 < 20ms,观察 7 天”才是。
  • 未证明项不写残余风险和截止日期。没有到期时间的风险不会有人处理,只会在下一次评审时原样出现。
  • 把“已接受”当成“已知悉”。接受一个未证明的风险需要责任方签字和到期复核,不能靠一句“先上线再说”。
  • 本节校验器只检查结构和字段,不验证指标是否可达、阈值是否合理;这些必须由评审和真实测量完成。

小结

卷三从故障模型走到跨库下单,能证明的是机制在确定性环境下的行为;不能证明的是它们在真实磁盘、网络、流量和组织流程里的表现。这一节把后者拆成共识性能、跨单元冲突、演练覆盖和组织成本四类问题,每项都要求责任方、指标、阈值、窗口和截止日期,并用校验器拒绝没有验收口径、缺少截止日期或把未证明项标成已接受的清单。

卷四要回答的正是这些“机制之外”的问题:怎么在真实负载下测量、怎么组织演练、怎么让治理成本可见。带着这张交接表进入下一卷,比带着一份“测试全部通过”的报告更接近可上线。

阅读导航:上一节:一次跨库下单的一致性验证 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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