《后端架构设计·架构基础与边界》12.3 质量属性场景与架构风险清单

卷一收口最后一步,把性能、可用性、安全与可维护性写成刺激-响应-度量的可测场景,再把未验证项整理成带概率、影响、owner 与可执行关闭条件的风险台账。本篇给出 Go 数据结构、属性覆盖与逾期校验测试,并明确所有阈值只是口径,不是压测结果。

从指标到场景

12.1 的 NFR 表给出了数字,但数字本身不可测。“p95 ≤ 300 ms”没有说明谁在什么环境下施加什么刺激,也没有说明系统应该给出什么响应。质量属性场景补的正是这六个位置:来源、刺激、制品、环境、响应、响应度量。

1.2 已经讲过质量属性与取舍,2.3 已经给过评审级风险台账。这一节不重复定义,只把 arch-lab 的五个场景与四条风险冻结成可校验的数据,并写明每条风险的关闭条件。

五个可测场景

ID属性刺激响应与度量状态Owner
QA-PERF-01performance促销流量 30 orders/s 持续 10 分钟订单创建成功,p95 ≤ 300 ms,错误率 ≤ 0.1%目标team-order
QA-PERF-02performance同一 SKU 100 个并发预留不超卖,超卖订单数 ≤ 0目标team-stock
QA-AVAIL-01availability杀掉三个副本中的一个流量转移,失败请求 ≤ 0.1%待验证platform
QA-SEC-01security重放过期支付回调全部拒绝且订单状态不变,拒绝率 == 100%待验证team-pay
QA-MAINT-01maintainability替换支付渠道适配器只改 payment 模块,变更源文件 ≤ 5 个目标team-pay

五个场景没有一个标成 verified。卷一没有做压测、故障注入或重放演练,所以状态只能是目标或待验证;表中的 300 ms、0.1%、5 个文件都是待验证的阈值,不是运行结果。场景的价值在于把“系统要快”变成别人可以复现的一次实验。

风险台账与关闭条件

ID风险概率×影响关联Owner关闭条件截止状态
R-12-01性能目标从未实测3×3QA-PERF-01team-orderstaging 负载报告同时满足 p95 与错误率阈值2026-11-30open
R-12-02单进程爆炸半径覆盖全部模块2×3架构platform副本击杀演练中失败请求低于 0.1%2026-12-15open
R-12-03跨模块 SQL 绕过数据 owner2×2架构platform适应度函数连续 20 次发布拒绝越界访问2026-11-20mitigated
R-12-04回调重放防护未验证2×3QA-SEC-01team-pay100 次重放全部拒绝且状态不变2026-12-31open

概率与影响都取 1..3,乘积得到 1..9 的暴露度,避免“高风险/中风险”这种无法排序的标签。风险不能只写“关注一下”:每条必须有 owner、关闭条件和截止日期。状态为 closed 时必须附证据;未关闭但已过截止日期的,校验器直接报 RISK_OVERDUE。

场景与风险的数据结构

// file: internal/quality/register.go
// tier: L2
// verify: go test ./internal/quality/ -count=1
package quality

import (
	"fmt"
	"math"
	"slices"
	"time"
)

// Scenario 是刺激-响应-度量,不是一个形容词。
type Scenario struct {
	ID          string  `json:"id"`
	Attribute   string  `json:"attribute"`
	Source      string  `json:"source"`
	Stimulus    string  `json:"stimulus"`
	Artifact    string  `json:"artifact"`
	Environment string  `json:"environment"`
	Response    string  `json:"response"`
	Measure     string  `json:"measure"`
	Comparator  string  `json:"comparator"`
	Threshold   float64 `json:"threshold"`
	Unit        string  `json:"unit"`
	Owner       string  `json:"owner"`
	Status      string  `json:"status"`
	Evidence    string  `json:"evidence,omitempty"`
	Plan        string  `json:"plan,omitempty"`
}

// Risk 的 Kind 为 scenario 时必须挂到场景;architecture 风险可以没有场景。
type Risk struct {
	ID             string `json:"id"`
	Title          string `json:"title"`
	Kind           string `json:"kind"`
	ScenarioID     string `json:"scenario_id,omitempty"`
	Probability    int    `json:"probability"`
	Impact         int    `json:"impact"`
	Owner          string `json:"owner"`
	CloseCondition string `json:"close_condition"`
	Due            string `json:"due"`
	Status         string `json:"status"`
	Evidence       string `json:"evidence,omitempty"`
}

type Register struct {
	Version   string     `json:"version"`
	Owners    []string   `json:"owners"`
	Scenarios []Scenario `json:"scenarios"`
	Risks     []Risk     `json:"risks"`
}

type Finding struct {
	Code    string `json:"code"`
	Ref     string `json:"ref"`
	Message string `json:"message"`
}

var requiredAttributes = []string{"performance", "availability", "security", "maintainability"}

// Validate 检查场景是否可测、风险是否有 owner 与关闭条件,并按 now 判断逾期。
func (r Register) Validate(now time.Time) []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 r.Version == "" {
		add("REGISTER_VERSION", "register", "version is required")
	}
	owners := map[string]bool{}
	for _, o := range r.Owners {
		if o == "" {
			add("OWNER_EMPTY", "owners", "owner must not be empty")
		}
		owners[o] = true
	}
	scenarios := map[string]Scenario{}
	attributes := map[string]bool{}
	for _, s := range r.Scenarios {
		if _, ok := scenarios[s.ID]; ok {
			add("SCENARIO_DUPLICATE", s.ID, "scenario id listed twice")
			continue
		}
		scenarios[s.ID] = s
		attributes[s.Attribute] = true
		if s.Source == "" || s.Stimulus == "" || s.Artifact == "" || s.Environment == "" || s.Response == "" || s.Measure == "" {
			add("SCENARIO_INCOMPLETE", s.ID, "source/stimulus/artifact/environment/response/measure are required")
		}
		if !slices.Contains([]string{"<=", ">=", "<", ">", "=="}, s.Comparator) {
			add("SCENARIO_COMPARATOR", s.ID, "comparator %q is not <= >= < > or ==", s.Comparator)
		}
		if math.IsNaN(s.Threshold) || math.IsInf(s.Threshold, 0) || s.Threshold < 0 {
			add("SCENARIO_THRESHOLD", s.ID, "threshold must be finite and non-negative")
		}
		if s.Unit == "" {
			add("SCENARIO_UNIT", s.ID, "unit is required")
		}
		if !owners[s.Owner] {
			add("SCENARIO_OWNER", s.ID, "owner %q not in roster", s.Owner)
		}
		switch s.Status {
		case "verified":
			if s.Evidence == "" {
				add("VERIFIED_NO_EVIDENCE", s.ID, "verified scenario needs evidence")
			}
		case "target", "unverified":
			if s.Plan == "" {
				add("SCENARIO_NO_PLAN", s.ID, "target or unverified scenario needs a measurement plan")
			}
		default:
			add("SCENARIO_STATUS", s.ID, "unknown status %q", s.Status)
		}
	}
	for _, attr := range requiredAttributes {
		if !attributes[attr] {
			add("ATTRIBUTE_COVERAGE", attr, "no scenario covers attribute %q", attr)
		}
	}
	risks := map[string]Risk{}
	for _, x := range r.Risks {
		if _, ok := risks[x.ID]; ok {
			add("RISK_DUPLICATE", x.ID, "risk id listed twice")
			continue
		}
		risks[x.ID] = x
		if x.Title == "" || x.CloseCondition == "" {
			add("RISK_INCOMPLETE", x.ID, "title and close condition are required")
		}
		if x.Probability < 1 || x.Probability > 3 || x.Impact < 1 || x.Impact > 3 {
			add("RISK_LEVEL", x.ID, "probability and impact must be 1..3")
		}
		if !owners[x.Owner] {
			add("RISK_OWNER", x.ID, "owner %q not in roster", x.Owner)
		}
		switch x.Kind {
		case "scenario":
			if _, ok := scenarios[x.ScenarioID]; !ok {
				add("RISK_SCENARIO", x.ID, "scenario %q does not exist", x.ScenarioID)
			}
		case "architecture":
			if x.ScenarioID != "" {
				add("RISK_SCENARIO", x.ID, "architecture risk must not reference a scenario")
			}
		default:
			add("RISK_KIND", x.ID, "kind %q must be scenario or architecture", x.Kind)
		}
		switch x.Status {
		case "open", "mitigated":
		case "closed":
			if x.Evidence == "" {
				add("RISK_CLOSED_NO_EVIDENCE", x.ID, "closed risk needs evidence")
			}
		default:
			add("RISK_STATUS", x.ID, "unknown status %q", x.Status)
		}
		if _, err := time.Parse(time.DateOnly, x.Due); err != nil {
			add("RISK_DUE", x.ID, "due must be YYYY-MM-DD")
		} else if x.Status != "closed" && now.Format(time.DateOnly) > x.Due {
			add("RISK_OVERDUE", x.ID, "open risk is past due %s", x.Due)
		}
	}
	return out
}

// Exposure 用 1..9 的乘积排序,不引入小数概率,避免“高风险”无法比较。
func (r Risk) Exposure() int {
	return r.Probability * r.Impact
}

测试:不可测场景与无关闭条件的风险被拒收

// file: internal/quality/register_test.go
// tier: L2
// verify: go test ./internal/quality/ -run 'TestValidateClean|TestRejects|TestOverdueRisk|TestExposure' -v
package quality

import (
	"testing"
	"time"
)

func fixture() Register {
	return Register{
		Version: "arch-lab-v1.0.0",
		Owners:  []string{"team-order", "team-stock", "team-pay", "platform"},
		Scenarios: []Scenario{
			{ID: "QA-PERF-01", Attribute: "performance", Source: "promotion traffic", Stimulus: "30 orders/s for 10 min", Artifact: "POST /api/v1/orders", Environment: "staging, 3 replicas", Response: "orders created, errors below 0.1%", Measure: "p95 latency", Comparator: "<=", Threshold: 300, Unit: "ms", Owner: "team-order", Status: "target", Plan: "k6 scenario, record p95 and error rate"},
			{ID: "QA-PERF-02", Attribute: "performance", Source: "same SKU buyers", Stimulus: "100 concurrent reservations", Artifact: "inventory reservation", Environment: "staging, one SKU", Response: "no oversell, conflicts retried", Measure: "oversold orders", Comparator: "<=", Threshold: 0, Unit: "orders", Owner: "team-stock", Status: "target", Plan: "concurrency test with 100 goroutines"},
			{ID: "QA-AVAIL-01", Attribute: "availability", Source: "node failure", Stimulus: "kill one of three replicas", Artifact: "orderd", Environment: "staging, steady 30 orders/s", Response: "traffic shifts, retries succeed", Measure: "failed requests", Comparator: "<=", Threshold: 0.1, Unit: "%", Owner: "platform", Status: "unverified", Plan: "chaos run with replica kill during load"},
			{ID: "QA-SEC-01", Attribute: "security", Source: "attacker", Stimulus: "replay expired payment callback", Artifact: "POST /orders/{id}/pay", Environment: "staging with fake provider", Response: "reject and leave state unchanged", Measure: "rejected callbacks", Comparator: "==", Threshold: 100, Unit: "%", Owner: "team-pay", Status: "unverified", Plan: "replay 100 captured callbacks"},
			{ID: "QA-MAINT-01", Attribute: "maintainability", Source: "payment provider change", Stimulus: "swap fake provider adapter", Artifact: "payment module", Environment: "clean worktree", Response: "only payment files change", Measure: "changed source files", Comparator: "<=", Threshold: 5, Unit: "files", Owner: "team-pay", Status: "target", Plan: "diff file count in a dry-run branch"},
		},
		Risks: []Risk{
			{ID: "R-12-01", Title: "性能目标从未实测", Kind: "scenario", ScenarioID: "QA-PERF-01", Probability: 3, Impact: 3, Owner: "team-order", CloseCondition: "staging load report meets p95 and error thresholds", Due: "2026-11-30", Status: "open"},
			{ID: "R-12-02", Title: "单进程爆炸半径覆盖全部模块", Kind: "architecture", Probability: 2, Impact: 3, Owner: "platform", CloseCondition: "replica kill drill keeps failed requests under 0.1%", Due: "2026-12-15", Status: "open"},
			{ID: "R-12-03", Title: "跨模块 SQL 绕过 owner", Kind: "architecture", Probability: 2, Impact: 2, Owner: "platform", CloseCondition: "fitness function rejects cross-module table access for 20 releases", Due: "2026-11-20", Status: "mitigated"},
			{ID: "R-12-04", Title: "回调重放防护未验证", Kind: "scenario", ScenarioID: "QA-SEC-01", Probability: 2, Impact: 3, Owner: "team-pay", CloseCondition: "100 replay attempts all rejected with no state change", Due: "2026-12-31", Status: "open"},
		},
	}
}

func scenario(r *Register, id string) *Scenario {
	for i := range r.Scenarios {
		if r.Scenarios[i].ID == id {
			return &r.Scenarios[i]
		}
	}
	panic("scenario not found: " + id)
}

func risk(r *Register, id string) *Risk {
	for i := range r.Risks {
		if r.Risks[i].ID == id {
			return &r.Risks[i]
		}
	}
	panic("risk 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) {
	now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
	if got := fixture().Validate(now); len(got) != 0 {
		t.Fatalf("clean register returned %v", got)
	}
}

func TestRejectsUnmeasurableScenariosAndRisks(t *testing.T) {
	tests := []struct {
		name   string
		mutate func(*Register)
		code   string
	}{
		{"scenario without owner", func(r *Register) { scenario(r, "QA-PERF-01").Owner = "team-ghost" }, "SCENARIO_OWNER"},
		{"verified without evidence", func(r *Register) {
			s := scenario(r, "QA-PERF-01")
			s.Status, s.Evidence = "verified", ""
		}, "VERIFIED_NO_EVIDENCE"},
		{"comparator is vague", func(r *Register) { scenario(r, "QA-PERF-01").Comparator = "approximately" }, "SCENARIO_COMPARATOR"},
		{"attribute not covered", func(r *Register) { r.Scenarios = r.Scenarios[:4] }, "ATTRIBUTE_COVERAGE"},
		{"risk points to missing scenario", func(r *Register) { risk(r, "R-12-04").ScenarioID = "QA-GHOST" }, "RISK_SCENARIO"},
		{"closed risk without evidence", func(r *Register) {
			x := risk(r, "R-12-01")
			x.Status, x.Evidence = "closed", ""
		}, "RISK_CLOSED_NO_EVIDENCE"},
		{"probability out of range", func(r *Register) { risk(r, "R-12-02").Probability = 5 }, "RISK_LEVEL"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			r := fixture()
			tc.mutate(&r)
			now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
			if findings := r.Validate(now); !has(findings, tc.code) {
				t.Fatalf("want %s, got %v", tc.code, findings)
			}
		})
	}
}

func TestOverdueRisk(t *testing.T) {
	later := time.Date(2027, 2, 1, 0, 0, 0, 0, time.UTC)
	if findings := fixture().Validate(later); !has(findings, "RISK_OVERDUE") {
		t.Fatalf("want RISK_OVERDUE, got %v", findings)
	}
}

func TestExposure(t *testing.T) {
	if got := (Risk{Probability: 3, Impact: 3}).Exposure(); got != 9 {
		t.Fatalf("exposure = %d, want 9", got)
	}
}

真实运行结果(macOS arm64,Go 1.26.0):

$ go test ./internal/quality -count=1
ok  	archlab/internal/quality	0.478s

$ go test ./internal/quality -run 'TestValidateClean|TestRejects|TestOverdueRisk|TestExposure' -v
--- PASS: TestValidateClean (0.00s)
--- PASS: TestRejectsUnmeasurableScenariosAndRisks (0.00s)
    --- PASS: TestRejectsUnmeasurableScenariosAndRisks/scenario_without_owner (0.00s)
    --- PASS: TestRejectsUnmeasurableScenariosAndRisks/verified_without_evidence (0.00s)
    --- PASS: TestRejectsUnmeasurableScenariosAndRisks/comparator_is_vague (0.00s)
    --- PASS: TestRejectsUnmeasurableScenariosAndRisks/attribute_not_covered (0.00s)
    --- PASS: TestRejectsUnmeasurableScenariosAndRisks/risk_points_to_missing_scenario (0.00s)
    --- PASS: TestRejectsUnmeasurableScenariosAndRisks/closed_risk_without_evidence (0.00s)
    --- PASS: TestRejectsUnmeasurableScenariosAndRisks/probability_out_of_range (0.00s)
--- PASS: TestOverdueRisk (0.00s)
--- PASS: TestExposure (0.00s)
PASS

测试里没有一条 verified 场景,这是刻意的。没有真实负载报告、故障注入记录或重放日志,就不能把状态改成 verified;RISK_CLOSED_NO_EVIDENCE 会拦住“口头关闭”。

与前面章节的分工

2.3 的风险台账服务于一次架构评审,记录的是“当时谁反对什么”;这里的风险台账服务于交接,强调每条风险的场景锚点、关闭条件和截止日期。8.3 的适应度函数负责持续执行边界规则,R-12-03 的关闭条件正是依赖它连续 20 次发布的结果。三者不互相替代:评审决定要不要做,场景定义怎么算做完,适应度函数保证做完之后没有回退。

常见错误与边界

把“高可用”直接当场景,是缺少刺激与度量的典型;把“p99 小于 200 ms”写成响应,却没有说明来源、环境和制品,也无法复现。风险台账最常见的错误是只有标题没有关闭条件,导致风险永远 open。另一种错误是把目标阈值直接改成实测值,让报告看起来达标,这比没有数据更危险。

校验器只能检查结构:它不知道 300 ms 对当前业务是否合理,也不知道 R-12-02 的演练是否真的做过。场景和风险的最终可信度仍取决于证据;没有证据时,成本最低的诚实写法就是“待验证”。

小结

12.3 把质量属性从形容词变成五个刺激-响应-度量场景,把未验证项变成四条有 owner、关闭条件和截止日期的风险。internal/quality 校验场景六要素、属性覆盖、风险等级与逾期状态,并拒绝没有证据的 verified 和 closed。至此卷一的基线闭环完成:12.1 定义指标与证据,12.2 冻结模块与数据 owner,12.3 把剩余不确定性登记成可跟踪的风险,交给卷二继续验证。

阅读导航:上一节:12.2 初始架构与模块划分 · 下一节:13.1 从需求到架构决策记录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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