“四个 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:把决策、备选与后果写下来 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。