《游戏服务端架构·品类、状态与拓扑》2.1 质量属性与无法回避的取舍

北辰工作室把“低延迟、强一致、低成本、快迭代”全写成 P0,评审直接卡住。本节用来源、刺激、制品、环境、响应、度量六要素,为《裂隙战场》《群岛纪元》《泡泡工坊》各写四条可测质量属性场景,做一张延迟、一致性、成本与可修改性的取舍矩阵,并用 Go 代码校验场景完整性、统计覆盖缺口,最后说明为什么没有架构能同时满足全部属性。

“四个 P0”把评审卡住了

北辰工作室的平台组写过一份架构需求文档。第一页只有四行:

延迟:P0,必须行业领先
一致性:P0,绝不能丢数据
成本:P0,预算不能超
可修改性:P0,新玩法要能两周上线

这份文档在评审会上被退回了三次。退回的理由不是哪一条写错了,而是四条放在一起,没有人能说清楚该先牺牲什么。

竞技组说延迟最重要,SLG 组说一致性最重要,财务说成本最重要,制作人说迭代速度最重要。每个人都能举出反例,每个反例都成立。会议最后变成了部门投票,投完仍然没有可执行的结论。

问题出在需求被写成了形容词。形容词没有来源、没有环境、没有度量,因此无法判断两个目标何时冲突,也无法判断当前架构是否达标。

质量属性不是形容词,是可测场景

架构评审里能用的质量属性,必须写成六要素场景:

要素要回答的问题缺失后的后果
来源谁触发不知道是玩家、运营还是外部系统
刺激发生了什么无法复现,也无法压测
制品影响到哪个部件责任边界不清
环境正常、峰值还是故障把峰值要求当成平均值
响应系统应该做什么只写“要快”,没写行为
度量多少算达标验收只能靠感觉

这六项不是文档模板,而是一道过滤网:写不出度量的需求,通常还没想清楚。

举个例子,“结算要快”不是场景;“王战结束后,两个联盟争夺同一块领地,结算服务在跨地域高峰下必须在 30 秒内给出唯一归属,重复请求不得重复发奖”,才是场景。后者可以直接变成测试用例,也会直接暴露它与延迟、成本、可修改性的冲突。

三款产品的十二条场景

下面把北辰三款产品各写四条。每条都保留六要素,并且刻意选了会互相打架的场景。

《裂隙战场》:延迟优先,但不是只有延迟

编号来源刺激制品环境响应度量
Q-LAT-01排位赛玩家连续释放三个技能房间战斗循环周五晚高峰,单房间 10 人同一帧内完成结算并广播操作 p99 ≤120ms,帧 p99 ≤50ms
Q-LAT-02观战者中途加入正在进行的对局观战转发链路正常负载2 秒内看到当前战局首帧 ≤2s,后续延迟 ≤500ms
Q-CON-01断线玩家30 秒后重连房间会话与战斗状态网络抖动回到同一局、同一角色重连成功率 ≥99.9%,状态差异为 0
Q-COST-01运营活动期间房间数翻倍房间调度与专用服池活动高峰不降级开房单房间成本 ≤ 基线的 1.3 倍

《群岛纪元》:一致性优先,延迟可以谈

编号来源刺激制品环境响应度量
Q-CON-02两个联盟同时争夺同一块领地世界分片与结算服务跨地域高峰只有一个联盟获得归属冲突裁决一致率 100%
Q-CON-03离线玩家8 小时后重新上线离线推进与资源结算跨天按规则补齐资源且可追溯数值误差 0,补发 ≤5s
Q-LAT-03跨洲玩家打开世界地图地图查询与分片路由跨地域返回可交互地图首屏 p99 ≤2s
Q-COST-02财务月末核算世界分片与跨地域流量正常成本可按区域拆分区域归属误差 ≤1%

《泡泡工坊》:成本与可修改性优先

编号来源刺激制品环境响应度量
Q-LAT-04新玩家点击开始游戏登录与开房链路晚高峰10 秒内进入房间开房 p99 ≤3s,失败率 ≤0.5%
Q-COST-03三人团队新增一个玩法单体与模块边界日常迭代一周内上线且不回滚发布耗时 ≤2 人日
Q-MOD-01三人团队替换房间匹配算法房间模块接口日常迭代只改一个模块并独立测试影响文件 ≤5 个,回归 ≤30 分钟
Q-MOD-02平台组接入新的支付渠道平台适配层大版本前不改游戏服代码改动范围仅平台模块

十二条场景放在一起,覆盖统计并不平均:延迟 4 条、一致性 3 条、成本 3 条、可修改性 2 条。这个分布本身就是信息:团队口头说四个属性都是 P0,真正写得出度量的却只有两类。

冲突从哪里来

质量属性之间的冲突不是抽象的,每一对都能落到具体场景。

延迟与一致性

Q-CON-02 要求跨地域冲突裁决一致率 100%。如果两个地域的玩家几乎同时提交领地争夺,系统要么让一次请求等待另一地域确认,要么先返回“处理中”。前者增加延迟,后者降低即时反馈。竞技游戏会选“本地先判、服务端回滚”,SLG 会选“排队等唯一结果”,因为两类玩家对等待的容忍度不同。

一致性、可用性与成本

要保证跨地域强一致,通常需要多数派写入、跨地域复制和冲突处理。这些都是钱:更多节点、更高带宽、更复杂的运维。Q-COST-02 要求成本可拆分,恰恰说明跨地域流量已经大到需要单独核算。

成本、延迟与可修改性

《泡泡工坊》用模块化单体,部署成本最低,但模块之间共享进程和数据库,边界会随时间腐化。拆成独立服务可以提高可修改性和故障隔离,却立刻增加发布、监控、网络和值班成本。三个人维护十个服务,通常是负优化。

为了把操作延迟压到 120ms,团队会写定制协议、对象池、无锁队列和手写序列化。这些优化与具体玩法强绑定,换一套战斗规则就要重写。延迟越低,通用性往往越差。

一张没法回避的取舍矩阵

把四个属性两两摆开,冲突关系可以写成一张矩阵:

延迟一致性成本可修改性
延迟—强一致要等待多数派或跨域确认,延迟上升压延迟要专用资源和冗余,成本上升极致优化绑定玩法,替换成本上升
一致性同上—多副本、跨域复制、对账都增加成本一致协议越强,边界越难改
成本省钱通常意味着共享与复用,延迟变差降副本会牺牲一致性或可用性—单体便宜但耦合,拆分灵活但运维贵
可修改性通用抽象增加一次跳转或分支可演进的协议往往要保留兼容层拆分后的独立部署推高成本—

这张矩阵没有“双赢”格。每一格都在说同一件事:你只能选择把哪两个属性做深,其余两个用边界和降级兜住。

北辰三款产品的排序并不相同:

产品首要属性次要属性主动牺牲原因
《裂隙战场》延迟、公平成本可修改性手感与判定直接关系留存,定制优化可接受
《群岛纪元》一致性、可用性成本延迟全球同服卖点建立在“同一张地图”上
《泡泡工坊》成本、可修改性延迟强一致三人团队必须先活下来,休闲玩法容忍弱一致

把场景写成可检查的结构

场景写在文档里仍然会被遗忘。下一步是把它变成代码里的结构,让缺字段的评审材料无法进入评审。

// file: internal/quality/scenario.go
// tier: L1
// verify: go test ./internal/quality/
package quality

import (
	"fmt"
	"strings"
)

// Dimension is a quality attribute that a game backend can optimize for.
type Dimension string

const (
	Latency       Dimension = "latency"
	Consistency   Dimension = "consistency"
	Cost          Dimension = "cost"
	Modifiability Dimension = "modifiability"
)

var Dimensions = []Dimension{Latency, Consistency, Cost, Modifiability}

// Scenario is the six-part quality attribute scenario used in review.
type Scenario struct {
	ID          string
	Product     string
	Attribute   Dimension
	Source      string
	Stimulus    string
	Artifact    string
	Environment string
	Response    string
	Measure     string
}

func (d Dimension) Valid() bool {
	for _, known := range Dimensions {
		if d == known {
			return true
		}
	}
	return false
}

// Validate rejects incomplete scenarios, including blank strings.
func (s Scenario) Validate() error {
	var missing []string
	add := func(name, value string) {
		if strings.TrimSpace(value) == "" {
			missing = append(missing, name)
		}
	}
	add("id", s.ID)
	add("product", s.Product)
	add("attribute", string(s.Attribute))
	add("source", s.Source)
	add("stimulus", s.Stimulus)
	add("artifact", s.Artifact)
	add("environment", s.Environment)
	add("response", s.Response)
	add("measure", s.Measure)
	if len(missing) > 0 {
		return fmt.Errorf("scenario %q: missing %s", s.ID, strings.Join(missing, ", "))
	}
	if !s.Attribute.Valid() {
		return fmt.Errorf("scenario %q: unknown attribute %q", s.ID, s.Attribute)
	}
	return nil
}

// ValidateAll also rejects duplicate IDs, which would inflate coverage.
func ValidateAll(scenarios []Scenario) []error {
	var errs []error
	seen := make(map[string]bool, len(scenarios))
	for _, s := range scenarios {
		if err := s.Validate(); err != nil {
			errs = append(errs, err)
			continue
		}
		if seen[s.ID] {
			errs = append(errs, fmt.Errorf("scenario %q: duplicate id", s.ID))
			continue
		}
		seen[s.ID] = true
	}
	return errs
}

// Coverage summarizes how many scenarios exist per attribute and product.
type Coverage struct {
	Total       int
	ByDimension map[Dimension]int
	ByProduct   map[string]int
}

func Summarize(scenarios []Scenario) Coverage {
	c := Coverage{
		Total:       len(scenarios),
		ByDimension: make(map[Dimension]int),
		ByProduct:   make(map[string]int),
	}
	for _, s := range scenarios {
		c.ByDimension[s.Attribute]++
		c.ByProduct[s.Product]++
	}
	return c
}

// Missing returns the review dimensions that have no scenario at all.
func Missing(c Coverage) []Dimension {
	var out []Dimension
	for _, d := range Dimensions {
		if c.ByDimension[d] == 0 {
			out = append(out, d)
		}
	}
	return out
}

这段代码做了三件事:

  • Validate 把六个要素加上编号、产品、属性变成硬性必填项,空白字符串也算缺失;
  • ValidateAll 拒绝重复编号,避免覆盖率被重复场景虚增;
  • Summarize 与 Missing 按属性和产品统计,直接回答“哪一类属性一条场景都没有”。

它不判断场景写得好不好,只判断场景是否完整。完整性是评审的最低门槛,质量高低仍需要人来判断。

测试:缺字段必须被拒

光有校验函数不够,必须证明每缺一个字段都会被拒绝。

// file: internal/quality/scenario_test.go
// tier: L1
// verify: go test ./internal/quality/
package quality

import (
	"strings"
	"testing"
)

func validScenario() Scenario {
	return Scenario{
		ID:          "Q-LAT-01",
		Product:     "裂隙战场",
		Attribute:   Latency,
		Source:      "排位赛玩家",
		Stimulus:    "连续释放三个技能",
		Artifact:    "房间服战斗循环",
		Environment: "周五晚高峰,单房间 10 人",
		Response:    "三个技能在同一帧内完成结算并广播",
		Measure:     "操作 p99 不超过 120ms,帧 p99 不超过 50ms",
	}
}

func TestValidateRejectsEveryMissingField(t *testing.T) {
	cases := []struct {
		name   string
		mutate func(*Scenario)
	}{
		{"id", func(s *Scenario) { s.ID = "" }},
		{"product", func(s *Scenario) { s.Product = " " }},
		{"attribute", func(s *Scenario) { s.Attribute = "" }},
		{"source", func(s *Scenario) { s.Source = "" }},
		{"stimulus", func(s *Scenario) { s.Stimulus = "" }},
		{"artifact", func(s *Scenario) { s.Artifact = "" }},
		{"environment", func(s *Scenario) { s.Environment = "" }},
		{"response", func(s *Scenario) { s.Response = "" }},
		{"measure", func(s *Scenario) { s.Measure = "" }},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			s := validScenario()
			c.mutate(&s)
			err := s.Validate()
			if err == nil {
				t.Fatalf("scenario without %s was accepted", c.name)
			}
			if !strings.Contains(err.Error(), c.name) {
				t.Fatalf("error %q does not name %q", err, c.name)
			}
		})
	}
}

func TestValidateRejectsUnknownAttributeAndDuplicateID(t *testing.T) {
	s := validScenario()
	s.Attribute = "security"
	if err := s.Validate(); err == nil {
		t.Fatal("unknown attribute was accepted")
	}

	a, b := validScenario(), validScenario()
	errs := ValidateAll([]Scenario{a, b})
	if len(errs) != 1 || !strings.Contains(errs[0].Error(), "duplicate") {
		t.Fatalf("got %v, want one duplicate error", errs)
	}
}

func TestSummarizeAndMissing(t *testing.T) {
	scenarios := []Scenario{
		validScenario(),
		{ID: "Q-CON-01", Product: "群岛纪元", Attribute: Consistency},
		{ID: "Q-CON-02", Product: "群岛纪元", Attribute: Consistency},
		{ID: "Q-COST-01", Product: "泡泡工坊", Attribute: Cost},
	}
	c := Summarize(scenarios)
	if c.Total != 4 || c.ByDimension[Consistency] != 2 || c.ByProduct["群岛纪元"] != 2 {
		t.Fatalf("unexpected coverage: %+v", c)
	}
	missing := Missing(c)
	if len(missing) != 1 || missing[0] != Modifiability {
		t.Fatalf("missing=%v, want [modifiability]", missing)
	}
}

本机验证:

$ cd .local/checks/gsarch-lab
$ gofmt -l .
$ go vet ./...
$ go test ./internal/quality/ -count=1 -v
=== RUN   TestValidateRejectsEveryMissingField
--- PASS: TestValidateRejectsEveryMissingField (0.00s)
=== RUN   TestValidateRejectsUnknownAttributeAndDuplicateID
--- PASS: TestValidateRejectsUnknownAttributeAndDuplicateID (0.00s)
=== RUN   TestSummarizeAndMissing
--- PASS: TestSummarizeAndMissing (0.00s)
PASS
ok  	gsarchlab/internal/quality	0.454s

gofmt -l . 无输出,go vet ./... 无告警,模块级 go test ./... 全部通过;下面是 quality 包的详细结果。

没有架构能同时满足全部属性

十二条场景和一张取舍矩阵,最终只指向一个结论:没有架构能同时满足全部质量属性。

原因不是工程师不够聪明,而是属性之间存在结构性冲突:强一致需要等待,等待就是延迟;低延迟需要就近与冗余,就近与冗余就是成本;低成本需要共享,共享会模糊边界,边界模糊就难修改;易修改需要抽象与隔离,抽象与隔离会增加一跳,也会增加运维面。

所以架构决策的正确问法不是“怎么全都做到”,而是:这款产品的首要属性是哪两个,哪两个允许降级,每个降级由哪条场景度量、谁在什么时候复核。

《裂隙战场》选择延迟与公平,把可修改性降级为“定制但可测试”;《群岛纪元》选择一致性与可用性,把延迟降级为“可排队但可见”;《泡泡工坊》选择成本与可修改性,把强一致降级为“最终对账”。三者都没有做到全部属性,但三者都说清楚了代价。

常见错误与边界

第一,把质量属性写成形容词。“高性能、高可用、易扩展”无法验收,也无法暴露冲突。

第二,只写正常路径。所有质量属性场景都要说明环境:正常、峰值、故障,缺一个就会在线上补课。

第三,四条 P0 并列。P0 太多等于没有优先级,评审必须排出首要、次要与主动牺牲。

第四,用平均值当度量。延迟、结算、重连都要看分位数和失败率。

第五,覆盖率造假。重复场景、同义场景会把统计做漂亮,但不会减少未知风险。

第六,把取舍矩阵当成一次性文档。产品阶段变化后,首要属性会变,矩阵必须重写。

小结

质量属性必须写成六要素场景:来源、刺激、制品、环境、响应、度量。北辰三款产品各四条场景,覆盖了延迟、一致性、成本与可修改性,但分布并不平均。四个属性两两冲突,取舍矩阵没有双赢格;每款产品只能选两个做深,其余用边界和降级兜住。

quality 包把场景完整性变成可执行校验:缺任何一个字段、属性未知、编号重复,都会被拒绝。下一节讨论如何把“为什么选这个、为什么不选那个”固定下来,也就是 ADR。

阅读导航:上一节:1.3 品类如何决定拓扑:北辰三款产品 · 下一节:2.2 ADR:把决策、备选与后果写下来 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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