交接不是总结,而是把未知项登记清楚
卷一结束在一个不太舒服的位置:边界、契约、数据归属和部署单元都已经确定,但几乎没有可用的生产性能数字。日订单量、创建订单 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 | 团队规模 | measured | 2 人 | — | docs/team.md |
| B-02 | 部署单元 | decision | 1 个 | — | ADR-007 |
| B-03 | 日订单量 | unknown | 未测 | product | 2026-11-10,SQL 日计数 |
| B-04 | 创建订单 P99 | unknown | 未测 | performance | 2026-11-20,bench-order |
| B-05 | outbox 最老事件年龄 | unknown | 未测 | platform | 2026-11-25,outbox SQL |
| B-06 | 主库写 QPS | unknown | 未测 | dba | 2026-11-18,pg_stat 增量 |
| B-07 | 订单状态机路径 | decision | 4+1 状态 | — | 3.3 边界 |
| B-08 | 生产 5xx 比例 | unknown | 未测 | sre | 2026-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-02 | P99 目标与尾延迟预算是多少 | 是 | 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 评审一次真实架构方案 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。