收口章交付什么
第 12 章不再引入新概念。1.2 已经把质量属性与取舍讲清楚,2.2 已经把决策写成 ADR,6.1 已经确定数据所有权。这一章只做一件事:把前 11 章散落在正文、表格和代码里的决定,压成一份可评审、可交接的基线。
基线不是一张架构图,而是五件事对齐:需求有编号,约束有影响,指标有口径,条目有 owner,结论有证据。四者缺一,交接时就会退化成“当时是这么定的”。站内 1.2 质量属性与无法回避的取舍 讨论的是怎么取舍,这里讨论的是取舍之后怎么留痕。
业务需求与硬约束
业务需求回答“系统必须做什么”,约束回答“哪些做法现在不能选”。两者都不是 NFR,但 NFR 的目标值必须能追溯到它们。
| ID | 需求 | 验收口径 |
|---|---|---|
| BR-01 | 创建订单,支持 Idempotency-Key | 同一键重复提交返回同一订单,不产生第二条记录 |
| BR-02 | 下单时预留库存,缺货拒绝 | 并发扣减后库存不为负,超卖为 0 |
| BR-03 | 支付、取消、退款按状态机迁移 | 非法迁移返回 INVALID_STATE,状态不变 |
| BR-04 | 按客户与状态分页查询订单 | 分页稳定,不因新增订单重复或漏项 |
| BR-05 | 跨模块写操作留下可追溯事件 | 卷一写入 outbox,投递语义在卷二展开 |
| ID | 类型 | 约束 | 对架构的影响 | Owner |
|---|---|---|---|---|
| C-01 | 技术 | Go 1.26,标准库优先 | 不引入 Web 框架与 ORM,端口手写 | platform |
| C-02 | 技术 | 卷一只有一个可部署二进制 | 模块边界靠包与校验器,不靠网络 | team-order |
| C-03 | 技术 | PostgreSQL 18.4 只在 L3 验证使用 | 卷一默认内存适配器,L3 步骤单独标注 | platform |
| C-04 | 组织 | 两个小队共用一个值班轮值 | 跨模块故障必须有联合 runbook | platform |
| C-05 | 业务 | 支付渠道合同锁定到 2027-03 | 适配器按渠道能力建模,不假设随时可换 | team-pay |
NFR 基线表
| ID | 指标 | 口径与目标 | 状态 | Owner | 证据或验证方式 |
|---|---|---|---|---|---|
| NFR-01 | 创建订单延迟 | staging 30 orders/s 持续 10 分钟,p95 ≤ 300 ms | 目标 | team-order | QA-PERF-01,记录 p95 与错误率 |
| NFR-02 | 订单查询延迟 | 缓存命中率 90%,GET 单订单 p99 ≤ 150 ms | 待验证 | team-order | QA-PERF-02,目标值仍属候选 |
| NFR-03 | 月度可用性 | 30 天 5xx 与超时占比 ≤ 0.1%,排除计划维护 | 目标 | platform | 监控月报,尚无完整月度数据 |
| NFR-04 | 日订单容量 | 峰值 30 orders/s 跑 30 分钟,日容量 ≥ 50000 单 | 目标 | team-order | QA-CAP-01,按峰值外推 |
| NFR-05 | 基线校验耗时 | 64 指标 + 16 约束,benchmark ns/op ≤ 5 ms | 实测 | platform | go test -bench=BenchmarkValidate -benchtime=200x -count=3 |
| NFR-06 | 恢复点目标 RPO | 主库故障丢失 ≤ 5 分钟数据 | 目标 | platform | QA-DR-01,主备切换演练 |
| NFR-07 | 支付回调重放拒绝率 | 过期签名回调 100% 拒绝且状态不变 | 待验证 | team-pay | QA-SEC-01,重放 100 个样本 |
这张表里只有 NFR-05 是实测,而且测的是校验器本身:三次运行最慢 5984 ns/op,即约 0.006 ms。系统级延迟、可用性、容量全部是目标或待验证。卷一没有做压测和故障注入,所以不写“实测 280 ms”这类没有报告支撑的数字。
三种状态对应三种证据
target 是已经承诺的目标,必须带验证计划;unverified 是目标值仍在候选、验证计划已写但还没执行;measured 必须有目标、实测值、证据命令和测量日期。三者的差别不是措辞,而是能否被复核。
校验器要拦住五类失真:target 偷偷携带实测值、measured 没有证据、unverified 已经有实测值、条目没有 owner 或 owner 不在值班名册、指标缺单位。目标值是否合理、5 ms 是否太松,机器不判断,留给评审。
基线的 Go 结构
// file: internal/baseline/baseline.go
// tier: L2
// verify: go test ./internal/baseline/ -count=1
package baseline
import (
"fmt"
"math"
"time"
)
type Status string
const (
StatusTarget Status = "target"
StatusMeasured Status = "measured"
StatusUnverified Status = "unverified"
)
// Metric 的两个数值用指针,区分“没有数”和“数恰好是 0”。
type Metric struct {
ID string `json:"id"`
Name string `json:"name"`
Unit string `json:"unit"`
Target *float64 `json:"target,omitempty"`
Measured *float64 `json:"measured,omitempty"`
Status Status `json:"status"`
Owner string `json:"owner"`
Evidence string `json:"evidence,omitempty"`
Verification string `json:"verification,omitempty"`
MeasuredOn string `json:"measured_on,omitempty"`
}
type Constraint struct {
ID string `json:"id"`
Kind string `json:"kind"`
Statement string `json:"statement"`
Impact string `json:"impact"`
Owner string `json:"owner"`
}
type Baseline struct {
Name string `json:"name"`
Version string `json:"version"`
Owners []string `json:"owners"`
Metrics []Metric `json:"metrics"`
Constraints []Constraint `json:"constraints"`
}
type Finding struct {
Code string `json:"code"`
Ref string `json:"ref"`
Message string `json:"message"`
}
// Validate 只拦“口径自相矛盾”,目标值是否合理留给评审。
func (b Baseline) Validate() []Finding {
var out []Finding
add := func(code, ref, format string, args ...any) {
out = append(out, Finding{Code: code, Ref: ref, Message: fmt.Sprintf(format, args...)})
}
if b.Name == "" || b.Version == "" {
add("BASELINE_ID", "baseline", "name and version are required")
}
owners := map[string]bool{}
for _, o := range b.Owners {
if o == "" {
add("OWNER_EMPTY", "owners", "owner must not be empty")
} else if owners[o] {
add("OWNER_DUPLICATE", o, "owner %q listed twice", o)
}
owners[o] = true
}
ids := map[string]string{}
for i, m := range b.Metrics {
ref := m.ID
if ref == "" {
ref = fmt.Sprintf("metrics[%d]", i)
add("METRIC_ID", ref, "id is required")
} else if prev, ok := ids[m.ID]; ok {
add("ID_DUPLICATE", ref, "id %q also used by %s", m.ID, prev)
} else {
ids[m.ID] = "metric"
}
if m.Name == "" || m.Unit == "" {
add("METRIC_TEXT", ref, "name and unit are required")
}
if !owners[m.Owner] {
add("METRIC_OWNER", ref, "owner %q not in roster", m.Owner)
}
if m.Target != nil && !finite(*m.Target) {
add("METRIC_TARGET", ref, "target must be finite and non-negative")
}
if m.Measured != nil && !finite(*m.Measured) {
add("METRIC_MEASURED", ref, "measured must be finite and non-negative")
}
switch m.Status {
case StatusTarget:
if m.Target == nil {
add("TARGET_MISSING", ref, "status target requires target")
}
if m.Measured != nil {
add("TARGET_HAS_MEASURED", ref, "status target must not carry measured")
}
if m.Verification == "" {
add("TARGET_NO_PLAN", ref, "target metric needs verification plan")
}
case StatusMeasured:
if m.Measured == nil || m.Target == nil {
add("MEASURED_INCOMPLETE", ref, "measured metric needs target and measured value")
}
if m.Evidence == "" {
add("MEASURED_NO_EVIDENCE", ref, "measured metric needs evidence command or report")
}
if _, err := time.Parse(time.RFC3339, m.MeasuredOn); err != nil {
add("MEASURED_NO_DATE", ref, "measured_on must be RFC3339")
}
case StatusUnverified:
if m.Measured != nil {
add("UNVERIFIED_HAS_MEASURED", ref, "unverified must not carry measured; use status measured")
}
if m.Target == nil || m.Verification == "" {
add("UNVERIFIED_INCOMPLETE", ref, "unverified metric needs target and verification plan")
}
default:
add("METRIC_STATUS", ref, "unknown status %q", m.Status)
}
}
for i, c := range b.Constraints {
ref := c.ID
if ref == "" {
ref = fmt.Sprintf("constraints[%d]", i)
add("CONSTRAINT_ID", ref, "id is required")
} else if prev, ok := ids[c.ID]; ok {
add("ID_DUPLICATE", ref, "id %q also used by %s", c.ID, prev)
} else {
ids[c.ID] = "constraint"
}
switch c.Kind {
case "business", "technical", "organizational":
default:
add("CONSTRAINT_KIND", ref, "kind %q is not business/technical/organizational", c.Kind)
}
if c.Statement == "" || c.Impact == "" {
add("CONSTRAINT_TEXT", ref, "statement and impact are required")
}
if !owners[c.Owner] {
add("CONSTRAINT_OWNER", ref, "owner %q not in roster", c.Owner)
}
}
return out
}
func finite(v float64) bool {
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0
}
结构体 Metric 与 Constraint 共用 ID 命名空间,避免 NFR-01 同时是一条指标和一条约束。Owners 是值班名册,不是自由文本;owner 不在名册里,基线就无法交接。
测试:每条失真都能被拒收
// file: internal/baseline/baseline_test.go
// tier: L2
// verify: go test ./internal/baseline/ -run TestValidate -v
package baseline
import (
"fmt"
"testing"
)
func ptr(v float64) *float64 { return &v }
func fixture() Baseline {
return Baseline{
Name: "arch-lab 卷一基线", Version: "2026-10-baseline.1",
Owners: []string{"team-order", "team-pay", "platform"},
Metrics: []Metric{
{ID: "NFR-01", Name: "创建订单延迟", Unit: "ms", Target: ptr(300), Status: StatusTarget, Owner: "team-order", Verification: "QA-PERF-01: staging 30 orders/s, p95 + error rate"},
{ID: "NFR-02", Name: "订单查询延迟", Unit: "ms", Target: ptr(150), Status: StatusUnverified, Owner: "team-order", Verification: "QA-PERF-02: cache hit 90%, GET p99"},
{ID: "NFR-03", Name: "月度可用性", Unit: "%", Target: ptr(99.9), Status: StatusTarget, Owner: "platform", Verification: "30-day 5xx + timeout ratio"},
{ID: "NFR-05", Name: "基线校验耗时", Unit: "ms", Target: ptr(5), Measured: ptr(0.006), Status: StatusMeasured, Owner: "platform", Evidence: "go test -bench=BenchmarkValidate -benchtime=200x -count=3", MeasuredOn: "2026-11-02T10:00:00+08:00"},
{ID: "NFR-06", Name: "恢复点目标 RPO", Unit: "min", Target: ptr(5), Status: StatusTarget, Owner: "platform", Verification: "QA-DR-01: primary failover drill"},
},
Constraints: []Constraint{
{ID: "C-01", Kind: "technical", Statement: "Go 1.26,标准库优先", Impact: "不引入 Web 框架与 ORM", Owner: "platform"},
{ID: "C-02", Kind: "organizational", Statement: "两个小队共用一个值班轮值", Impact: "跨模块故障需要联合 runbook", Owner: "platform"},
},
}
}
func metric(b *Baseline, id string) *Metric {
for i := range b.Metrics {
if b.Metrics[i].ID == id {
return &b.Metrics[i]
}
}
panic("metric not found: " + id)
}
func has(findings []Finding, code string) bool {
for _, f := range findings {
if f.Code == code {
return true
}
}
return false
}
func TestValidateClean(t *testing.T) {
if got := fixture().Validate(); len(got) != 0 {
t.Fatalf("clean baseline returned %v", got)
}
}
func TestValidateRejectsConfusedStatuses(t *testing.T) {
tests := []struct {
name string
mutate func(*Baseline)
code string
}{
{"target carries measured", func(b *Baseline) { metric(b, "NFR-01").Measured = ptr(280) }, "TARGET_HAS_MEASURED"},
{"measured without evidence", func(b *Baseline) { metric(b, "NFR-05").Evidence = "" }, "MEASURED_NO_EVIDENCE"},
{"unverified carries measured", func(b *Baseline) { metric(b, "NFR-02").Measured = ptr(120) }, "UNVERIFIED_HAS_MEASURED"},
{"unknown owner", func(b *Baseline) { metric(b, "NFR-03").Owner = "team-ghost" }, "METRIC_OWNER"},
{"duplicate id", func(b *Baseline) { metric(b, "NFR-02").ID = "NFR-01" }, "ID_DUPLICATE"},
{"target without plan", func(b *Baseline) { metric(b, "NFR-06").Verification = "" }, "TARGET_NO_PLAN"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
b := fixture()
tc.mutate(&b)
if findings := b.Validate(); !has(findings, tc.code) {
t.Fatalf("want %s, got %v", tc.code, findings)
}
})
}
}
// 64 条指标 + 16 条约束,模拟一次季度基线评审的规模。
func BenchmarkValidate(b *testing.B) {
base := fixture()
for i := len(base.Metrics); i < 64; i++ {
base.Metrics = append(base.Metrics, Metric{ID: fmt.Sprintf("NFR-%02d", i+1), Name: "generated", Unit: "ms", Target: ptr(100), Status: StatusTarget, Owner: "team-order", Verification: "generated plan"})
}
for i := len(base.Constraints); i < 16; i++ {
base.Constraints = append(base.Constraints, Constraint{ID: fmt.Sprintf("C-%02d", i+1), Kind: "technical", Statement: "generated", Impact: "generated", Owner: "platform"})
}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = base.Validate()
}
}
真实运行结果(macOS arm64,Go 1.26.0):
$ go test ./internal/baseline -count=1
ok archlab/internal/baseline 0.599s
$ go test ./internal/baseline -run '^$' -bench=BenchmarkValidate -benchtime=200x -count=3
BenchmarkValidate-10 200 5841 ns/op 9289 B/op 13 allocs/op
BenchmarkValidate-10 200 5984 ns/op 9289 B/op 13 allocs/op
BenchmarkValidate-10 200 5951 ns/op 9289 B/op 13 allocs/op
PASS
ok archlab/internal/baseline 0.350s
最慢一次 5984 ns/op,即 0.005984 ms,低于 5 ms 目标;Measured 填 0.006 是按最慢值向上取整,不是取平均。
交接检查单
- 指标 ID 在基线与 ADR 中使用同一套编号,不另起一套。
- 每条
target和unverified都有可执行的验证计划,不接受“上线后观察”。 - 每条
measured的证据命令能被他人在同一提交上重跑。 - 每条指标和约束都有 owner,且 owner 在值班名册内。
- 基线版本号随内容变化递增,评审记录写明改了哪几条、为什么。
常见错误与边界
第一类错误是把目标写成实测:表格里写“p95 280 ms”,证据栏却是空的。第二类错误是只有指标没有口径:同样叫“可用性”,按月、按请求、是否排除维护,会得到完全不同的数。第三类错误是约束写成愿望,例如“尽量不引入外部依赖”,它对架构没有约束力。
校验器只能检查结构,不能检查目标是否值得追求。5 ms 是否合理、30 orders/s 是否覆盖大促,仍需容量模型和业务输入。它也不能替代压测:没有 QA-PERF-01 的报告,NFR-01 永远只能停在“目标”。
小结
12.1 的产物不是一张更漂亮的表,而是一份能被 CI 拒收的基线:需求有编号,约束有影响,指标有三种明确状态,每条都有 owner 和证据。internal/baseline 把“目标与实测混用”“无 owner 的报告”“待验证却没有验证计划”变成稳定的错误码。下一节把这套需求映射到模块、部署单元和数据 owner,形成架构基线快照。
阅读导航:上一节:11.3 有状态服务的边界 · 下一节:12.2 初始架构与模块划分 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。