《游戏服务端架构·品类、状态与拓扑》2.2 ADR:把决策、备选与后果写下来

一次关于《群岛纪元》匹配状态归属的评审吵了四十分钟没有结论,因为没人写下备选与后果。本节给出 ADR 的最小结构:背景、备选、决定、后果、退出条件与状态;用 ADR-005 匹配状态归属、ADR-006 结算幂等、ADR-007 全球同服时间模型三份真实记录示范被否决的备选和退出条件,并用 Go 校验器拒绝缺少退出条件的 ADR。

吵了四十分钟的会

2026 年 8 月,《群岛纪元》准备出海。跨地域匹配是上线前最后一个没有定下来的架构问题。

会议室里坐着四个人:SLG 组主程老周、平台组负责人阿青、数据组的小林,以及制作人。白板上写着三个方案:A 把匹配状态放世界分片;B 把匹配票据放共享 Redis;C 用独立匹配服务独占状态。

老周支持 A。他的理由很直接:世界分片已经存在,匹配只是在分片里多存一张表,少一个服务就少一套部署、监控和值班。

阿青支持 B。平台组已经在用 Redis 做排行榜和会话缓存,他认为匹配票据也是缓存,所有分片都能读,扩容简单。

小林反对 A 和 B。他指出跨地域匹配的票据要在不同地域之间转移,如果状态归世界分片,究竟哪个分片是权威副本?如果放 Redis,两个地域同时撮合同一批玩家,谁来做原子裁决?

三个人说的都成立,但四十分钟过去,会议没有结论。每个人都在论证自己的方案“更好”,没有人写下“如果选错了,最坏会发生什么”,也没有人定义“什么情况下应该换方案”。

最后制作人问了一句:“半年后新来的同事问为什么不用 Redis,我们拿什么回答?”

会议室安静了几秒。答案不是再吵一遍,而是把这场争论写下来。

会议纪要救不了架构

团队以前也写过会议纪要。纪要通常只写“8 月 14 日架构会:决定匹配状态放独立匹配服务”,再加参会人。三个月后,没有人记得为什么否掉 Redis。半年后,新同事看到 Redis 已经在平台里跑得很稳,自然会问:“为什么不放 Redis?”这时只能凭记忆重新辩论,而记忆会偏向声音大的人。

会议纪要记录“发生了什么”,ADR 记录“为什么这样决定,以及什么时候应该重新决定”。两者的差别是:

文档回答的问题半年后是否可用
会议纪要谁参加了,决定了什么只能证明决定存在
设计文档系统现在怎么工作描述现状,不解释取舍
ADR为什么选它、为什么不选别的、何时退出可以直接复用论证

ADR 不是更长的会议纪要。它是一份可以被未来的人反驳、也可以被未来的人执行的决策记录。

ADR 的最小结构

北辰工作室后来把 ADR 压到六个字段。少一个,记录就会退化成口号。

字段必须回答缺失后的后果
背景什么约束逼出了这个决定不知道问题是否还存在
备选至少两个认真考虑过的方案决定变成命令,无法复盘
决定选哪个,边界在哪执行时各自理解
后果接受了哪些代价出问题时互相推责
退出条件什么信号出现时重新评估架构变成信仰
状态提议、接受、否决还是被取代不知道哪份有效

其中最容易被跳过、也最不该跳过的是“退出条件”。背景会过时,备选会被新技术替代,决定本身也可能被证明是错的。没有退出条件,ADR 就只是一份归档文件。

退出条件必须可观测。写“当架构不再合适时”等于没写;写“匹配 p99 因服务跳转连续一周超过 2 秒”才是信号。

ADR-005:匹配状态由匹配服务独占

那场会最终产出了 ADR-005。

背景:《群岛纪元》8 万在线,跨地域匹配约占 30%。匹配票据需要在不同地域之间转移,世界分片不能各自维护一份;匹配服务重启后,未完成的匹配必须能恢复。

备选方案:

  • A. 世界分片持有匹配状态。被否决,因为跨分片匹配没有单一权威,两个分片可能同时对同一批玩家做出不同裁决,状态会分叉。
  • B. 匹配票据放共享 Redis。被否决,因为原子转移和审计困难;故障时无法判断票据归哪个地域,也无法解释“为什么这个玩家进了这场匹配”。
  • C. 独立匹配服务独占匹配票据。选中。

决定:匹配服务是匹配票据的唯一权威副本,世界分片只通过命令和事件与它交互。匹配服务可以按地域部署多个实例,但同一张票据在同一时刻只属于一个实例。

后果:匹配服务成为有状态服务,发布需要排空和票据迁移;世界分片多了一次网络调用,跨地域匹配延迟增加约 15–25ms;匹配服务必须持久化票据并支持崩溃恢复。

退出条件:

  • 匹配 p99 因服务跳转连续一周超过 2 秒;
  • 跨地域匹配下线,单分片可以独立完成全部匹配;
  • 平台提供原生的跨地域一致队列,且迁移成本低于维护独立服务。

状态:accepted。

老周后来承认,A 并不是错得离谱,它只是把“少一个服务”的收益放在了“单一权威”之上。ADR 把这条取舍写清楚之后,争论就从“谁对”变成了“我们愿不愿意为单一权威多付一个服务”。

ADR-006:结算幂等键由结算服务生成

第二份 ADR 来自一次线上事故。王战结束后,结算服务重试了三次,同一个联盟收到了三份奖励。

背景:结算请求可能因超时重试、消息重复投递或人工补单而执行多次。奖励发放必须满足“同一场战斗只发一次”,但结算链路跨多个服务,无法依赖单一数据库事务。

备选方案:

  • A. 用分布式事务保证 exactly-once。被否决,因为跨库事务成本高、可用性差,且第三方支付渠道不支持参与事务。
  • B. 客户端生成幂等键并随请求上传。被否决,因为客户端不可信,重复安装、改包和重放都会制造新键。
  • C. 结算服务根据 battle_id + rule_version 生成幂等键,写库时依赖唯一索引,异步对账兜底。选中。

决定:幂等键由服务端生成,写入结算明细表并建立唯一索引;重复请求返回第一次的结果,不重复发奖。对账任务每天扫描“已结算未发奖”和“已发奖无明细”两类异常。

后果:结算明细表成为写热点,需要按赛季或区服分片;修改结算规则必须升级 rule_version,否则历史战斗会被判为重复;对账任务本身需要观测,否则异常会静默堆积。

退出条件:

  • 数据库提供原生幂等写入,且跨库一致性成本低于自建幂等表;
  • 结算峰值超过单表写入上限,分片方案无法在预算内支撑;
  • 业务允许“最多一次”发奖,并由人工客服统一补偿。

状态:accepted。

注意 B 不是“明显错误”。很多团队确实让客户端传幂等键,在弱联网休闲游戏里也能工作。ADR 的价值在于写明:在《群岛纪元》这种可被改包的重度 SLG 里,客户端不可信,所以 B 被否决。

ADR-007:全球同服使用 UTC 逻辑时间

第三份 ADR 是这次评审真正卡住的地方。

背景:《群岛纪元》全球同服,王战在固定时刻开启,赛季按周期结算。玩家分布在中国、东南亚、欧洲和北美,各地时区不同,部分区域还有夏令时。

备选方案:

  • A. 各区域使用本地时间开启王战。被否决,因为“全球同服”的核心卖点是所有人在同一张地图上竞争,本地时间会让不同区域处于不同赛季阶段,联盟战争无法同步。
  • B. 服务端使用 UTC 逻辑时钟,客户端按本地时区展示。选中。
  • C. 使用玩家设备时间。被否决,因为设备时间可被修改,且同一联盟的玩家会看到不同的开启时刻。

决定:所有服务端计时、赛季边界、王战开启和结算截止都使用 UTC 逻辑时钟;客户端只负责把 UTC 转成本地时间展示。逻辑时钟由平台时间服务统一发号,不依赖单机时钟。

后果:客服和运营需要同时理解 UTC 与本地时间,排班和公告容易出错;部分区域的黄金时段与王战时间冲突,需要产品侧做补偿活动;时间服务成为关键依赖,必须有多副本和漂移告警。

退出条件:

  • 监管要求按本地日历结算,UTC 模型无法满足合规;
  • 产品拆分为独立赛区,跨赛区不再共享赛季;
  • 时间服务连续出现无法解释的漂移,且修复成本高于改为区域时钟。

状态:accepted,但“本地日历合规”这一条被法务标记为待确认,转入风险台账,在 2.3 节继续跟踪。

三份 ADR 的共同模式

把三份记录放在一起,可以看到同一种结构:

ADR决定被否决的关键备选退出信号
ADR-005匹配服务独占票据世界分片持有;共享 Redis跳转延迟连续超标,或跨地域匹配下线
ADR-006服务端生成幂等键分布式事务;客户端幂等键原生幂等写入更便宜,或结算量超过单表上限
ADR-007UTC 逻辑时钟区域本地时间;设备时间合规要求本地日历,或拆分为独立赛区

三份记录都至少有两个备选,都写了否决理由,都有一个可以在监控里观察到的退出信号。这不是格式要求,而是防止团队在半年后失去论证能力。

用代码校验 ADR

ADR 写成 Markdown 也会缺字段。北辰的做法是先用结构体校验,再把通过校验的内容渲染成文档。

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

import (
	"fmt"
	"strings"
)

// Status is the lifecycle state of a decision record.
type Status string

const (
	Proposed   Status = "proposed"
	Accepted   Status = "accepted"
	Rejected   Status = "rejected"
	Superseded Status = "superseded"
)

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

// Alternative is an option that was seriously considered. The rejection
// reason is mandatory: options listed after the fact are not alternatives.
type Alternative struct {
	Name            string
	RejectedBecause string
}

// ADR is the minimum record for a durable decision. ExitConditions is what
// makes the decision revisitable.
type ADR struct {
	ID             string
	Title          string
	Status         Status
	Context        string
	Alternatives   []Alternative
	Decision       string
	Consequences   []string
	ExitConditions []string
}

func (a ADR) Validate() error {
	var missing []string
	add := func(name, value string) {
		if strings.TrimSpace(value) == "" {
			missing = append(missing, name)
		}
	}
	add("id", a.ID)
	add("title", a.Title)
	add("status", string(a.Status))
	add("context", a.Context)
	add("decision", a.Decision)
	if len(missing) > 0 {
		return fmt.Errorf("ADR %q: missing %s", a.ID, strings.Join(missing, ", "))
	}
	if !validStatuses[a.Status] {
		return fmt.Errorf("ADR %q: unknown status %q", a.ID, a.Status)
	}
	if len(a.Alternatives) < 2 {
		return fmt.Errorf("ADR %q: needs at least two alternatives, got %d", a.ID, len(a.Alternatives))
	}
	for i, alt := range a.Alternatives {
		if strings.TrimSpace(alt.Name) == "" {
			return fmt.Errorf("ADR %q: alternative %d has no name", a.ID, i+1)
		}
		if strings.TrimSpace(alt.RejectedBecause) == "" {
			return fmt.Errorf("ADR %q: alternative %q has no rejection reason", a.ID, alt.Name)
		}
	}
	if len(a.Consequences) == 0 {
		return fmt.Errorf("ADR %q: missing consequences", a.ID)
	}
	if len(a.ExitConditions) == 0 {
		return fmt.Errorf("ADR %q: missing exit conditions", a.ID)
	}
	for i, c := range a.ExitConditions {
		if strings.TrimSpace(c) == "" {
			return fmt.Errorf("ADR %q: exit condition %d is empty", a.ID, i+1)
		}
	}
	return nil
}

// ValidateAll also enforces unique IDs.
func ValidateAll(records []ADR) []error {
	var errs []error
	seen := make(map[string]bool, len(records))
	for _, a := range records {
		if err := a.Validate(); err != nil {
			errs = append(errs, err)
			continue
		}
		if seen[a.ID] {
			errs = append(errs, fmt.Errorf("ADR %q: duplicate id", a.ID))
			continue
		}
		seen[a.ID] = true
	}
	return errs
}

这段校验器有三个刻意的设计:

  • 备选少于两个直接失败。只有一个方案的“决策”不是决策,是通知。
  • 每个备选必须有否决理由。只列名字不写原因,等于把备选当装饰。
  • ExitConditions 为空或全空白直接失败。这是本节最重要的一条规则。

测试:缺退出条件必须被拒

// file: internal/adr/adr_test.go
// tier: L1
// verify: go test ./internal/adr/
package adr

import (
	"strings"
	"testing"
)

func validADR() ADR {
	return ADR{
		ID:     "ADR-005",
		Title:  "匹配状态由匹配服务独占",
		Status: Accepted,
		Context: "《群岛纪元》跨地域匹配需要持久化匹配票据," +
			"世界分片不能各自维护一份。",
		Alternatives: []Alternative{
			{Name: "世界分片持有匹配状态", RejectedBecause: "跨分片匹配没有单一权威,状态会分叉"},
			{Name: "匹配票据放共享 Redis", RejectedBecause: "原子转移与审计困难,故障时无法判断归属"},
		},
		Decision:       "匹配服务独占匹配票据,世界分片只通过命令和事件交互。",
		Consequences:   []string{"匹配服务成为有状态服务,发布需要排空", "世界分片多一次网络调用"},
		ExitConditions: []string{"匹配 p99 因服务跳转连续一周超过 2 秒", "跨地域匹配下线,单分片可独立完成匹配"},
	}
}

func TestValidateRejectsMissingExitConditions(t *testing.T) {
	cases := []struct {
		name string
		set  func(*ADR)
	}{
		{"nil", func(a *ADR) { a.ExitConditions = nil }},
		{"blank", func(a *ADR) { a.ExitConditions = []string{"  "} }},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			a := validADR()
			c.set(&a)
			err := a.Validate()
			if err == nil || !strings.Contains(err.Error(), "exit condition") {
				t.Fatalf("got %v, want missing exit conditions", err)
			}
		})
	}
}

func TestValidateRejectsWeakAlternatives(t *testing.T) {
	cases := []struct {
		name   string
		mutate func(*ADR)
		want   string
	}{
		{"single", func(a *ADR) { a.Alternatives = a.Alternatives[:1] }, "two alternatives"},
		{"no-reason", func(a *ADR) { a.Alternatives[0].RejectedBecause = "" }, "rejection reason"},
	}
	for _, c := range cases {
		a := validADR()
		c.mutate(&a)
		err := a.Validate()
		if err == nil || !strings.Contains(err.Error(), c.want) {
			t.Fatalf("%s: got %v, want %s", c.name, err, c.want)
		}
	}
}

func TestValidateRejectsBadStatusOrMissingConsequences(t *testing.T) {
	a := validADR()
	a.Status = "done"
	if err := a.Validate(); err == nil {
		t.Fatal("unknown status was accepted")
	}
	b := validADR()
	b.Consequences = nil
	err := b.Validate()
	if err == nil || !strings.Contains(err.Error(), "consequences") {
		t.Fatalf("got %v, want consequences error", err)
	}
}

本机验证:

$ cd .local/checks/gsarch-lab
$ gofmt -l .
$ go vet ./...
$ go test ./internal/adr/ -count=1
ok  	gsarchlab/internal/adr	0.755s

gofmt -l . 无输出,go vet ./... 无告警,模块级 go test ./... 全部通过;adr 包的三个测试也全部通过,其中包括“缺退出条件”的 nil 与空白两种输入。

常见错误与边界

第一,把 ADR 写成会议纪要。只记“决定了什么”,不记“为什么否掉别的”,半年后无法复用。

第二,备选是稻草人。故意列两个明显不合理的方案来衬托决定,评审时能过关,复盘时没有价值。

第三,退出条件不可观测。“将来不合适再改”不是条件,必须绑定指标、阈值和持续时间。

第四,状态从不更新。ADR 被取代后仍然标 accepted,团队会按旧决定执行。

第五,什么决定都写 ADR。只记录影响边界、成本、数据模型和故障域的 durable decision;命名规范不需要。

第六,ADR 只由架构师写。最好的 ADR 往往由提出反对意见的人补充,因为他们最清楚风险在哪。

小结

ADR 的最小结构是背景、备选、决定、后果、退出条件与状态。备选至少两个,每个都要有否决理由;退出条件必须可观测;状态必须随现实更新。ADR-005、ADR-006、ADR-007 分别固定了匹配状态归属、结算幂等和全球同服时间模型,每份都保留了被否决的方案和重新评估的信号。

adr 包把这些要求变成校验规则,尤其拒绝缺少退出条件的记录。下一节把这些 ADR 带进一次真实的架构评审,看看哪些风险被接受、哪些决策会被卡住。

阅读导航:上一节:2.1 质量属性与无法回避的取舍 · 下一节:2.3 一次真实的架构评审与风险台账 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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