《后端架构设计·演进与治理》13.3 四卷收口:架构、性能、一致性与治理的路线图

四卷收口篇把前三卷与卷四的能力整理成一张可校验的覆盖矩阵:用 L0 到 L3 的成熟度和文档、测试、演练、遥测四类证据约束声明,用依赖图与环检测排出路线顺序,并拒绝过期或越级的证据;给出可运行的 Go 校验器、跨四卷的示例矩阵与真实测试输出,最后说明这套治理如何持续运转。

读完之后,团队真正需要的是“还欠什么”

四卷读完,arch-lab 的架构评审会上有人问:我们到底覆盖了什么,下一步补哪一块?如果回答是一串文章标题,评审就无法判断优先级;如果回答是“都覆盖了”,那多半是把“写过”当成了“做到”。

收口篇不列书单,而是把能力当成资产登记:每一项能力属于哪一卷、由谁负责、成熟到哪一级、证据是什么、下一步做什么、依赖谁。登记表可以被程序校验,于是“覆盖”从形容词变成一份能拒绝过度声明的数据。三卷之间的技术衔接已在卷三收口 里做过一次,这一节把范围扩到四卷,并补上治理 maturity 与证据新鲜度。

四卷是一条依赖链,不是四个并列主题

依赖方向大体是从左到右:没有稳定边界,性能预算无处挂载;没有一致性保障,治理只能统计不可信的数字。但依赖不是严格串行,卷四的门禁会反过来暴露前三卷的缺口,例如 SLO 门禁发现某个服务的错误预算从未被度量,这就是一条要回填的依赖。

把依赖写成图还有第二个好处:能查环。团队常写出“发布门禁依赖金丝雀,金丝雀依赖自动回滚,自动回滚又依赖发布门禁”这样的环,口头评审很难发现,程序一次深度优先搜索就能指出路径。

成熟度必须由证据兑现

等级含义最低证据允许的对外表述
L0 临时靠人记得步骤无只能称“有做法”
L1 成文有 runbook 或 ADR文档可称“已定义”
L2 自动化流水线强制执行测试或流水线可称“已落地”
L3 持续验证证据按节奏刷新演练或遥测,且未过期可称“持续有效”

“文档不能证明自动化”是这张表最重要的一条:一份写得很好的发布规范,不能支撑“自动回滚已落地”的声明。同理,一次测试通过不能支撑“持续验证”,后者必须拿出带日期的演练或遥测证据,并且日期落在允许的证据年龄内。矩阵校验器把这三条都写成错误而不是警告,因为治理声明一旦被放宽,后面所有统计都会失真。

覆盖矩阵要能拒绝,而不只是能展示

校验器检查四类问题。结构类:ID 唯一、卷号在 1 到 4、章号在 1 到 13、标题与负责人非空。证据类:L1 以上必须有证据类型与引用,L2 以上不能只靠文档,L3 必须是演练或遥测且未过期。路线类:L2 以上必须有下一步动作,依赖必须存在且不能自指。覆盖类:四卷都必须至少有一项能力,依赖图不能有环。

环检测使用白、灰、黑三色深度优先搜索:灰色表示当前递归栈上的节点,再次遇到灰色即发现环,并输出从路径到重复节点的完整链条。未知依赖在环检测里跳过,因为结构校验已经报过,跳过可以避免一个坏引用掩盖真正的环。

可运行实现

// file: internal/evolve/maturity.go
// tier: L1
// verify: go test ./internal/evolve/ -count=1 -run TestMatrix
package evolve

import (
	"fmt"
	"sort"
	"strings"
	"time"
)

// Maturity is what is operated, not what has been written about.
type Maturity int

const (
	MaturityAdHoc                Maturity = 0 // people remember the steps
	MaturityDocumented           Maturity = 1 // a runbook or ADR exists
	MaturityAutomated            Maturity = 2 // a pipeline enforces it
	MaturityContinuouslyVerified Maturity = 3 // evidence is refreshed on a cadence
)

var maturityNames = [...]string{"L0-ad-hoc", "L1-documented", "L2-automated", "L3-continuous"}

func (m Maturity) String() string {
	if m >= 0 && int(m) < len(maturityNames) {
		return maturityNames[m]
	}
	return fmt.Sprintf("invalid(%d)", m)
}

// EvidenceKind says what backs a claim; a document cannot prove automation.
type EvidenceKind string

const (
	EvidenceDoc       EvidenceKind = "doc"
	EvidenceTest      EvidenceKind = "test"
	EvidenceDrill     EvidenceKind = "drill"
	EvidenceTelemetry EvidenceKind = "telemetry"
)

var evidenceKinds = map[EvidenceKind]bool{
	EvidenceDoc: true, EvidenceTest: true, EvidenceDrill: true, EvidenceTelemetry: true,
}

// Capability is one row of the four-volume coverage matrix.
type Capability struct {
	ID, Title, Owner string
	Volume, Chapter  int
	Maturity         Maturity
	Evidence         EvidenceKind
	EvidenceRef      string
	VerifiedAt       time.Time
	NextAction       string
	DependsOn        []string
}

// CoverageMatrix is the roadmap as data: coverage, maturity, owner, next step.
type CoverageMatrix struct {
	AsOf           time.Time
	MaxEvidenceAge time.Duration
	Capabilities   []Capability
}

// Validate is strict: maturity without matching evidence is an error.
func (m CoverageMatrix) Validate() []error {
	var errs []error
	fail := func(format string, args ...any) {
		errs = append(errs, fmt.Errorf(format, args...))
	}
	if m.AsOf.IsZero() {
		fail("as-of date is required")
	}
	if m.MaxEvidenceAge <= 0 {
		fail("max evidence age must be positive")
	}
	if len(m.Capabilities) == 0 {
		fail("matrix has no capabilities")
		return errs
	}
	seen, volumes := map[string]bool{}, map[int]bool{}
	byID := map[string]Capability{}
	// Index IDs first so a forward reference is not read as a missing capability.
	for _, c := range m.Capabilities {
		byID[c.ID] = c
	}
	for i, c := range m.Capabilities {
		where := fmt.Sprintf("capability[%d] %s", i, c.ID)
		if strings.TrimSpace(c.ID) == "" {
			fail("%s: id is empty", where)
		} else if seen[c.ID] {
			fail("%s: duplicate id %q", where, c.ID)
		}
		seen[c.ID] = true
		if c.Volume < 1 || c.Volume > 4 {
			fail("%s: volume %d outside 1..4", where, c.Volume)
		} else {
			volumes[c.Volume] = true
		}
		if c.Chapter < 1 || c.Chapter > 13 {
			fail("%s: chapter %d outside 1..13", where, c.Chapter)
		}
		if strings.TrimSpace(c.Title) == "" {
			fail("%s: title is empty", where)
		}
		if strings.TrimSpace(c.Owner) == "" {
			fail("%s: owner is empty", where)
		}
		if c.Maturity < MaturityAdHoc || c.Maturity > MaturityContinuouslyVerified {
			fail("%s: maturity %d outside 0..3", where, c.Maturity)
		}
		if c.Evidence != "" && !evidenceKinds[c.Evidence] {
			fail("%s: unknown evidence kind %q", where, c.Evidence)
		}
		if c.Maturity >= MaturityDocumented && c.Evidence == "" {
			fail("%s: %s needs an evidence kind", where, c.Maturity)
		}
		if c.Maturity >= MaturityDocumented && strings.TrimSpace(c.EvidenceRef) == "" {
			fail("%s: %s needs an evidence reference", where, c.Maturity)
		}
		if c.Maturity >= MaturityAutomated && c.Evidence == EvidenceDoc {
			fail("%s: %s cannot rest on a document", where, c.Maturity)
		}
		if c.Maturity >= MaturityAutomated && strings.TrimSpace(c.NextAction) == "" {
			fail("%s: %s needs a next action", where, c.Maturity)
		}
		if c.Maturity == MaturityContinuouslyVerified {
			if c.Evidence != EvidenceDrill && c.Evidence != EvidenceTelemetry {
				fail("%s: continuous claim needs drill or telemetry evidence", where)
			}
			if c.VerifiedAt.IsZero() || m.AsOf.Sub(c.VerifiedAt) > m.MaxEvidenceAge {
				fail("%s: evidence is stale or undated", where)
			}
		}
		for _, dep := range c.DependsOn {
			if dep == c.ID {
				fail("%s: depends on itself", where)
			} else if _, ok := byID[dep]; !ok {
				fail("%s: unknown dependency %q", where, dep)
			}
		}
	}
	for v := 1; v <= 4; v++ {
		if !volumes[v] {
			fail("volume %d has no capability", v)
		}
	}
	return append(errs, m.cycleErrors(byID)...)
}

// cycleErrors finds cycles; unknown dependencies are skipped here.
func (m CoverageMatrix) cycleErrors(byID map[string]Capability) []error {
	const white, gray, black = 0, 1, 2
	color := map[string]int{}
	var errs []error
	var visit func(id string, path []string)
	visit = func(id string, path []string) {
		switch color[id] {
		case gray:
			errs = append(errs, fmt.Errorf("dependency cycle: %s", strings.Join(append(path, id), " -> ")))
			return
		case black:
			return
		}
		color[id] = gray
		deps := append([]string(nil), byID[id].DependsOn...)
		sort.Strings(deps)
		for _, dep := range deps {
			if _, ok := byID[dep]; !ok {
				continue
			}
			visit(dep, append(path, id))
		}
		color[id] = black
	}
	ids := make([]string, 0, len(byID))
	for id := range byID {
		ids = append(ids, id)
	}
	sort.Strings(ids)
	for _, id := range ids {
		visit(id, nil)
	}
	return errs
}

// VolumeScore is one row of the maturity summary.
type VolumeScore struct {
	Volume  int
	Count   int
	Average float64
}

// Scores averages maturity per volume; an uncovered volume still shows up.
func (m CoverageMatrix) Scores() []VolumeScore {
	sum, count := map[int]int{}, map[int]int{}
	for _, c := range m.Capabilities {
		sum[c.Volume] += int(c.Maturity)
		count[c.Volume]++
	}
	out := make([]VolumeScore, 0, 4)
	for v := 1; v <= 4; v++ {
		score := VolumeScore{Volume: v, Count: count[v]}
		if count[v] > 0 {
			score.Average = float64(sum[v]) / float64(count[v])
		}
		out = append(out, score)
	}
	return out
}

// Overall is the mean maturity across all capabilities.
func (m CoverageMatrix) Overall() float64 {
	if len(m.Capabilities) == 0 {
		return 0
	}
	total := 0
	for _, c := range m.Capabilities {
		total += int(c.Maturity)
	}
	return float64(total) / float64(len(m.Capabilities))
}

示例矩阵为四卷各登记两项能力,其中两项达到 L3、四项达到 L2、一项 L1,依赖构成一条从左到右的链。测试同时验证评分与拒绝路径:

// file: internal/evolve/maturity_test.go
// tier: L1
// verify: go test ./internal/evolve/ -count=1 -run TestMatrix
func sampleMatrix() CoverageMatrix {
	verified := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
	reconciled := time.Date(2026, 10, 27, 9, 0, 0, 0, time.UTC)
	return CoverageMatrix{
		AsOf:           time.Date(2026, 11, 2, 10, 20, 0, 0, time.UTC),
		MaxEvidenceAge: 90 * 24 * time.Hour,
		Capabilities: []Capability{
			{ID: "boundary-modular-monolith", Volume: 1, Chapter: 4, Title: "模块边界与拆分评分卡", Owner: "架构组",
				Maturity: MaturityAutomated, Evidence: EvidenceTest, EvidenceRef: "go test ./internal/platform/split",
				NextAction: "把评分卡接入季度评审"},
			{ID: "latency-budget", Volume: 2, Chapter: 1, Title: "尾延迟预算", Owner: "性能组",
				Maturity: MaturityAutomated, Evidence: EvidenceTest, EvidenceRef: "go test ./internal/platform/slo",
				NextAction: "补齐跨服务预算", DependsOn: []string{"boundary-modular-monolith"}},
			{ID: "outbox-delivery", Volume: 3, Chapter: 8, Title: "事务性 outbox 投递", Owner: "订单组",
				Maturity: MaturityContinuouslyVerified, Evidence: EvidenceDrill, EvidenceRef: "drill-2026-10-20",
				VerifiedAt: verified, NextAction: "每季度重跑", DependsOn: []string{"latency-budget"}},
			{ID: "slo-gate", Volume: 4, Chapter: 7, Title: "错误预算发布门禁", Owner: "SRE",
				Maturity: MaturityContinuouslyVerified, Evidence: EvidenceTelemetry, EvidenceRef: "grafana/order-slo",
				VerifiedAt: time.Date(2026, 11, 1, 9, 0, 0, 0, time.UTC), NextAction: "覆盖库存服务",
				DependsOn: []string{"outbox-delivery"}},
			{ID: "adr-review", Volume: 1, Chapter: 2, Title: "ADR 评审", Owner: "架构组",
				Maturity: MaturityDocumented, Evidence: EvidenceDoc, EvidenceRef: "adr-014",
				NextAction: "把评审清单自动化"},
			{ID: "canary-release", Volume: 4, Chapter: 9, Title: "金丝雀发布", Owner: "发布组",
				Maturity: MaturityAutomated, Evidence: EvidenceTest, EvidenceRef: "go test ./internal/platform/release",
				NextAction: "接入自动回滚", DependsOn: []string{"slo-gate"}},
			{ID: "rate-limit", Volume: 2, Chapter: 6, Title: "限流与隔离", Owner: "网关组",
				Maturity: MaturityAutomated, Evidence: EvidenceTest, EvidenceRef: "go test ./internal/platform/gateway",
				NextAction: "补租户配额", DependsOn: []string{"latency-budget"}},
			{ID: "reconciliation", Volume: 3, Chapter: 9, Title: "实时对账", Owner: "订单组",
				Maturity: MaturityContinuouslyVerified, Evidence: EvidenceDrill, EvidenceRef: "drill-2026-10-27",
				VerifiedAt: reconciled, NextAction: "改为每日自动差异", DependsOn: []string{"outbox-delivery"}},
		},
	}
}

func TestMatrixAcceptsCoveredRoadmap(t *testing.T) {
	m := sampleMatrix()
	if errs := m.Validate(); len(errs) != 0 {
		t.Fatalf("valid matrix rejected: %v", errs)
	}
	scores := m.Scores()
	want := []float64{1.5, 2.0, 3.0, 2.5}
	for i, s := range scores {
		if s.Count != 2 || math.Abs(s.Average-want[i]) > 0.001 {
			t.Fatalf("volume %d = %+v, want count 2 average %.2f", s.Volume, s, want[i])
		}
	}
	if got := m.Overall(); math.Abs(got-2.25) > 0.001 {
		t.Fatalf("overall = %.2f, want 2.25", got)
	}
}

func TestMatrixRejectsOverclaimedMaturity(t *testing.T) {
	m := sampleMatrix()
	c := &m.Capabilities[0]
	c.Maturity = MaturityContinuouslyVerified
	c.Evidence = EvidenceDoc
	assertMatrixError(t, m, "cannot rest on a document")
	assertMatrixError(t, m, "continuous claim needs drill or telemetry evidence")
}

func assertMatrixError(t *testing.T, m CoverageMatrix, want string) {
	t.Helper()
	for _, err := range m.Validate() {
		if strings.Contains(err.Error(), want) {
			return
		}
	}
	t.Fatalf("missing error %q in %v", want, m.Validate())
}

本机实测

环境为 macOS darwin/arm64、Go 1.26.0。完整测试套件共 23 个用例,下面是其中四个用例的原始输出:

$ go test ./internal/evolve/ -count=1 -v -run TestMatrix
=== RUN   TestMatrixAcceptsCoveredRoadmap
--- PASS: TestMatrixAcceptsCoveredRoadmap (0.00s)
=== RUN   TestMatrixRejectsStaleContinuousClaim
--- PASS: TestMatrixRejectsStaleContinuousClaim (0.00s)
=== RUN   TestMatrixRejectsOverclaimedMaturity
--- PASS: TestMatrixRejectsOverclaimedMaturity (0.00s)
=== RUN   TestMatrixRejectsMissingNextAction
--- PASS: TestMatrixRejectsMissingNextAction (0.00s)
PASS
ok  	archlab/internal/evolve	0.239s

示例矩阵四卷平均成熟度分别为 1.5、2.0、3.0、2.5,总体 2.25;每一项都低于最高级,说明路线图仍有明确缺口,而不是一张“全部 L3”的漂亮报表。过度声明用例把第一项从 L2 改成 L3 却仍用文档作证据,校验器同时报出“不能只靠文档”和“持续声明需要演练或遥测”,两条都保留,避免修掉一条就以为问题消失。过期证据用例把日期推到 120 天前,超过了 90 天的证据年龄上限,同样被拒绝。

这些数字来自固定输入的单元测试,不代表任何真实组织的治理水平。真实使用时,AsOf、证据年龄和每项能力的证据引用需要按季度人工复核,校验器只负责让“写了什么”和“证据是什么”对不上时无法悄悄通过。

常见错误与边界

第一,把篇数当成熟度。文章数量说明覆盖了主题,不说明能力被运营。矩阵登记的是能力,不是目录条目;一篇讲金丝雀的文章不能自动让“金丝雀发布”达到 L2。

第二,只登记不写下一步。没有 NextAction 的 L2 以上能力会停在原地,半年后证据过期才发现。校验器强制要求下一步动作,把路线图从静态清单变成待办队列。

第三,依赖图不查环。人工评审容易漏掉三方互相依赖,白灰黑三色搜索能给出完整环路径。发现环之后要拆依赖,而不是把校验规则关掉。

第四,证据一次性写死。L3 的关键是新鲜度,VerifiedAt 加上 MaxEvidenceAge 才能表达“持续”。只写“已演练”而不写日期,等同于没有证据。

小结

四卷的关系不是四个并列主题,而是一条从边界、性能、一致性到治理的依赖链。把能力登记成数据之后,覆盖、成熟度、负责人、证据和下一步都可以被程序检查,“还欠什么”也从会议争论变成一次校验输出。

这套矩阵的价值不在评分高低,而在拒绝:拒绝没有证据的成熟度、拒绝只靠文档的自动化、拒绝过期仍宣称持续、拒绝缺卷和依赖环。治理成熟的标志不是所有项都到 L3,而是团队愿意让不合格的声明失败,并按 NextAction 一项项补齐。

至此四卷收口。架构、性能、一致性与治理最终都要回到同一件事:让系统在变化中仍然可解释、可验证、可回退。

阅读导航:上一节:13.2 一次带 SLO 门禁与混沌演练的发布 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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