《后端架构设计·架构基础与边界》12.1 需求、约束与非功能目标

卷一收口的第一步,是把 arch-lab 的业务需求、技术与组织约束、延迟/可用性/容量/安全指标压成一份可机器校验的基线。本篇给出三种状态口径、一张标清目标与实测的 NFR 表,以及能拒收缺失 owner、目标与实测混用的 Go 校验器和真实基准输出。

收口章交付什么

第 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组织两个小队共用一个值班轮值跨模块故障必须有联合 runbookplatform
C-05业务支付渠道合同锁定到 2027-03适配器按渠道能力建模,不假设随时可换team-pay

NFR 基线表

ID指标口径与目标状态Owner证据或验证方式
NFR-01创建订单延迟staging 30 orders/s 持续 10 分钟,p95 ≤ 300 ms目标team-orderQA-PERF-01,记录 p95 与错误率
NFR-02订单查询延迟缓存命中率 90%,GET 单订单 p99 ≤ 150 ms待验证team-orderQA-PERF-02,目标值仍属候选
NFR-03月度可用性30 天 5xx 与超时占比 ≤ 0.1%,排除计划维护目标platform监控月报,尚无完整月度数据
NFR-04日订单容量峰值 30 orders/s 跑 30 分钟,日容量 ≥ 50000 单目标team-orderQA-CAP-01,按峰值外推
NFR-05基线校验耗时64 指标 + 16 约束,benchmark ns/op ≤ 5 ms实测platformgo test -bench=BenchmarkValidate -benchtime=200x -count=3
NFR-06恢复点目标 RPO主库故障丢失 ≤ 5 分钟数据目标platformQA-DR-01,主备切换演练
NFR-07支付回调重放拒绝率过期签名回调 100% 拒绝且状态不变待验证team-payQA-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 是按最慢值向上取整,不是取平均。

交接检查单

  1. 指标 ID 在基线与 ADR 中使用同一套编号,不另起一套。
  2. 每条 target 和 unverified 都有可执行的验证计划,不接受“上线后观察”。
  3. 每条 measured 的证据命令能被他人在同一提交上重跑。
  4. 每条指标和约束都有 owner,且 owner 在值班名册内。
  5. 基线版本号随内容变化递增,评审记录写明改了哪几条、为什么。

常见错误与边界

第一类错误是把目标写成实测:表格里写“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 初始架构与模块划分 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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