测试通过不等于可以上线
第 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-01 | 3 节点批量 fsync 的写 p99 | L2 消息计数,无磁盘 | 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,p99 | partial | 订单组 | 2027-01-31 |
| H-05 | 真实流量切换演练 | 桌面演练脚本 | 1% 流量,RTO ≤ 5min | unproven | SRE | 2027-03-31 |
| H-06 | 回切与数据校验 | 无 | 回切 ≤ 10min,差异 0 | unproven | SRE | 2027-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 天”才是。
- 未证明项不写残余风险和截止日期。没有到期时间的风险不会有人处理,只会在下一次评审时原样出现。
- 把“已接受”当成“已知悉”。接受一个未证明的风险需要责任方签字和到期复核,不能靠一句“先上线再说”。
- 本节校验器只检查结构和字段,不验证指标是否可达、阈值是否合理;这些必须由评审和真实测量完成。
小结
卷三从故障模型走到跨库下单,能证明的是机制在确定性环境下的行为;不能证明的是它们在真实磁盘、网络、流量和组织流程里的表现。这一节把后者拆成共识性能、跨单元冲突、演练覆盖和组织成本四类问题,每项都要求责任方、指标、阈值、窗口和截止日期,并用校验器拒绝没有验收口径、缺少截止日期或把未证明项标成已接受的清单。
卷四要回答的正是这些“机制之外”的问题:怎么在真实负载下测量、怎么组织演练、怎么让治理成本可见。带着这张交接表进入下一卷,比带着一份“测试全部通过”的报告更接近可上线。
阅读导航:上一节:一次跨库下单的一致性验证 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。