《后端架构设计·架构基础与边界》2.3 一次真实评审记录与风险台账

以一次明确标注为演练的 arch-lab 卷一评审为线索,记录订单、库存、支付、SRE、安全五类角色的议程、五个决策点的反对意见与结论;给出覆盖一致性、可用性、安全、演进、运维的九条风险台账,并用 Go 工具校验必填字段、等级矩阵、负责人与关闭条件,附真实命令输出。

先说清这不是哪家公司的会议

这是基于 arch-lab 的评审演练,不是某公司的真实会议记录。 文中只有角色,没有公司名、人名和具体日期;所有反对意见与结论都围绕 2.1 的视图和 2.2 的五份 ADR 展开,目的是演示一次可复现的评审方法,而不是伪造一段组织史。

演练的输入是 orderd 单进程、PostgreSQL 单写库、同步库存预留、异步履约,以及仍是“提议”状态的 ADR-006 和 ADR-007。输出是每个决策点的结论、需要补充的实验,以及一份可以交给工具校验的风险台账。

参会角色与议程

角色关注点本次评审的主要问题
订单模块负责人(主持)状态机与用例边界ADR-004 的 PAID/FULFILLED 语义是否可解释
库存模块负责人预留、释放、对账ADR-002 与 ADR-004 的边界是否留下超卖窗口
支付模块负责人回调幂等与金额ADR-006 的 outbox 是否增加回调失败面
SRE可用性、延迟、回滚ADR-003 单库故障、ADR-007 开关回滚
安全负责人越权、密钥、PII备份加密、outbox payload、日志脱敏
记录人(架构文档维护者)结论与行动项只记录,不投票

议程分五步:先用十分钟走查四级视图与依赖规则;再逐条评审 ADR-003 至 ADR-007;然后识别风险并填写触发条件、概率、影响、负责人、缓解与关闭条件;接着形成“接受 / 附条件接受 / 需补充实验”的结论;最后明确回写规则,只更新 ADR 登记表的下一版,不修改其他文件。

五个决策点的反对意见

ADR评审焦点反对意见结论
003单写库是否让数据库成为单点安全:备份含全部订单与支付引用,加密与访问审计谁负责;库存:模块将来独立怎么办接受;补备份加密与恢复演练(R-03、R-07),拆分触发条件保留在 ADR-003
004支付后异步履约的语义支付:用户看到 PAID 但未发货,客服如何解释;SRE:outbox relay 挂了会停多久接受;PAID 文档化为“已受理履约”,补 outbox lag SLO(R-05)
005端口数量与查询性能支付:接口会不会爆炸;订单:跨模块查询三次调用太慢接受;写路径一模块一端口,读路径允许 query port,reporting join 标注边界
006提交后崩溃是否丢事件SRE:提交与 relay 之间 kill -9 怎么办;安全:payload 含 PII 保留多久附条件接受;完成崩溃注入与 PII 保留评审后转 accepted
007开关组合与回滚SRE:开关与部署 canary 叠加,排障时哪个生效;订单:请求路径读配置的性能附条件接受;完成开关矩阵与 kill switch 演练后转 accepted

有两条反对意见没有被采纳,但记录在案。支付模块要求把履约放进回调,理由是“用户付完钱就应该立刻看到发货”;否决理由是这会把回调延迟绑定到仓库系统,重试还会造成重复发货。订单模块要求允许跨模块直接 join,理由是三次调用太慢;折中方案是只读 reporting view,允许 join 但不得写业务规则。

这两条“不采纳但记录”比结论本身更重要。三个月后如果仓库系统 SLA 变差,第一条可能重新成立;如果 reporting 查询成为主路径,第二条也需要重新评估。评审记录的价值在于保留反方论据,而不是只留获胜方案。

评审结论与 ADR 回写

ADR回写状态转正或保持的条件
003accepted补备份加密、恢复演练;触发条件不变
004accepted补 PAID 语义文档、outbox 最老事件年龄 SLO
005accepted补 query port 规范;reporting view 单独标注
006accepted with conditions提交后 kill -9 注入,重启 relay 能补发;PII 字段保留策略评审通过
007accepted with conditions开关矩阵演练;kill switch 实测 5 分钟内回退

回写只发生在本文和 ADR 登记表的下一版,不修改 2.2 的文件。ADR-006 和 ADR-007 在实验完成前仍保持 proposed;这也是登记表校验器要求“状态合法”而不是“状态必须是 accepted”的原因。

风险台账

台账共九条,覆盖一致性(R-01、R-02)、可用性(R-03、R-04)、运维(R-05)、安全(R-06、R-07)、演进(R-08、R-09)。概率与影响都取 low/medium/high,等级由固定矩阵计算,避免人工把 medium 写成 high。

ID风险(影响)触发条件概率等级负责人缓解措施关闭条件
R-01订单状态与库存预留不一致,峰值超卖订单写库成功,库存端口超时或返回未知mediumhigh订单模块负责人同一本地事务;未知结果进入待对账注入 200 次断连,差异为 0
R-02支付回调重复或乱序,重复扣款网关重试、回调乱序或人工重放mediumhigh支付模块负责人provider_ref + event_id 幂等;金额核对重放 1000 次只迁移一次
R-03PostgreSQL 单写库不可用,写入全停主库故障、磁盘满、连接池耗尽lowmediumSRE连接池上限;只读不占写连接;切换演练RTO ≤ 5 分钟、RPO ≤ 1 分钟
R-04支付网关变慢拖垮 HTTP 工作协程网关 P99 超过 2 秒或连接拒绝mediumhighSRE独立超时预算、熔断、信号量隔离注入 3 秒延迟,下单 P99 ≤ 300ms
R-05outbox relay 积压,履约延迟relay 停止、消费失败、事件突增mediummediumSRE积压与最老事件年龄告警;多实例租约最老事件 P99 < 30 秒连续 7 天
R-06越权读取或取消他人订单构造 order_id、缺失归属校验mediumhigh安全负责人授权中间件;不可枚举 ID;404越权测试集 100% 返回 404
R-07支付凭证或数据库口令进入日志与镜像调试日志打印请求体、配置误提交mediumhigh安全负责人脱敏;密钥管理注入;secret 扫描日志与镜像扫描连续 30 天零命中
R-08模块开关长期不清理,测试矩阵失控开关超过 90 天、组合持续增长highhigh架构负责人owner、默认值、到期日;到期告警超过 90 天的开关为 0
R-09模块化单体退化为跨模块直连仓储赶工期直接 import 其他模块仓储mediummedium架构负责人deplint 进 CI;跨模块只经 ports连续 20 次发布零违规

这张表不是“风险清单”的终点,而是评审后的工作项来源。每条风险都必须有负责人和关闭条件;没有关闭条件的风险无法验收,没有负责人的风险无人推动。

台账 JSON 与校验工具

台账以 JSON 保存,第一条如下,其余八条字段相同:

{
  "risks": [
    {
      "id": "R-01",
      "title": "订单状态与库存预留不一致",
      "trigger": "订单写库成功,库存端口超时或返回未知结果",
      "impact": "订单显示已预留,实际库存未扣,峰值超卖并引发赔付",
      "probability": "medium",
      "impact_level": "high",
      "level": "high",
      "owner_role": "订单模块负责人",
      "mitigation": "预留与订单写入同一本地事务;端口结果未知时进入待对账",
      "closure": "注入 200 次提交后断连,账实差异为 0"
    }
  ]
}

校验器做三件事:必填字段非空、概率与影响等级在枚举内、等级等于固定矩阵的计算结果。它还检查编号唯一和关闭条件单独存在,避免“有缓解措施就算完成”。

// file: tools/riskreg/riskreg.go
// tier: L1
// verify: go test ./tools/riskreg/ -count=1 -v
package main

import (
	"encoding/json"
	"fmt"
	"os"
)

type Rating string

const (
	Low      Rating = "low"
	Medium   Rating = "medium"
	High     Rating = "high"
	Critical Rating = "critical"
)

type Risk struct {
	ID          string `json:"id"`
	Title       string `json:"title"`
	Trigger     string `json:"trigger"`
	Impact      string `json:"impact"`
	Probability Rating `json:"probability"`
	ImpactLevel Rating `json:"impact_level"`
	Level       Rating `json:"level"`
	OwnerRole   string `json:"owner_role"`
	Mitigation  string `json:"mitigation"`
	Closure     string `json:"closure"`
}

type Register struct {
	Risks []Risk `json:"risks"`
}

type Issue struct {
	Code string
	Ref  string
	Why  string
}

const (
	CodeMissingField   = "MISSING_FIELD"
	CodeDuplicateID    = "DUPLICATE_ID"
	CodeBadRating      = "BAD_RATING"
	CodeLevelMismatch  = "LEVEL_MISMATCH"
	CodeMissingClosure = "MISSING_CLOSURE"
)

func load(path string) (Register, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return Register{}, err
	}
	var reg Register
	if err := json.Unmarshal(data, &reg); err != nil {
		return Register{}, fmt.Errorf("parse %s: %w", path, err)
	}
	return reg, nil
}

func validProbability(r Rating) bool { return r == Low || r == Medium || r == High }

func validLevel(r Rating) bool {
	return r == Low || r == Medium || r == High || r == Critical
}

// expectedLevel 是概率 × 影响的固定矩阵,避免人工把 medium 写成 high。
func expectedLevel(probability, impact Rating) Rating {
	rank := map[Rating]int{Low: 0, Medium: 1, High: 2}
	switch sum := rank[probability] + rank[impact]; {
	case sum <= 1:
		return Low
	case sum == 2:
		return Medium
	case sum == 3:
		return High
	default:
		return Critical
	}
}

func (r Register) Validate() []Issue {
	var issues []Issue
	seen := make(map[string]bool, len(r.Risks))

	for _, risk := range r.Risks {
		if risk.ID == "" {
			issues = append(issues, Issue{CodeMissingField, "(empty)", "id"})
		} else if seen[risk.ID] {
			issues = append(issues, Issue{CodeDuplicateID, risk.ID, "declared twice"})
		}
		seen[risk.ID] = true

		required := []struct{ name, value string }{
			{"title", risk.Title}, {"trigger", risk.Trigger}, {"impact", risk.Impact},
			{"owner_role", risk.OwnerRole}, {"mitigation", risk.Mitigation},
		}
		for _, field := range required {
			if field.value == "" {
				issues = append(issues, Issue{CodeMissingField, risk.ID, field.name})
			}
		}
		if risk.Closure == "" {
			issues = append(issues, Issue{CodeMissingClosure, risk.ID, "closure"})
		}
		if !validProbability(risk.Probability) {
			issues = append(issues, Issue{CodeBadRating, risk.ID, "probability=" + string(risk.Probability)})
		}
		if !validProbability(risk.ImpactLevel) {
			issues = append(issues, Issue{CodeBadRating, risk.ID, "impact_level=" + string(risk.ImpactLevel)})
		}
		if !validLevel(risk.Level) {
			issues = append(issues, Issue{CodeBadRating, risk.ID, "level=" + string(risk.Level)})
		}
		if validProbability(risk.Probability) && validProbability(risk.ImpactLevel) &&
			validLevel(risk.Level) && risk.Level != expectedLevel(risk.Probability, risk.ImpactLevel) {
			want := expectedLevel(risk.Probability, risk.ImpactLevel)
			issues = append(issues, Issue{CodeLevelMismatch, risk.ID,
				"level=" + string(risk.Level) + " want=" + string(want)})
		}
	}
	return issues
}

命令入口只负责读文件、打印问题、返回退出码:

// file: tools/riskreg/main.go
// tier: L1
// verify: go run ./tools/riskreg -file risk-register.json
package main

import (
	"flag"
	"fmt"
	"os"
)

func main() {
	file := flag.String("file", "risk-register.json", "path to risk register JSON")
	flag.Parse()

	reg, err := load(*file)
	if err != nil {
		fmt.Fprintln(os.Stderr, "riskreg:", err)
		os.Exit(2)
	}
	issues := reg.Validate()
	for _, issue := range issues {
		fmt.Println(issue.Code, issue.Ref, issue.Why)
	}
	if len(issues) > 0 {
		fmt.Fprintf(os.Stderr, "riskreg: %d issue(s) in %s\n", len(issues), *file)
		os.Exit(1)
	}
	fmt.Printf("riskreg: ok (%d risks)\n", len(reg.Risks))
}

测试覆盖五条规则,并验证 JSON 解码后的台账能通过校验:

// file: tools/riskreg/riskreg_test.go
// tier: L1
// verify: go test ./tools/riskreg/ -count=1 -v
package main

import (
	"os"
	"path/filepath"
	"testing"
)

func validRegister() Register {
	return Register{Risks: []Risk{
		{ID: "R-01", Title: "t", Trigger: "g", Impact: "i", Probability: Medium,
			ImpactLevel: High, Level: High, OwnerRole: "SRE", Mitigation: "m", Closure: "c"},
		{ID: "R-02", Title: "t", Trigger: "g", Impact: "i", Probability: Low,
			ImpactLevel: High, Level: Medium, OwnerRole: "SRE", Mitigation: "m", Closure: "c"},
	}}
}

func hasCode(issues []Issue, want string) bool {
	for _, issue := range issues {
		if issue.Code == want {
			return true
		}
	}
	return false
}

func TestValidRegister(t *testing.T) {
	if issues := validRegister().Validate(); len(issues) != 0 {
		t.Fatalf("valid register has %v", issues)
	}
}

func TestValidateTable(t *testing.T) {
	tests := []struct {
		name   string
		mutate func(*Register)
		want   string
	}{
		{"missing owner", func(r *Register) { r.Risks[0].OwnerRole = "" }, CodeMissingField},
		{"missing closure", func(r *Register) { r.Risks[0].Closure = "" }, CodeMissingClosure},
		{"bad probability", func(r *Register) { r.Risks[0].Probability = "likely" }, CodeBadRating},
		{"level mismatch", func(r *Register) { r.Risks[0].Level = Low }, CodeLevelMismatch},
		{"duplicate id", func(r *Register) { r.Risks[1].ID = "R-01" }, CodeDuplicateID},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			reg := validRegister()
			tt.mutate(&reg)
			if !hasCode(reg.Validate(), tt.want) {
				t.Fatalf("issues = %v, want %s", reg.Validate(), tt.want)
			}
		})
	}
}

func TestLoad(t *testing.T) {
	path := filepath.Join(t.TempDir(), "risks.json")
	body := `{"risks":[{"id":"R-01","title":"t","trigger":"g","impact":"i",` +
		`"probability":"medium","impact_level":"high","level":"high",` +
		`"owner_role":"SRE","mitigation":"m","closure":"c"}]}`
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	reg, err := load(path)
	if err != nil {
		t.Fatal(err)
	}
	if issues := reg.Validate(); len(issues) != 0 {
		t.Fatalf("loaded register invalid: %v", issues)
	}
}

真实运行结果

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0):

$ go run ./tools/riskreg -file risk-register.json
riskreg: ok (9 risks)

$ go run ./tools/riskreg -file bad-risk-register.json 2>&1
MISSING_FIELD R-01 owner_role
MISSING_CLOSURE R-01 closure
LEVEL_MISMATCH R-01 level=low want=high
riskreg: 3 issue(s) in bad-risk-register.json
exit status 1

工具能证明台账格式自洽,不能证明风险已被缓解。等级矩阵只能防止“低概率高影响被写成低风险”,真正的关闭仍然依赖实验、演练和监控数据。

与站内评审文章的分工

站内的 架构评审与技术债管理 已经介绍 ATAM、效用树、技术债分类与量化方法。本节不重复这些方法论,只交付一次针对 arch-lab 的评审演练和一份可校验的风险台账。一个回答“评审有哪些方法”,本节回答“这五个决策在评审桌上会被问什么、结论怎么落成可跟踪的风险”。

常见错误与边界

第一,把演练写成真实会议。没有公司、人名、日期并明确标注演练,读者就无法判断哪些是事实、哪些是示例;伪造细节会损害整本书的可信度。

第二,只记录同意的意见。反对意见是未来重新评估的线索;删掉反方论据,评审就退化成盖章。

第三,风险没有负责人。没有 owner 的风险不会有人推动缓解,也不会有人在触发条件出现时被通知。

第四,关闭条件写成“已完成优化”。“差异为 0”“P99 < 30 秒”“连续 30 天零命中”才是可验收的关闭条件。

第五,等级靠拍脑袋。概率和影响必须来自固定枚举,等级由矩阵计算;否则同一风险在不同人手里会得到不同等级。

第六,把校验器当成风险已关闭的证明。它只校验字段与矩阵,不校验缓解措施是否真的执行,也不监控触发条件。

小结

这次演练围绕 2.1 的视图和 2.2 的五份 ADR 展开:ADR-003、004、005 被接受,ADR-006、007 附条件接受,条件分别是崩溃注入、PII 保留评审、开关矩阵演练和 kill switch 实测。九条风险覆盖一致性、可用性、运维、安全和演进,每条都有负责人、缓解措施与可验收的关闭条件。tools/riskreg 把台账格式固化成命令,格式错误会返回非零退出码;风险是否真正关闭,仍然要靠实验和监控。

阅读导航:上一节:2.2 订单系统的五个 ADR · 下一节:3.1 子域、限界上下文与团队边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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