《游戏服务端架构·品类、状态与拓扑》2.3 一次真实的架构评审与风险台账

《群岛纪元》全球同服方案评审只给了团队四十八小时。本节还原评审桌上的三个争议,建立包含概率、影响、分值、负责人、触发信号与缓解动作的风险台账,区分“接受的风险”和“被卡住的决策”,并用 Go 代码实现评分、排序与 Top N,引用 ADR-005、ADR-006、ADR-007 说明每个风险对应的架构决定。

评审通知只发了四十八小时

2026 年 9 月 7 日周一,北辰工作室发出评审通知:周三下午评审《群岛纪元》全球同服 v2 方案,四十八小时准备时间。参会五个人:SLG 组主程老周、平台组阿青、DBA 老陈、SRE 小许、制作人。评审规则写在通知最后一行:每个结论必须有负责人和可观测信号,没有信号的意见不进入决议。

评审材料只有四份:

输入来源在评审里的用途
ADR-0052.2 节匹配状态归属判断跨地域匹配能否全量上线
ADR-0062.2 节结算幂等判断幂等表是否需要提前分片
ADR-0072.2 节全球同服时间模型判断 UTC 方案是否满足合规
容量与事故复盘运维与客服给风险和缓解动作提供量级

这四份材料决定了评审的边界:不重新讨论“要不要全球同服”,只讨论“按这三份 ADR 落地时,哪些风险接受、哪些决策暂缓”。

评审桌上先摆什么

很多架构评审一上来就画图。这次评审先摆三张表:决定表、证据表、待定表。

  • 决定表:已经由 ADR 固定、本次不再重开的问题;
  • 证据表:容量、延迟、事故、合规条款,用来支撑风险评分;
  • 待定表:本次无法拍板、必须带回业务或法务的问题。

ADR-005 已经确定匹配状态归匹配服务,评审就不再讨论“要不要放 Redis”,只讨论“跨地域全量匹配的第一个月怎么控制风险”。

三个争议

争议一:跨地域匹配全量上线还是灰度

阿青主张全量。他的依据是压测数据:匹配服务在 8 万在线下 p99 为 420ms,低于 800ms 的门槛。

老周反对。他指出压测使用的是模拟票据,真实玩家的联盟关系、时区分布和重复匹配率都不同。ADR-005 的退出条件写的是“匹配 p99 因服务跳转连续一周超过 2 秒”,但灰度期根本还没积累一周数据。结论是不在评审桌上投票,改为“10% 灰度 + 每日看 p99”,对应风险台账里的 R-01。

争议二:幂等表现在分片还是等半年

DBA 老陈估算,按当前王战频率,结算明细表单表还能撑六个月。结算组认为王战开启瞬间的写入峰值是均值的 8 倍,等不到六个月。

ADR-006 的后果一栏已经写了“结算明细表成为写热点,需要按赛季或区服分片”。评审没有推翻 ADR,只补了一个触发条件:单表写入连续一周超过容量的 70% 就启动分片。

结论:暂不分片,但把分片触发条件写进台账,对应 R-07。

争议三:UTC 展示是否满足本地合规

ADR-007 选择 UTC 逻辑时钟,客户端按本地时区展示。法务没有出席,只在会前回了一句:“部分区域要求按本地日历结算,需确认。”

这句话让 ADR-007 无法完全关闭。评审把“本地日历合规”列为被卡住的决策,而不是假装它已经解决。对应风险 R-05。

风险台账的六个字段

评审的产出不是一份会议纪要,而是一张风险台账。每行必须有六个字段:

字段要求反例
概率1–5,有锚点“可能吧”
影响1–5,有锚点“挺严重”
分值概率 × 影响只写“高”
负责人一个具体的人“相关团队”
触发信号可观测、有阈值、有持续时间“感觉不对时”
缓解动作动作、截止时间、验证方式“加强监控”

“负责人”必须是一个人。写团队等于没人负责;“触发信号”必须能在监控或客服系统里看到;“缓解动作”必须能验收。

《群岛纪元》全球同服风险台账

编号风险概率影响分值负责人触发信号缓解动作
R-01跨地域匹配 p99 超 800ms4416老周p99>800ms 连续 10 分钟地域亲和匹配;超时降级本区撮合
R-02结算重复发奖2510老陈同一 battle_id 出现两次发奖ADR-006 幂等键唯一索引 + 日对账
R-03王战开启瞬间写放大5420老周写入 QPS 超基线 3 倍令牌桶 + 分批开放 + 只读降级
R-04UTC 与本地赛季展示错位339制作人相关客诉周环比 >20%展示层本地化 + 公告模板
R-05跨境数据合规不满足2510法务监管问询或条款更新数据分区与本地留存;暂缓承诺本地赛季
R-06世界分片热点4312阿青单分片 CPU>75% 持续 15 分钟动态分片 + 热点迁移
R-07幂等表成为写热点339老陈单表写入连续一周 >70%按赛季分表 + 历史归档
R-08匹配服务故障导致无法开战248阿青匹配成功率 <99%本地降级匹配 + 熔断
R-09回滚后事件格式不兼容248小许回滚后消费失败率 >1%事件版本兼容 + 双写窗口
R-10观测缺口导致定位慢428小许告警到定位 >30 分钟统一 trace + 风险看板

这张表里没有“高/中/低”三档,只有 1–25 的分值。分值不是为了精确预测,而是为了排序:评审时间有限,先讨论分值最高的三行。

评分不是拍脑袋

为了让不同人打出的分可比,团队给概率和影响各写了锚点:概率从 1(低于 5%)到 5(高于 70%),影响从 1(玩家无感)到 5(资损、合规或不可恢复的数据错误)。分值区间固定为 1–4 低、5–9 中、10–16 高、17–25 严重。锚点写下来之后,老周和阿青对 R-01 的概率仍有分歧,但至少争的是“40% 还是 60%”,而不是“严重还是还好”。

评审结论:接受的风险与被卡住的决策

评审最后只输出两类结论,外加一份 Top N。

接受的风险:

  • R-04(9 分,中):UTC 展示错位接受为已知问题,制作人负责公告模板,2026-12-31 复盘;
  • R-10(8 分,中):观测缺口接受 30 天,SRE 小许负责补 trace,逾期升级为高。

接受不是忽略。每一条接受的风险都写明了负责人、复盘日期和升级条件。

被卡住的决策:

  • ADR-007 的“区域本地日历赛季”扩展被 R-05 卡住。法务确认前,产品不承诺任何按本地日历结算的运营活动。
  • ADR-005 的跨地域匹配全量上线被 R-01 卡住。先灰度 10%,连续一周 p99 达标再扩到 50%。
  • 王战全量同时开启被 R-03 卡住。改为分批开放,每批间隔 90 秒,观察写入曲线。

Top 3:R-03(20,严重)、R-01(16,高)、R-06(12,高)。评审的后续动作按这个顺序排期,而不是按谁先发言。

用代码评分、排序与 Top N

风险台账如果只存在表格里,排序会随人手动调整。把评分和排序写成代码,评审前跑一遍,Top N 就是可复现的。

// file: internal/risk/register.go
// tier: L1
// verify: go test ./internal/risk/
package risk

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

// Risk is one row of the review register. Probability and Impact are
// limited to 1..5: a ten-point scale invites false precision.
type Risk struct {
	ID          string
	Title       string
	Probability int
	Impact      int
	Owner       string
	Trigger     string
	Mitigation  string
	Accepted    bool
}

// Score is probability times impact, in the range 1..25.
func (r Risk) Score() int {
	return r.Probability * r.Impact
}

func (r Risk) Band() string {
	return Band(r.Score())
}

// Band maps a score to the review vocabulary; boundaries are tested.
func Band(score int) string {
	switch {
	case score >= 17:
		return "critical"
	case score >= 10:
		return "high"
	case score >= 5:
		return "medium"
	case score >= 1:
		return "low"
	default:
		return "invalid"
	}
}

func (r Risk) Validate() error {
	var missing []string
	add := func(name, value string) {
		if strings.TrimSpace(value) == "" {
			missing = append(missing, name)
		}
	}
	add("id", r.ID)
	add("title", r.Title)
	add("owner", r.Owner)
	add("trigger", r.Trigger)
	add("mitigation", r.Mitigation)
	if len(missing) > 0 {
		return fmt.Errorf("risk %q: missing %s", r.ID, strings.Join(missing, ", "))
	}
	if r.Probability < 1 || r.Probability > 5 {
		return fmt.Errorf("risk %q: probability %d outside 1..5", r.ID, r.Probability)
	}
	if r.Impact < 1 || r.Impact > 5 {
		return fmt.Errorf("risk %q: impact %d outside 1..5", r.ID, r.Impact)
	}
	return nil
}

// Rank returns a sorted copy: higher score first, then ID ascending.
func Rank(risks []Risk) ([]Risk, error) {
	for _, r := range risks {
		if err := r.Validate(); err != nil {
			return nil, err
		}
	}
	out := append([]Risk(nil), risks...)
	sort.SliceStable(out, func(i, j int) bool {
		si, sj := out[i].Score(), out[j].Score()
		if si != sj {
			return si > sj
		}
		return out[i].ID < out[j].ID
	})
	return out, nil
}

// TopN is the list the review meeting actually discusses.
func TopN(risks []Risk, n int) ([]Risk, error) {
	ranked, err := Rank(risks)
	if err != nil {
		return nil, err
	}
	if n <= 0 {
		return []Risk{}, nil
	}
	if n > len(ranked) {
		n = len(ranked)
	}
	return ranked[:n], nil
}

Rank 先校验再排序,分数相同按编号升序,保证两次评审得到同一个顺序。TopN 对非正数返回空列表,避免评审脚本因为参数写错而崩溃。

测试边界分数

风险评分的错误通常出在边界:4 分算低还是中,9 分算中还是高,16 分算高还是严重。

// file: internal/risk/register_test.go
// tier: L1
// verify: go test ./internal/risk/
package risk

import (
	"fmt"
	"strings"
	"testing"
)

func validRisk(id string, probability, impact int) Risk {
	return Risk{
		ID:          id,
		Title:       "测试风险",
		Probability: probability,
		Impact:      impact,
		Owner:       "世界组",
		Trigger:     "触发信号",
		Mitigation:  "缓解动作",
	}
}

func TestBandAndScoreBoundaries(t *testing.T) {
	cases := []struct {
		probability int
		impact      int
		score       int
		band        string
	}{
		{1, 1, 1, "low"},
		{1, 4, 4, "low"},
		{1, 5, 5, "medium"},
		{3, 3, 9, "medium"},
		{2, 5, 10, "high"},
		{4, 4, 16, "high"},
		{5, 4, 20, "critical"},
		{5, 5, 25, "critical"},
	}
	for _, c := range cases {
		r := validRisk("R-1", c.probability, c.impact)
		if r.Score() != c.score || r.Band() != c.band {
			t.Fatalf("%dx%d: got score=%d band=%s", c.probability, c.impact, r.Score(), r.Band())
		}
	}
	if Band(0) != "invalid" || Band(26) != "critical" {
		t.Fatal("band did not handle out-of-range scores")
	}
}

func TestValidateRejectsOutOfRange(t *testing.T) {
	cases := [][2]int{{0, 3}, {6, 3}, {3, 0}, {3, 6}}
	for _, c := range cases {
		if err := validRisk("R-1", c[0], c[1]).Validate(); err == nil {
			t.Fatalf("p=%d i=%d was accepted", c[0], c[1])
		}
	}
}

func TestRankOrdersByScoreThenID(t *testing.T) {
	risks := []Risk{
		validRisk("R-B", 2, 5), // 10
		validRisk("R-A", 2, 5), // 10
		validRisk("R-C", 5, 5), // 25
		validRisk("R-D", 1, 1), // 1
	}
	ranked, err := Rank(risks)
	if err != nil {
		t.Fatalf("Rank: %v", err)
	}
	want := []string{"R-C", "R-A", "R-B", "R-D"}
	for i, r := range ranked {
		if r.ID != want[i] {
			t.Fatalf("position %d = %s, want %s", i, r.ID, want[i])
		}
	}
	if risks[0].ID != "R-B" {
		t.Fatal("Rank mutated the input slice")
	}
}

func TestTopNEdgeCases(t *testing.T) {
	risks := []Risk{validRisk("R-1", 5, 5), validRisk("R-2", 3, 3)}
	for _, n := range []int{-1, 0} {
		got, err := TopN(risks, n)
		if err != nil || len(got) != 0 {
			t.Fatalf("TopN(%d) = %d risks, err=%v", n, len(got), err)
		}
	}
	got, err := TopN(risks, 10)
	if err != nil || len(got) != 2 {
		t.Fatalf("TopN(10) = %d risks, err=%v", len(got), err)
	}
}

func ExampleTopN() {
	riskList := []Risk{
		validRisk("R-01", 4, 4),
		validRisk("R-02", 2, 5),
		validRisk("R-03", 5, 4),
		validRisk("R-04", 3, 3),
		validRisk("R-06", 4, 3),
	}
	top, err := TopN(riskList, 3)
	if err != nil {
		fmt.Println(err)
		return
	}
	for _, r := range top {
		fmt.Printf("%s score=%d band=%s\n", r.ID, r.Score(), r.Band())
	}
	// Output:
	// R-03 score=20 band=critical
	// R-01 score=16 band=high
	// R-06 score=12 band=high
}

func TestValidateRejectsMissingOwner(t *testing.T) {
	r := validRisk("R-1", 3, 3)
	r.Owner = ""
	if err := r.Validate(); err == nil || !strings.Contains(err.Error(), "owner") {
		t.Fatalf("got %v, want owner error", err)
	}
}

本机验证:

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

gofmt -l . 无输出,go vet ./... 无告警,模块级 go test ./... 全部通过;risk 包也全部通过。ExampleTopN 的 // Output: 断言了台账里 Top 3 的顺序:R-03 20 分、R-01 16 分、R-06 12 分。

常见错误与边界

第一,风险没有单一负责人。写“相关团队”等于没人负责,触发时只会互相等待。

第二,触发信号不可观测。“感觉不对”不是信号,必须绑定指标、阈值和持续时间。

第三,只列风险不做取舍。台账的产出必须是“接受哪些、卡住哪些”,否则风险列得再多也不产生决策。

第四,概率和影响拍脑袋。没有锚点,两个人对同一个风险会给出相差两倍的分值。

第五,评审没有 ADR 输入。没有 ADR-005、ADR-006、ADR-007,评审会退化成重新辩论已经决定过的问题。

小结

一次有效的架构评审,输入是 ADR 与证据,输出是风险台账与取舍。台账的每个风险都要有概率、影响、分值、单一负责人、可观测触发信号和可验证的缓解动作。评审结论必须区分“接受的风险”和“被卡住的决策”:R-04、R-10 被接受,R-05 卡住 ADR-007 的本地日历扩展,R-01 卡住 ADR-005 的全量匹配,R-03 卡住王战全量同开。

risk 包把评分、排序和 Top N 变成可复现代码,边界分数由测试固定。下一章从拓扑选择进入部署形态,讨论单体、分层与模块化单体在游戏服务端里的适用边界。

阅读导航:上一节:2.2 ADR:把决策、备选与后果写下来 · 下一节:3.1 单体、分层与模块化单体 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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