评审通知只发了四十八小时
2026 年 9 月 7 日周一,北辰工作室发出评审通知:周三下午评审《群岛纪元》全球同服 v2 方案,四十八小时准备时间。参会五个人:SLG 组主程老周、平台组阿青、DBA 老陈、SRE 小许、制作人。评审规则写在通知最后一行:每个结论必须有负责人和可观测信号,没有信号的意见不进入决议。
评审材料只有四份:
| 输入 | 来源 | 在评审里的用途 |
|---|---|---|
| ADR-005 | 2.2 节匹配状态归属 | 判断跨地域匹配能否全量上线 |
| ADR-006 | 2.2 节结算幂等 | 判断幂等表是否需要提前分片 |
| ADR-007 | 2.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 超 800ms | 4 | 4 | 16 | 老周 | p99>800ms 连续 10 分钟 | 地域亲和匹配;超时降级本区撮合 |
| R-02 | 结算重复发奖 | 2 | 5 | 10 | 老陈 | 同一 battle_id 出现两次发奖 | ADR-006 幂等键唯一索引 + 日对账 |
| R-03 | 王战开启瞬间写放大 | 5 | 4 | 20 | 老周 | 写入 QPS 超基线 3 倍 | 令牌桶 + 分批开放 + 只读降级 |
| R-04 | UTC 与本地赛季展示错位 | 3 | 3 | 9 | 制作人 | 相关客诉周环比 >20% | 展示层本地化 + 公告模板 |
| R-05 | 跨境数据合规不满足 | 2 | 5 | 10 | 法务 | 监管问询或条款更新 | 数据分区与本地留存;暂缓承诺本地赛季 |
| R-06 | 世界分片热点 | 4 | 3 | 12 | 阿青 | 单分片 CPU>75% 持续 15 分钟 | 动态分片 + 热点迁移 |
| R-07 | 幂等表成为写热点 | 3 | 3 | 9 | 老陈 | 单表写入连续一周 >70% | 按赛季分表 + 历史归档 |
| R-08 | 匹配服务故障导致无法开战 | 2 | 4 | 8 | 阿青 | 匹配成功率 <99% | 本地降级匹配 + 熔断 |
| R-09 | 回滚后事件格式不兼容 | 2 | 4 | 8 | 小许 | 回滚后消费失败率 >1% | 事件版本兼容 + 双写窗口 |
| R-10 | 观测缺口导致定位慢 | 4 | 2 | 8 | 小许 | 告警到定位 >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 单体、分层与模块化单体 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。