三份文档,一个闭环
架构师的工作台上有三份常被分开维护的文档:决策记录写“已经定了什么”,风险台账写“还有什么没解决”,路线图写“接下来做什么”。分开维护不是问题,互不引用才是问题。
一个典型现场:决策记录里写着“匹配服务改用独占票据”,风险台账里还挂着“票据重复发放”,路线图里却没有任何一项处理它。三份文档各自看起来都完整,合起来却回答不了一个最简单的问题:这个风险现在由谁在管?
本节不重复 ADR 的写作模板,也不展开决策的生命周期图,那些在前面的章节已经讲过。这里只处理三件套之间的一致性:字段怎么对齐、引用怎么校验、评审怎么排期、过期条目怎么退场。
三件套各回答一个问题
| 工件 | 回答的问题 | 时间指向 | 最小字段 | 失效信号 |
|---|---|---|---|---|
| 决策记录 | 我们选了什么,为什么 | 过去 | ID、状态、负责人、退出条件、复审日 | 退出条件成立或被新决策取代 |
| 风险台账 | 什么还没解决,谁在管 | 现在 | ID、负责人、严重度、可能性、缓解措施、关联决策、复审日 | 风险关闭,或转为已接受 |
| 路线图 | 接下来改什么,依据是什么 | 未来 | ID、季度、状态、关联决策或风险 | 季度校准被移出,或追溯对象失效 |
三者的时间指向不同,所以不能用同一套状态字段。决策有“已采纳/已取代”,风险有“开放/缓解中/已接受/已关闭”,路线图有“计划/进行/完成/取消”。强行统一成“状态”一个字段,只会让评审时无法判断该问哪个问题。
三者的共同点是都必须有负责人和复审时间。没有负责人的条目会在第一次交接时消失;没有复审时间的条目会永久留在文档里,最后没人相信它。
决策记录:只补一致性所需的部分
决策记录的核心不是“当时讨论得多热烈”,而是“什么条件下这个决定会失效”。一条可用的记录至少要有:
- 一个稳定 ID,供风险和路线图引用;
- 一个明确状态,且“已取代”必须指向替代它的决策;
- 一个负责人,不是委员会;
- 一组退出条件,写成可观察的信号;
- 一个复审日期,到期进入队列。
本节关心的是一致性:风险只能挂在仍然有效的决策上,路线图不能引用已经被取代的决策。一个风险如果还挂在一个已失效的决策下,它的缓解措施大概率也已经失效,只是没人回头改。
“已接受”的风险是例外。它不需要缓解措施,但需要复审日期和接受理由。接受不是遗忘,而是明确写下“我们知道它存在,暂时不处理,什么时候再看”。
风险台账:严重度乘可能性,不等于优先级
风险台账最常见的退化,是变成一张按录入时间排序的清单。要让它可排序,至少要有两个维度:
| 字段 | 取值 | 作用 | 常见错误 |
|---|---|---|---|
| 严重度 | 1–5 | 一旦发生,影响多大 | 把“发生概率”混进严重度 |
| 可能性 | 1–5 | 在当前控制下,多可能发生 | 用行业平均代替自身观测 |
| 优先级 | 严重度 × 可能性 | 月度复盘的排序依据 | 把乘积当成精确概率 |
| 缓解措施 | 具体动作 | 降低可能性或影响 | 写“持续关注” |
| 关联决策 | 决策 ID | 说明为什么这样缓解 | 留空或指向失效决策 |
| 复审日 | 日期 | 防止条目沉睡 | 设成“长期” |
乘积只用于排序,不是概率。严重度 5、可能性 4 的风险优先级 20,不意味着“80% 会出事”;它只意味着在同样证据质量下,应该先看它。
风险和责任要分开。一个风险可以有一个负责人,但缓解措施往往由另一个团队执行。台账里写的是“谁负责推动它被解决”,不是“谁背锅”。如果一条风险的负责人是“平台组”,那等于没有负责人。
风险也不是问题。已经发生、正在造成影响的事情是事故或问题,应该进故障流程;风险台账只放尚未发生、但可能发生的事。两者混在一起,会让月度复盘同时处理救火和规划,最后两件事都做不好。
路线图:每一项都要能追溯
路线图的每一项都应该能回答“为什么现在做这个”。最直接的检查是:每一项至少关联一个决策或一个风险。
| 路线图项 | 关联对象 | 说明 | 反例 |
|---|---|---|---|
| 结算补偿上线 | 决策 ADR-006、风险 RSK-001 | 决策要求异步补偿,风险要求兜底 | 只写“优化结算” |
| 逻辑时钟灰度 | 决策 ADR-007 | 决策的落地步骤 | 只写“技术升级” |
| 旧时钟下线 | 风险 RSK-003 | 风险关闭后的清理 | 只写“清理历史包袱” |
路线图不是愿望清单。一个条目如果既不对应决策,也不对应风险,通常有三种可能:它是纯技术兴趣,应该放进技术雷达;它是别人已经决定的事,应该补一条决策记录;它是重复工作,应该合并。
路线图也不应该只有“新增”。下线、迁移、还债、演练都是路线图项,而且往往比新增更需要排期,因为它们没有业务方催。
季度校准时要问三个问题:上一季度承诺的项为什么没完成;新加入的项依据哪条决策或风险;被移出的项是取消了,还是只是往后挪。第三个问题最重要,因为“往后挪”连续发生三次,实际上就是取消,只是没人敢写。
一致性校验:四条硬规则
把三件套放在一起,可以定义四条可自动检查的规则:
| 规则 | 检查 | 失败后果 | 处理 |
|---|---|---|---|
| 风险有归属 | 开放/缓解中的风险必须有负责人、缓解措施、复审日 | 无人推动 | 补齐或降级为已接受 |
| 风险挂有效决策 | 开放风险关联的决策必须存在且未失效 | 缓解依据过期 | 改挂新决策或关闭风险 |
| 路线图可追溯 | 每项至少关联一个存在的决策或风险 | 工作来源不明 | 补引用或移入技术雷达 |
| 不做无效工作 | 计划/进行中的项不能关联已关闭风险 | 资源浪费 | 取消该项或重开风险 |
这四条规则不判断内容对不对,只判断引用是否成立。内容判断仍然要人来做,但引用检查可以放进 CI 或月度脚本,避免“文档看起来都对,实际上全是断链”。
还有一条软规则:开放风险如果没有被任何未取消的路线图项覆盖,应该出现在月度报告里。它不是错误,因为接受风险不排期可以是刻意选择;但它必须是可见的选择,而不是漏掉。
评审节奏:月度看风险,季度看路线图
| 评审 | 频率 | 输入 | 输出 | 时间盒 |
|---|---|---|---|---|
| 风险复盘 | 每月 | 到期风险、优先级前 10、未覆盖风险 | 关闭/升级/接受/改期 | 60 分钟 |
| 路线图校准 | 每季度 | 上季完成率、失效追溯、容量 | 新增/移出/重排 | 90 分钟 |
| 决策复审 | 按到期日 | 到期决策、退出条件证据 | 维持/修订/取代 | 30 分钟/条 |
月度风险复盘只做四件事:关闭已经有证据不再成立的风险;升级优先级明显变化的风险;把无法缓解的风险显式转为已接受;给其余条目改复审日。不要在会上讨论新的架构方案,那是另一个议程。
季度路线图校准先看追溯,再看排期。一个季度内新增的项如果超过总项数的三分之一,通常说明决策记录没跟上,而不是路线图更丰富了。
决策复审按到期日分散进行,不要攒到季度末。一次复审 30 分钟,只回答一个问题:当初的退出条件出现了吗?出现了就修订或取代,没出现就延长复审日,并写下下一次要看什么信号。
过期条目的清理要有明确规则(下列阈值为示例值),不能靠“感觉没用了”:
- 决策被取代后,保留记录,状态改为已取代,并指向替代者;
- 风险关闭后,保留一个季度再归档,供复盘引用;
- 路线图项连续两个季度未启动,自动移出并记录原因;
- 任何条目超过复审日 30 天未处理,升级到架构评审议程。
可运行片段:把引用检查变成代码
下面的 register.go 在既有决策包上补了风险与路线图两层。它不重复决策生命周期,只做三件事:校验风险字段、校验路线图追溯、生成月度复审队列。
// file: internal/decisionlog/register.go
// tier: L1
// verify: go test ./internal/decisionlog/
package decisionlog
import (
"fmt"
"sort"
"strings"
"time"
)
// RiskStatus is the handling state of a risk, not its technical category.
type RiskStatus string
const (
RiskOpen RiskStatus = "open"
RiskMitigating RiskStatus = "mitigating"
RiskAccepted RiskStatus = "accepted"
RiskClosed RiskStatus = "closed"
)
var riskStatuses = map[RiskStatus]bool{
RiskOpen: true, RiskMitigating: true, RiskAccepted: true, RiskClosed: true,
}
// Risk is an unresolved threat with an owner. Severity and Likelihood are
// scored 1..5, so Priority is a 1..25 review ordering, not a probability.
type Risk struct {
ID string
Title string
Owner string
Severity int
Likelihood int
Status RiskStatus
Mitigation string
DecisionID string
ReviewBy time.Time
}
func (r Risk) Priority() int { return r.Severity * r.Likelihood }
func (r Risk) IsOpen() bool {
return r.Status == RiskOpen || r.Status == RiskMitigating
}
// Validate checks one risk. Open and mitigating risks need a mitigation, a
// review date, and a decision they hang from; otherwise nobody is on the hook.
func (r Risk) Validate() []error {
var errs []error
for _, f := range []struct{ name, value string }{
{"id", r.ID}, {"title", r.Title}, {"owner", r.Owner},
} {
if strings.TrimSpace(f.value) == "" {
errs = append(errs, fmt.Errorf("risk %q: empty %s", r.ID, f.name))
}
}
if !riskStatuses[r.Status] {
errs = append(errs, fmt.Errorf("risk %q: unknown status %q", r.ID, r.Status))
}
if r.Severity < 1 || r.Severity > 5 {
errs = append(errs, fmt.Errorf("risk %q: severity %d, want 1..5", r.ID, r.Severity))
}
if r.Likelihood < 1 || r.Likelihood > 5 {
errs = append(errs, fmt.Errorf("risk %q: likelihood %d, want 1..5", r.ID, r.Likelihood))
}
if r.IsOpen() {
if strings.TrimSpace(r.Mitigation) == "" {
errs = append(errs, fmt.Errorf("risk %q: open without mitigation", r.ID))
}
if r.ReviewBy.IsZero() {
errs = append(errs, fmt.Errorf("risk %q: open without review date", r.ID))
}
if strings.TrimSpace(r.DecisionID) == "" {
errs = append(errs, fmt.Errorf("risk %q: open without decision", r.ID))
}
}
if r.Status == RiskAccepted && r.ReviewBy.IsZero() {
errs = append(errs, fmt.Errorf("risk %q: accepted without review date", r.ID))
}
return errs
}
// RoadmapItem is a planned change. Each item must point at at least one
// decision or risk; an item that traces to nothing is a wish, not a plan.
type RoadmapItem struct {
ID string
Title string
Quarter string
Status string
DecisionID string
RiskIDs []string
}
var itemStatuses = map[string]bool{
"planned": true, "active": true, "done": true, "dropped": true,
}
func (i RoadmapItem) Validate() []error {
var errs []error
for _, f := range []struct{ name, value string }{
{"id", i.ID}, {"title", i.Title}, {"quarter", i.Quarter},
} {
if strings.TrimSpace(f.value) == "" {
errs = append(errs, fmt.Errorf("roadmap item %q: empty %s", i.ID, f.name))
}
}
if !itemStatuses[i.Status] {
errs = append(errs, fmt.Errorf("roadmap item %q: unknown status %q", i.ID, i.Status))
}
if strings.TrimSpace(i.DecisionID) == "" && len(i.RiskIDs) == 0 {
errs = append(errs, fmt.Errorf("roadmap item %q: no decision or risk traced", i.ID))
}
return errs
}
// ValidateRegister checks the three artifacts together. Decisions are checked
// by the existing lifecycle rules; this layer adds cross-artifact references.
func ValidateRegister(ds []Decision, risks []Risk, items []RoadmapItem) []error {
errs := ValidateAll(ds)
byDecision := make(map[string]Decision, len(ds))
for _, d := range ds {
byDecision[d.ID] = d
}
byRisk := make(map[string]Risk, len(risks))
for _, r := range risks {
errs = append(errs, r.Validate()...)
if _, ok := byRisk[r.ID]; ok {
errs = append(errs, fmt.Errorf("risk %q: duplicate id", r.ID))
}
byRisk[r.ID] = r
if !r.IsOpen() || strings.TrimSpace(r.DecisionID) == "" {
continue
}
d, ok := byDecision[r.DecisionID]
if !ok {
errs = append(errs, fmt.Errorf("risk %q: unknown decision %q", r.ID, r.DecisionID))
continue
}
if dead(d.Status) {
errs = append(errs, fmt.Errorf("risk %q: hangs from %s decision %q", r.ID, d.Status, d.ID))
}
}
seenItem := make(map[string]bool, len(items))
for _, i := range items {
errs = append(errs, i.Validate()...)
if seenItem[i.ID] {
errs = append(errs, fmt.Errorf("roadmap item %q: duplicate id", i.ID))
}
seenItem[i.ID] = true
if i.DecisionID != "" {
if _, ok := byDecision[i.DecisionID]; !ok {
errs = append(errs, fmt.Errorf("roadmap item %q: unknown decision %q", i.ID, i.DecisionID))
}
}
for _, ref := range i.RiskIDs {
r, ok := byRisk[ref]
if !ok {
errs = append(errs, fmt.Errorf("roadmap item %q: unknown risk %q", i.ID, ref))
continue
}
if (i.Status == "planned" || i.Status == "active") && r.Status == RiskClosed {
errs = append(errs, fmt.Errorf("roadmap item %q: addresses closed risk %q", i.ID, ref))
}
}
}
return errs
}
// ReviewQueue returns open risks due inside the window, highest priority
// first. Priority ties fall back to the earlier review date, then ID.
func ReviewQueue(risks []Risk, now time.Time, window time.Duration) []Risk {
cutoff := now.Add(window)
var out []Risk
for _, r := range risks {
if !r.IsOpen() || r.ReviewBy.IsZero() || r.ReviewBy.After(cutoff) {
continue
}
out = append(out, r)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Priority() != out[j].Priority() {
return out[i].Priority() > out[j].Priority()
}
if !out[i].ReviewBy.Equal(out[j].ReviewBy) {
return out[i].ReviewBy.Before(out[j].ReviewBy)
}
return out[i].ID < out[j].ID
})
return out
}
// UncoveredRisks lists open risks that no live roadmap item addresses. It is a
// report, not a validation error: accepting a risk without planned work can be
// deliberate, but it should be visible in the monthly review.
func UncoveredRisks(risks []Risk, items []RoadmapItem) []string {
covered := make(map[string]bool)
for _, i := range items {
if i.Status == "dropped" {
continue
}
for _, ref := range i.RiskIDs {
covered[ref] = true
}
}
var out []string
for _, r := range risks {
if r.IsOpen() && !covered[r.ID] {
out = append(out, r.ID)
}
}
sort.Strings(out)
return out
}
代码里有四个值得注意的取舍。
第一,风险和路线图都使用独立 ID,而不是复用决策 ID。一个决策可能衍生多个风险,一个风险也可能被多个路线图项处理,复用 ID 会让关系变成一对一,丢失信息。
第二,ValidateRegister 先调用同包 decisionlog.go 的决策校验,再检查跨工件引用。这样决策自身的规则只有一份,风险层不需要重复实现取代、冲突、依赖检查。
第三,开放风险必须关联决策,已接受风险不强制。这是有意的:开放意味着还要投入资源,投入必须有决策依据;接受意味着明确不投入,只需要复审日期和理由。
第四,ReviewQueue 按优先级排序,而不是按日期。日期只决定“是否进入窗口”,优先级决定“先看哪条”。如果按日期排,一个高危但复审日稍晚的风险会被低危但明天到期的风险挤到后面。
UncoveredRisks 返回没有被任何未取消路线图项覆盖的开放风险。它不参与校验失败,因为“接受但不排期”可以是合法策略;它只确保这种策略在月度报告里可见。
失败模式
| 失败模式 | 表现 | 兜底 |
|---|---|---|
| 三份文档各写各的 | 风险引用的决策已经失效 | 把引用校验放进月度脚本 |
| 风险没有负责人 | 负责人写“平台组” | 必须落到具体人,团队名不算 |
| 缓解措施写成口号 | “持续关注”“加强监控” | 写成可验收的动作和信号 |
| 路线图成为许愿池 | 新增项无追溯 | 无追溯项移入技术雷达 |
| 复审日永不临近 | 全部设为“长期” | 最长 90 天,到期必须改期 |
| 已接受风险被遗忘 | 没有复审日 | 接受也要设复审日 |
| 只增不减 | 路线图越来越长 | 每季度强制移出至少 10% |
| 用风险台账记事故 | 复盘同时救火和规划 | 事故走故障流程,风险只记未发生的事 |
检查表
发布前或月度评审前,逐条检查:
- 每条开放风险都有具体负责人,而不是团队名。
- 每条开放风险都有可验收的缓解措施,而不是“持续关注”。
- 每条开放风险都关联一个仍然有效的决策。
- 每条已接受风险都有接受理由和复审日。
- 每个路线图项都关联至少一个决策或风险。
- 没有计划/进行中的项关联已关闭风险。
- 所有条目都在复审日 90 天以内,或有明确的长期例外。
- 连续两个季度未启动的路线图项已经移出并记录原因。
- 本月关闭的风险保留了复盘引用,而不是直接删除。
- 决策被取代后,引用它的风险和路线图项已经改挂或关闭。
小结
决策记录、风险台账与路线图不是三份文档,而是一条链:决策产生风险,风险决定路线图,路线图的执行又产生新的决策。链上的每个节点都要有负责人、复审时间和可追溯的引用。
internal/decisionlog 把这套关系变成可执行检查:风险字段是否完整、引用是否有效、路线图是否可追溯、哪些风险该进入本月复审。它不能替架构师判断风险大小,但能保证判断所依赖的记录没有断链。
下一节讨论另一类高频决策:技术选型与供应商评估。那里的对象从内部工件变成外部依赖,退出成本会成为主角。
阅读导航:上一节:11.3 AI 辅助测试与压测 · 下一节:12.2 技术选型与供应商评估 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。