《后端架构设计·架构基础与边界》13.3 进入卷二:性能与可用性

把卷一的基线、未知项和未决问题整理成可交接清单:八条基线中五项没有生产数据,六个问题全部带 owner、截止日期和对应卷二主题;Go 就绪门禁检查 measured 是否可复核、unknown 是否有计划、阻塞问题是否有截止日期,并明确“可以开始卷二”不等于“可以宣称性能指标”。

交接不是总结,而是把未知项登记清楚

卷一结束在一个不太舒服的位置:边界、契约、数据归属和部署单元都已经确定,但几乎没有可用的生产性能数字。日订单量、创建订单 P99、主库写 QPS、outbox 积压、5xx 比例,全都还是未知。把这些未知项藏起来,卷二就会在错误的基础上做容量规划;把它们登记成带 owner、口径和截止日期的条目,卷二才能从第一页开始就知道要测什么。

本节只做交接,不写卷二正文。吞吐、尾延迟、容量和故障的具体算法、压测方法与数字,留到卷二展开;这里交付的是输入清单、未决问题,以及一个能检查清单是否完整的 Go 门禁。

卷二会问的四个新问题

卷一的决策大多回答“边界在哪里”,卷二的问题则追问“代价是多少”。四个方向对应四类新证据:

新问题卷一已经回答卷二要继续追问相关章节
吞吐单库、单部署单元、模块边界峰值 QPS 与并发数是多少,瓶颈在数据库还是应用2、12
尾延迟支付回调 500ms 的质量场景P99/P999 是多少,超时预算如何沿调用链分配1、7
容量单实例拓扑与故障域单实例能扛多少,扩容阈值和成本是多少13
故障单点与故障域已经识别哪三个场景必须演练,降级和恢复时间是多少8、13

注意“相关章节”是卷二目录,不是本节的结论。本节不提前给出答案,只保证这些问题在交接时已经被写下来,并且有人负责。

输入清单:把“有什么”和“缺什么”分开

八条基线分成三类:一条 measured(团队规模),两条 decision(部署单元、状态机路径),五条 unknown(订单量、P99、outbox 年龄、写 QPS、5xx 比例)。unknown 不是失败,失败的是没有 owner、没有口径、没有截止日期的 unknown。

ID基线项类型当前值谁补计划
B-01团队规模measured2 人—docs/team.md
B-02部署单元decision1 个—ADR-007
B-03日订单量unknown未测product2026-11-10,SQL 日计数
B-04创建订单 P99unknown未测performance2026-11-20,bench-order
B-05outbox 最老事件年龄unknown未测platform2026-11-25,outbox SQL
B-06主库写 QPSunknown未测dba2026-11-18,pg_stat 增量
B-07订单状态机路径decision4+1 状态—3.3 边界
B-08生产 5xx 比例unknown未测sre2026-11-22,PromQL

B-01 是唯一 measured 项,比例因此只有 16%(1 ÷ (1+5))。这个数字难看,但它诚实;如果把五个 unknown 随手填成“预计值”,比例会变成 100%,卷二却会在假基线上做容量规划。

未决问题:每个问题都要有 owner 和截止日期

六个问题里有三个是阻塞项:不知道峰值 QPS、P99 目标和单实例容量,卷二无法给出可信的扩容结论。非阻塞项也要有 owner,否则“以后再看”会变成没人看。

ID未决问题阻塞对应卷二主题Owner / 截止
Q-01峰值 QPS 与并发数是多少是2 从业务指标到 QPS 与并发数product / 11-10
Q-02P99 目标与尾延迟预算是多少是1 尾部延迟;7 超时预算performance / 11-15
Q-03单实例容量与扩容阈值是多少是13 容量规划platform / 12-01
Q-04哪三个故障场景必须演练否8 故障域;13 故障场景sre / 12-05
Q-05缓存与异步改造的收益阈值否3 缓存;4 同步改异步architecture / 12-10
Q-06连接池、限流与超时预算否5 池;6 限流;7 超时performance / 12-12

三个阻塞项都指向同一件事:卷二必须先建立口径,再谈优化。没有峰值 QPS,缓存命中率提高多少都无法换算成容量;没有 P99 目标,异步化减少的延迟无法判断是否值得。

就绪门禁:结构完整不等于数字齐全

下面的包把交接清单编码成结构,并提供三类检查:measured 项必须有来源、可复现命令、值和单位;unknown/estimated 项必须有 owner、验证方式和合法日期;问题必须有 owner 和对应卷二主题,阻塞问题还必须有截止日期。

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

import (
	"strings"
	"time"
)

type (
	Kind string
	Item struct {
		ID, Name, Value, Unit string
		Kind                  Kind
		Source, Owner, Due    string
		Verify                string
	}
	Question struct {
		ID, Question, Topic string
		Blocking            bool
		Owner, Due          string
	}
	Handoff struct {
		From, To, Date string
		Items          []Item
		Questions      []Question
	}
	Gap struct{ Code, Ref, Detail string }
)

const (
	KindMeasured  Kind = "measured"
	KindEstimated Kind = "estimated"
	KindUnknown   Kind = "unknown"
	KindDecision  Kind = "decision"

	CodeBadDate          = "BAD_DATE"
	CodeBadKind          = "BAD_KIND"
	CodeMeasuredNoSource = "MEASURED_NO_SOURCE"
	CodeMeasuredNoVerify = "MEASURED_NO_VERIFY"
	CodePlanIncomplete   = "PLAN_INCOMPLETE"
	CodeBadDue           = "BAD_DUE"
	CodeDuplicateID      = "DUPLICATE_ID"
	CodeQuestionNoOwner  = "QUESTION_NO_OWNER"
	CodeBlockingNoDue    = "BLOCKING_NO_DUE"
	CodeQuestionNoTopic  = "QUESTION_NO_TOPIC"
)

func (k Kind) Valid() bool {
	return k == KindMeasured || k == KindEstimated || k == KindUnknown || k == KindDecision
}

func validDate(s string) bool {
	_, err := time.Parse("2006-01-02", s)
	return err == nil
}

func (h Handoff) Gaps() []Gap {
	var out []Gap
	add := func(code, ref, detail string) { out = append(out, Gap{code, ref, detail}) }
	if _, err := time.Parse(time.RFC3339, h.Date); err != nil {
		add(CodeBadDate, h.From+">"+h.To, h.Date)
	}
	seen := make(map[string]bool, len(h.Items)+len(h.Questions))
	for _, it := range h.Items {
		if seen[it.ID] {
			add(CodeDuplicateID, it.ID, "declared twice")
		}
		seen[it.ID] = true
		if !it.Kind.Valid() {
			add(CodeBadKind, it.ID, string(it.Kind))
			continue
		}
		if it.Kind == KindMeasured {
			if strings.TrimSpace(it.Source) == "" {
				add(CodeMeasuredNoSource, it.ID, "measured needs source")
			}
			if strings.TrimSpace(it.Verify) == "" {
				add(CodeMeasuredNoVerify, it.ID, "measured needs verify command")
			}
			if it.Value == "" || it.Unit == "" {
				add(CodePlanIncomplete, it.ID, "measured needs value and unit")
			}
		}
		if it.Kind == KindEstimated || it.Kind == KindUnknown {
			if it.Owner == "" || it.Verify == "" {
				add(CodePlanIncomplete, it.ID, "owner and verify required")
			}
			if !validDate(it.Due) {
				add(CodeBadDue, it.ID, it.Due)
			}
		}
		if it.Kind == KindDecision && strings.TrimSpace(it.Source) == "" {
			add(CodePlanIncomplete, it.ID, "decision needs source")
		}
	}
	for _, q := range h.Questions {
		if seen[q.ID] {
			add(CodeDuplicateID, q.ID, "declared twice")
		}
		seen[q.ID] = true
		if q.Owner == "" {
			add(CodeQuestionNoOwner, q.ID, "owner required")
		}
		if strings.TrimSpace(q.Topic) == "" {
			add(CodeQuestionNoTopic, q.ID, "must map to volume two topic")
		}
		if q.Blocking && !validDate(q.Due) {
			add(CodeBlockingNoDue, q.ID, q.Due)
		}
	}
	return out
}

func (h Handoff) Ready() bool { return len(h.Gaps()) == 0 }

// MeasuredRatio 只统计可测量的项;决策不是指标,不进入分母。
func (h Handoff) MeasuredRatio() int {
	measured, total := 0, 0
	for _, it := range h.Items {
		switch it.Kind {
		case KindMeasured:
			measured++
			total++
		case KindEstimated, KindUnknown:
			total++
		}
	}
	if total == 0 {
		return 0
	}
	return measured * 100 / total
}

func (h Handoff) UnknownCount() int {
	n := 0
	for _, it := range h.Items {
		if it.Kind == KindUnknown || it.Kind == KindEstimated {
			n++
		}
	}
	return n
}

func (h Handoff) BlockingQuestions() int {
	n := 0
	for _, q := range h.Questions {
		if q.Blocking {
			n++
		}
	}
	return n
}

Ready() 只回答“清单结构是否完整”,不回答“性能是否达标”。这两个问题必须分开,否则门禁会被误读成性能验收。

基线数据

基线数据是普通的结构体字面量,没有隐藏逻辑。五条 unknown 都带 owner、日期和验证命令;两条 decision 带 ADR 来源,因为它们不是指标,不需要单位和测量值。

// file: internal/handoff/volume_one.go
// tier: L1
// verify: go test ./internal/handoff/ -run TestVolumeOneHandoff -v
package handoff

// VolumeOne 是交给卷二的基线;unknown 不是失败,而是带 owner 和计划的未知项。
func VolumeOne() Handoff {
	return Handoff{
		From: "卷一 架构基础与边界", To: "卷二 性能与可用性", Date: "2026-10-12T04:00:00+08:00",
		Items: []Item{
			{ID: "B-01", Name: "团队规模", Kind: KindMeasured, Value: "2", Unit: "人", Source: "项目花名册", Verify: "docs/team.md"},
			{ID: "B-02", Name: "部署单元", Kind: KindDecision, Value: "1", Unit: "个", Source: "ADR-007 单部署单元", Verify: "ls cmd/"},
			{ID: "B-03", Name: "日订单量", Kind: KindUnknown, Unit: "单/日", Owner: "product", Due: "2026-11-10", Verify: "SELECT count(*) FROM orders WHERE created_at >= now() - interval '1 day'"},
			{ID: "B-04", Name: "创建订单 P99", Kind: KindUnknown, Unit: "ms", Owner: "performance", Due: "2026-11-20", Verify: "bench-order --p99"},
			{ID: "B-05", Name: "outbox 最老事件年龄", Kind: KindUnknown, Unit: "s", Owner: "platform", Due: "2026-11-25", Verify: "SELECT max(now()-created_at) FROM outbox WHERE sent_at IS NULL"},
			{ID: "B-06", Name: "主库写 QPS", Kind: KindUnknown, Unit: "QPS", Owner: "dba", Due: "2026-11-18", Verify: "pg_stat_database.xact_commit 每秒增量"},
			{ID: "B-07", Name: "订单状态机路径", Kind: KindDecision, Value: "4+1", Unit: "状态", Source: "3.3 聚合与一致性边界", Verify: "go test ./internal/order/"},
			{ID: "B-08", Name: "生产 5xx 比例", Kind: KindUnknown, Unit: "%", Owner: "sre", Due: "2026-11-22", Verify: "rate(http_requests_total{code=~\"5..\"}[5m])"},
		},
		Questions: []Question{
			{ID: "Q-01", Question: "峰值 QPS 与并发数是多少?", Topic: "卷二 2 从业务指标到 QPS 与并发数", Blocking: true, Owner: "product", Due: "2026-11-10"},
			{ID: "Q-02", Question: "创建订单 P99 目标与尾延迟预算是多少?", Topic: "卷二 1 吞吐、延迟与尾部延迟;7 超时预算", Blocking: true, Owner: "performance", Due: "2026-11-15"},
			{ID: "Q-03", Question: "单实例容量与扩容阈值是多少?", Topic: "卷二 13 一次完整的容量规划", Blocking: true, Owner: "platform", Due: "2026-12-01"},
			{ID: "Q-04", Question: "哪三个故障场景必须演练?", Topic: "卷二 8 冗余、故障域;13 三个故障场景", Owner: "sre", Due: "2026-12-05"},
			{ID: "Q-05", Question: "缓存与异步改造的收益阈值是什么?", Topic: "卷二 3 缓存;4 同步改异步", Owner: "architecture", Due: "2026-12-10"},
			{ID: "Q-06", Question: "连接池、限流与超时预算如何分配?", Topic: "卷二 5 池;6 限流;7 超时", Owner: "performance", Due: "2026-12-12"},
		},
	}
}

需要强调的是,B-03 到 B-06、B-08 的 Verify 是计划中的测量命令,不是已经跑出的结果。它们的作用是让下一个负责人知道“用什么口径补”,而不是冒充证据。

用测试固定门禁规则

测试先断言真实交接清单零缺口,并锁定 16% 的 measured 比例、5 个 unknown 和 3 个阻塞问题;再用表驱动用例逐条破坏规则,确认每个错误码都能被触发。

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

import "testing"

func hasCode(gaps []Gap, want string) bool {
	for _, gap := range gaps {
		if gap.Code == want {
			return true
		}
	}
	return false
}

func TestVolumeOneHandoff(t *testing.T) {
	h := VolumeOne()
	if gaps := h.Gaps(); len(gaps) != 0 {
		t.Fatalf("VolumeOne() has %d gap(s): %v", len(gaps), gaps)
	}
	if !h.Ready() || h.MeasuredRatio() != 16 || h.UnknownCount() != 5 || h.BlockingQuestions() != 3 {
		t.Fatalf("ready/ratio/unknown/blocking = %v/%d/%d/%d",
			h.Ready(), h.MeasuredRatio(), h.UnknownCount(), h.BlockingQuestions())
	}
	t.Logf("gaps=0 items=%d questions=%d measured_ratio=%d unknowns=%d blocking=%d",
		len(h.Items), len(h.Questions), h.MeasuredRatio(), h.UnknownCount(), h.BlockingQuestions())
}

func TestGapRules(t *testing.T) {
	tests := []struct {
		name   string
		mutate func(*Handoff)
		want   string
	}{
		{"bad handoff date", func(h *Handoff) { h.Date = "2026-10-12" }, CodeBadDate},
		{"bad item kind", func(h *Handoff) { h.Items[0].Kind = "guess" }, CodeBadKind},
		{"measured without source", func(h *Handoff) { h.Items[0].Source = "" }, CodeMeasuredNoSource},
		{"measured without verify", func(h *Handoff) { h.Items[0].Verify = "" }, CodeMeasuredNoVerify},
		{"unknown without owner", func(h *Handoff) { h.Items[2].Owner = "" }, CodePlanIncomplete},
		{"unknown without due", func(h *Handoff) { h.Items[2].Due = "2026/11/10" }, CodeBadDue},
		{"duplicate id", func(h *Handoff) { h.Items[3].ID = h.Items[2].ID }, CodeDuplicateID},
		{"question without owner", func(h *Handoff) { h.Questions[0].Owner = "" }, CodeQuestionNoOwner},
		{"blocking without due", func(h *Handoff) { h.Questions[0].Due = "" }, CodeBlockingNoDue},
		{"question without topic", func(h *Handoff) { h.Questions[0].Topic = "" }, CodeQuestionNoTopic},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			h := VolumeOne()
			tt.mutate(&h)
			if !hasCode(h.Gaps(), tt.want) {
				t.Fatalf("gaps = %v, want %s", h.Gaps(), tt.want)
			}
		})
	}
}

真实运行结果

以下命令在本机 macOS(darwin/arm64,Go 1.26.0)真实执行,输出未改写:

$ go test ./internal/handoff/ -run TestVolumeOneHandoff -count=1 -v
=== RUN   TestVolumeOneHandoff
    handoff_test.go:26: gaps=0 items=8 questions=6 measured_ratio=16 unknowns=5 blocking=3
--- PASS: TestVolumeOneHandoff (0.00s)
PASS
ok  	archlab/internal/handoff	0.240s

$ go test ./internal/handoff/ -run TestGapRules -v | grep -c '^    --- PASS'
10

门禁零缺口,说明八条基线和六个问题都具备 owner、口径和日期;十条破坏性用例全部被捕获,说明规则不是装饰。它仍然不能证明任何性能数字,因为 measured 项只有一个。

16% 的 measured 比例说明什么

第一,卷一的产出是结构,不是性能。模块边界、事务边界、端口和部署单元都已经确定,但这些结构能承受多少流量,卷一没有测,也不应该假装测过。

第二,门禁通过和性能达标是两件事。Ready() 为真只表示“卷二可以开始问问题”,不表示“系统可以按当前容量上线”。把这两件事混在一起,交接就会变成一次虚假的验收。

第三,卷二的第一项工作不是优化,而是把 B-03、B-04、B-06 从 unknown 变成 measured。先有同口径的基线,后面的缓存、异步、限流和扩容才有比较对象;否则所有“提升”都无法证伪。

常见错误与边界

第一,把 unknown 填成估算值。没有来源的“预计 5000 QPS”会让后续容量规划建立在假数字上;宁可保留 unknown,也不要制造精确的幻觉。

第二,只写指标名,不写口径和命令。“P99 是多少”必须配“在什么负载、什么时间窗、用什么命令测”,否则两个人会报出两个不可比的数。

第三,问题没有 owner 和截止日期。“待定”“后续跟进”不是交接,只是把不确定性推迟;阻塞问题尤其需要日期,否则卷二无法判断何时可以开始。

第四,把门禁通过当成性能验收。门禁检查的是清单结构,不是系统能力;真正的性能结论只能来自卷二同口径的测量和故障演练。

小结

卷一交给卷二的是八条基线、六个未决问题和一套可执行门禁:一条 measured、两条 decision、五条 unknown,measured 比例 16%,三个阻塞问题都带 owner 和截止日期。internal/handoff 保证 unknown 不会静默消失,也保证 measured 项有来源和验证命令。卷二将从这些未知项开始,先建立吞吐、尾延迟、容量与故障的同口径基线,再讨论缓存、异步、限流和扩容。

阅读导航:上一节:13.2 评审一次真实架构方案 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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