先说清这不是哪家公司的会议
这是基于 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 | 回写状态 | 转正或保持的条件 |
|---|---|---|
| 003 | accepted | 补备份加密、恢复演练;触发条件不变 |
| 004 | accepted | 补 PAID 语义文档、outbox 最老事件年龄 SLO |
| 005 | accepted | 补 query port 规范;reporting view 单独标注 |
| 006 | accepted with conditions | 提交后 kill -9 注入,重启 relay 能补发;PII 字段保留策略评审通过 |
| 007 | accepted 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 | 订单状态与库存预留不一致,峰值超卖 | 订单写库成功,库存端口超时或返回未知 | medium | high | 订单模块负责人 | 同一本地事务;未知结果进入待对账 | 注入 200 次断连,差异为 0 |
| R-02 | 支付回调重复或乱序,重复扣款 | 网关重试、回调乱序或人工重放 | medium | high | 支付模块负责人 | provider_ref + event_id 幂等;金额核对 | 重放 1000 次只迁移一次 |
| R-03 | PostgreSQL 单写库不可用,写入全停 | 主库故障、磁盘满、连接池耗尽 | low | medium | SRE | 连接池上限;只读不占写连接;切换演练 | RTO ≤ 5 分钟、RPO ≤ 1 分钟 |
| R-04 | 支付网关变慢拖垮 HTTP 工作协程 | 网关 P99 超过 2 秒或连接拒绝 | medium | high | SRE | 独立超时预算、熔断、信号量隔离 | 注入 3 秒延迟,下单 P99 ≤ 300ms |
| R-05 | outbox relay 积压,履约延迟 | relay 停止、消费失败、事件突增 | medium | medium | SRE | 积压与最老事件年龄告警;多实例租约 | 最老事件 P99 < 30 秒连续 7 天 |
| R-06 | 越权读取或取消他人订单 | 构造 order_id、缺失归属校验 | medium | high | 安全负责人 | 授权中间件;不可枚举 ID;404 | 越权测试集 100% 返回 404 |
| R-07 | 支付凭证或数据库口令进入日志与镜像 | 调试日志打印请求体、配置误提交 | medium | high | 安全负责人 | 脱敏;密钥管理注入;secret 扫描 | 日志与镜像扫描连续 30 天零命中 |
| R-08 | 模块开关长期不清理,测试矩阵失控 | 开关超过 90 天、组合持续增长 | high | high | 架构负责人 | owner、默认值、到期日;到期告警 | 超过 90 天的开关为 0 |
| R-09 | 模块化单体退化为跨模块直连仓储 | 赶工期直接 import 其他模块仓储 | medium | medium | 架构负责人 | 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, ®); 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(®)
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 子域、限界上下文与团队边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。