《游戏服务端架构·品类、状态与拓扑》12.3 架构决策的记录、评审与复盘

一份两年前标记为 accepted 的 ADR 仍然躺在决策目录里,它依赖的 ADR 早已被取代,复审日期也过去了三个月。本节把决策当作有生命周期的图节点,讨论提议、评审、采纳、复审、失效与替代,给出取代/依赖/冲突关系的检测、复审到期提醒和失效迁移清单,并用 Go 实现决策图校验。

一份仍然写着 accepted 的旧 ADR

2026 年 10 月,北辰工作室整理《群岛纪元》的决策目录。ADR-005 的状态仍然是 accepted,标题是“匹配服务独占匹配票据”。这份记录写于 2024 年,复审日期是 2026 年 6 月,已经过去四个月。

更麻烦的是它的依赖:ADR-005 依赖 ADR-002 定义的“世界分片身份模型”,而 ADR-002 在 2025 年被 ADR-011 取代。也就是说,一份仍然有效的决策,建立在一个已经失效的前提上。

没有人故意忽略。ADR 写完之后,团队就进入了下一次迭代;状态字段只在创建时被填写,之后没有任何机制提醒谁去复审。记录还在,决策的生命周期却停在了“采纳”那一刻。

2.2 节 已经讲过 ADR 的写法:背景、备选、决定、后果、退出条件与状态。本节不重复模板,只讨论模板写完之后的事:决策如何记录成图、何时评审、何时复审、失效后如何替代。2.3 节 的风险台账是评审输入,本节关注评审之后的状态流转。

决策不是一次性事件,而是生命周期

一份决策至少要经过六个状态:

proposed ──> in-review ──> accepted ──> superseded
                  │              │
                  └──> rejected  └──> expired
状态含义必须回答
proposed有人提出,尚未评审要解决什么问题
in-review正在收集证据与反对意见谁在评审,何时截止
accepted已采纳,正在执行复审日期、退出条件、负责人
rejected明确不采纳否决理由,避免重复提议
superseded被新决策替代替代者是谁,迁移是否完成
expired前提消失,自然失效哪个前提消失了

关键约束是:只有 accepted 的决策可以取代旧决策。一个还在评审中的提议可以“计划取代”,但不能把旧决策提前标成失效,否则执行层会同时收到两套指令。

决策图:取代、依赖、冲突

决策之间不是孤立文档,而是一张有向图:

关系含义检查
supersedes新决策替代旧决策旧决策状态必须是 superseded,替代者必须是 accepted
depends-on本决策的前提是另一份决策依赖对象不能是 rejected、superseded 或 expired
conflicts-with两份决策不能同时成立关系必须对称,且不能同时为 accepted

例如:

  • ADR-005 依赖 ADR-002;
  • ADR-011 取代 ADR-002;
  • ADR-012 与 ADR-006 在“结算幂等键由谁生成”上冲突。

图一旦画出来,前面那份旧 ADR 的问题就暴露了:ADR-005 是 accepted,但它依赖的 ADR-002 已经 superseded。这不是文档瑕疵,而是执行风险:团队可能仍在按一个失效前提做容量规划。

评审看什么:假设、证据、退出条件

评审常被理解成“看方案是否合理”。更可执行的评审清单是三项:

  1. 假设:这个决定依赖哪些尚未验证的事实?例如“跨地域延迟稳定低于 100ms”。
  2. 证据:假设现在有哪些数据支撑?压测、线上观测、事故复盘,还是只有直觉。
  3. 退出条件:什么信号出现时,这个决定必须重新评估?条件必须可观测、有阈值、有持续时间。

方案本身反而排在第四位。方案可以改,前提错了,方案再漂亮也没有意义。

评审的产出不是“通过/不通过”两个词,而是四类结论:采纳、附条件采纳、退回补充证据、拒绝。附条件采纳必须把条件写进决策记录,否则条件会在会后消失。

复审触发:时间、信号与依赖失效

复审不能只靠日历。至少有三类触发:

触发例子动作
时间复审日期到达负责人召集复审,更新状态
信号匹配 p99 连续一周超 2 秒检查退出条件,决定是否替代
依赖失效依赖的 ADR 被取代或过期立即标记为待复审,不等到日期

第三类最容易被忽略。依赖失效不需要等复审日期,因为前提已经不存在了。代码里的 ValidateAll 会把“active 决策依赖 dead 决策”直接报错,目的就是逼团队在合并前处理,而不是等下一次架构会。

复审还要看“决策是否仍然成立”,而不是“当初的决定是否聪明”。复盘不是追责,是确认前提、成本和收益是否还和当初一致。

失效处理:标记、替代与迁移清单

决策失效不等于工作结束。至少要完成五件事:

  1. 把旧决策状态改为 superseded 或 expired,并写明原因。
  2. 指定替代者;如果暂时没有替代者,标记为“无替代失效”,并列出风险。
  3. 找出所有引用旧决策的文档、配置和代码注释,更新或加注。
  4. 检查依赖旧决策的其他决策,把它们标记为待复审。
  5. 在发布说明或架构月报里记录这次失效,避免执行层继续按旧结论工作。

只改状态字段,不改下游引用,等于把过期结论留在了系统里。

代码:决策图与三类检查

// file: internal/decisionlog/decisionlog.go
// tier: L1
// verify: go test ./internal/decisionlog/
package decisionlog

import (
	"fmt"
	"sort"
	"strings"
	"time"
)

// Status is the lifecycle position of a decision, not its technical outcome.
type Status string

const (
	Proposed   Status = "proposed"
	InReview   Status = "in-review"
	Accepted   Status = "accepted"
	Rejected   Status = "rejected"
	Superseded Status = "superseded"
	Expired    Status = "expired"
)

var statuses = map[Status]bool{
	Proposed: true, InReview: true, Accepted: true,
	Rejected: true, Superseded: true, Expired: true,
}

// Decision is one node in the decision graph. Supersedes, DependsOn, and
// ConflictsWith point at other decision IDs.
type Decision struct {
	ID             string
	Title          string
	Status         Status
	Owner          string
	ReviewBy       time.Time
	ExitConditions []string
	Supersedes     []string
	DependsOn      []string
	ConflictsWith  []string
}

func active(s Status) bool {
	return s == Proposed || s == InReview || s == Accepted
}

func dead(s Status) bool {
	return s == Rejected || s == Superseded || s == Expired
}

// Validate checks one node. Active decisions must carry a review date and
// at least one exit condition; otherwise they cannot be re-evaluated.
func (d Decision) Validate() []error {
	var errs []error
	for _, f := range []struct{ name, value string }{
		{"id", d.ID}, {"title", d.Title}, {"owner", d.Owner},
	} {
		if strings.TrimSpace(f.value) == "" {
			errs = append(errs, fmt.Errorf("decision %q: empty %s", d.ID, f.name))
		}
	}
	if !statuses[d.Status] {
		errs = append(errs, fmt.Errorf("decision %q: unknown status %q", d.ID, d.Status))
	}
	if active(d.Status) {
		if d.ReviewBy.IsZero() {
			errs = append(errs, fmt.Errorf("decision %q: active without review date", d.ID))
		}
		if len(d.ExitConditions) == 0 {
			errs = append(errs, fmt.Errorf("decision %q: active without exit conditions", d.ID))
		}
	}
	if contains(d.Supersedes, d.ID) || contains(d.DependsOn, d.ID) || contains(d.ConflictsWith, d.ID) {
		errs = append(errs, fmt.Errorf("decision %q: references itself", d.ID))
	}
	return errs
}

// SupersedeCycle reports the first cycle in the replacement graph.
func SupersedeCycle(ds []Decision) []error {
	edges := make(map[string][]string, len(ds))
	for _, d := range ds {
		edges[d.ID] = append(edges[d.ID], d.Supersedes...)
	}
	state := make(map[string]int, len(ds)) // 1 visiting, 2 done
	var visit func(string) bool
	visit = func(id string) bool {
		switch state[id] {
		case 1:
			return true
		case 2:
			return false
		}
		state[id] = 1
		for _, next := range edges[id] {
			if visit(next) {
				return true
			}
		}
		state[id] = 2
		return false
	}
	for _, d := range ds {
		if state[d.ID] == 0 && visit(d.ID) {
			return []error{fmt.Errorf("supersede cycle involving %q", d.ID)}
		}
	}
	return nil
}

// ValidateAll checks references, replacement consistency, conflicts, and
// dependencies. It returns every error; callers decide how to report them.
func ValidateAll(ds []Decision) []error {
	var errs []error
	byID := make(map[string]Decision, len(ds))
	for _, d := range ds {
		errs = append(errs, d.Validate()...)
		if _, ok := byID[d.ID]; ok {
			errs = append(errs, fmt.Errorf("decision %q: duplicate id", d.ID))
		}
		byID[d.ID] = d
	}
	supersededBy := make(map[string]bool)
	for _, d := range ds {
		for _, ref := range d.Supersedes {
			target, ok := byID[ref]
			if !ok {
				errs = append(errs, fmt.Errorf("decision %q: supersedes unknown %q", d.ID, ref))
				continue
			}
			if d.Status != Accepted {
				errs = append(errs, fmt.Errorf("decision %q: only accepted decisions may supersede", d.ID))
			}
			if target.Status != Superseded {
				errs = append(errs, fmt.Errorf("decision %q: supersedes %q which is %s", d.ID, ref, target.Status))
				continue
			}
			supersededBy[ref] = true
		}
		for _, ref := range d.DependsOn {
			target, ok := byID[ref]
			if !ok {
				errs = append(errs, fmt.Errorf("decision %q: depends on unknown %q", d.ID, ref))
				continue
			}
			if active(d.Status) && dead(target.Status) {
				errs = append(errs, fmt.Errorf("decision %q: depends on %s decision %q", d.ID, target.Status, ref))
			}
		}
		for _, ref := range d.ConflictsWith {
			target, ok := byID[ref]
			if !ok {
				errs = append(errs, fmt.Errorf("decision %q: conflicts with unknown %q", d.ID, ref))
				continue
			}
			if !contains(target.ConflictsWith, d.ID) {
				errs = append(errs, fmt.Errorf("decision %q: conflict with %q is not symmetric", d.ID, ref))
			}
			if d.Status == Accepted && target.Status == Accepted {
				errs = append(errs, fmt.Errorf("decisions %q and %q are both accepted and conflict", d.ID, ref))
			}
		}
	}
	for _, d := range ds {
		if d.Status == Superseded && !supersededBy[d.ID] {
			errs = append(errs, fmt.Errorf("decision %q: superseded without accepted replacement", d.ID))
		}
	}
	errs = append(errs, SupersedeCycle(ds)...)
	return errs
}

// Due returns active decisions inside the review window, earliest first.
func Due(ds []Decision, now time.Time, window time.Duration) []Decision {
	cutoff := now.Add(window)
	var out []Decision
	for _, d := range ds {
		if !active(d.Status) || d.ReviewBy.IsZero() || d.ReviewBy.After(cutoff) {
			continue
		}
		out = append(out, d)
	}
	sort.SliceStable(out, func(i, j int) bool {
		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
}

func contains(list []string, want string) bool {
	for _, s := range list {
		if s == want {
			return true
		}
	}
	return false
}

Validate 检查单个节点,SupersedeCycle 检查取代关系是否有环,ValidateAll 检查引用、替代一致性、冲突对称性和依赖有效性,Due 返回复审窗口内到期的 active 决策。Due 只提醒,不修改状态;状态变更仍然需要人来做决定。

测试:取代环、冲突不对称、到期提醒

表驱动测试分成四组:六组单节点用例、三组取代环用例、九组图一致性用例和四组到期提醒用例。重点用例:

用例期望
active 决策缺复审日期报错
active 决策缺退出条件报错
取代关系形成环报错
非 accepted 决策试图取代旧决策报错
冲突关系不对称报错
两份 accepted 决策互相冲突报错
active 决策依赖已失效决策报错
到期提醒按日期、再按 ID 排序固定顺序

本机只跑本包:

$ cd .local/checks/gsarch-lab
$ go test -count=1 ./internal/decisionlog/
ok  	gsarchlab/internal/decisionlog	0.723s

同时跑了 gofmt -l internal/decisionlog(无输出)和 go vet ./internal/decisionlog/(退出码 0)。

常见错误与边界

第一,只记录决定,不记录状态流转。ADR 目录里全是 accepted,等于没有生命周期。

第二,用 proposed 的决策取代旧决策。执行层会看到两份互相矛盾的指令。

第三,依赖失效不处理。旧决策仍然 active,但它成立的前提已经不存在。

第四,冲突关系只写一边。单向冲突在图上看起来像普通依赖,评审时会被漏掉。

第五,复审只看日历,不看信号。退出条件写了却没人观测,等于没写。

第六,失效只改状态。下游文档、配置和依赖决策不更新,过期结论会继续传播。

小结

决策是有生命周期的图节点:提议、评审、采纳、复审、失效与替代。取代关系不能成环,冲突必须对称,active 决策不能依赖已失效决策,复审既看日期也看信号。internal/decisionlog 把这些规则变成可执行检查,并把到期提醒单独输出,避免“记录还在、决策已经过期”。

本节与 2.2 的分工是:2.2 教怎么写一份 ADR,本节管 ADR 写完之后的评审、复审与失效。下一章回到三款产品,用前十二章的视图、契约和决策方法,给出各自的架构基线。

阅读导航:上一节:12.2 时序图、状态机与契约表 · 下一节:13.1 三款产品的架构基线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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