《游戏服务端架构·平台化与架构治理》12.1 决策记录、风险台账与路线图

决策记录、风险台账与路线图常被写成三份互不相干的文档。本文给出三者的字段契约、交叉校验规则、月度与季度评审节奏,以及过期条目的清理标准,并用 Go 实现风险关联、路线图追溯与复审队列,让每条记录都能回答谁负责、依据什么、何时失效,避免三份文档各自完整、合起来断链。

三份文档,一个闭环

架构师的工作台上有三份常被分开维护的文档:决策记录写“已经定了什么”,风险台账写“还有什么没解决”,路线图写“接下来做什么”。分开维护不是问题,互不引用才是问题。

一个典型现场:决策记录里写着“匹配服务改用独占票据”,风险台账里还挂着“票据重复发放”,路线图里却没有任何一项处理它。三份文档各自看起来都完整,合起来却回答不了一个最简单的问题:这个风险现在由谁在管?

本节不重复 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 技术选型与供应商评估 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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