从指标到场景
12.1 的 NFR 表给出了数字,但数字本身不可测。“p95 ≤ 300 ms”没有说明谁在什么环境下施加什么刺激,也没有说明系统应该给出什么响应。质量属性场景补的正是这六个位置:来源、刺激、制品、环境、响应、响应度量。
1.2 已经讲过质量属性与取舍,2.3 已经给过评审级风险台账。这一节不重复定义,只把 arch-lab 的五个场景与四条风险冻结成可校验的数据,并写明每条风险的关闭条件。
五个可测场景
| ID | 属性 | 刺激 | 响应与度量 | 状态 | Owner |
|---|---|---|---|---|---|
| QA-PERF-01 | performance | 促销流量 30 orders/s 持续 10 分钟 | 订单创建成功,p95 ≤ 300 ms,错误率 ≤ 0.1% | 目标 | team-order |
| QA-PERF-02 | performance | 同一 SKU 100 个并发预留 | 不超卖,超卖订单数 ≤ 0 | 目标 | team-stock |
| QA-AVAIL-01 | availability | 杀掉三个副本中的一个 | 流量转移,失败请求 ≤ 0.1% | 待验证 | platform |
| QA-SEC-01 | security | 重放过期支付回调 | 全部拒绝且订单状态不变,拒绝率 == 100% | 待验证 | team-pay |
| QA-MAINT-01 | maintainability | 替换支付渠道适配器 | 只改 payment 模块,变更源文件 ≤ 5 个 | 目标 | team-pay |
五个场景没有一个标成 verified。卷一没有做压测、故障注入或重放演练,所以状态只能是目标或待验证;表中的 300 ms、0.1%、5 个文件都是待验证的阈值,不是运行结果。场景的价值在于把“系统要快”变成别人可以复现的一次实验。
风险台账与关闭条件
| ID | 风险 | 概率×影响 | 关联 | Owner | 关闭条件 | 截止 | 状态 |
|---|---|---|---|---|---|---|---|
| R-12-01 | 性能目标从未实测 | 3×3 | QA-PERF-01 | team-order | staging 负载报告同时满足 p95 与错误率阈值 | 2026-11-30 | open |
| R-12-02 | 单进程爆炸半径覆盖全部模块 | 2×3 | 架构 | platform | 副本击杀演练中失败请求低于 0.1% | 2026-12-15 | open |
| R-12-03 | 跨模块 SQL 绕过数据 owner | 2×2 | 架构 | platform | 适应度函数连续 20 次发布拒绝越界访问 | 2026-11-20 | mitigated |
| R-12-04 | 回调重放防护未验证 | 2×3 | QA-SEC-01 | team-pay | 100 次重放全部拒绝且状态不变 | 2026-12-31 | open |
概率与影响都取 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 从需求到架构决策记录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。