《游戏服务端架构·平台化与架构治理》8.1 架构评审与决策权

一次被归类为“小改动”的付费字段调整绕过了评审,两周后两个产品的结算口径出现分叉。本节把变更分为小改动、跨服务、跨产品、数据与付费四类,明确每类由谁批准、谁只能建议、谁能否决,并给出背景、选项、后果、风险、回滚五件材料与时间盒结论的记录方式,最后附一份可直接使用的评审检查表。

一次“小改动”绕过评审,两周后才被发现

《群岛纪元》的付费礼包需要增加一个“跨区领取”字段。改动只有两行:一行业务结构体,一行数据库映射。提交人按团队惯例判断这是小改动,没有发起跨服务评审,直接走普通代码合并。

两周后,运营发现同一个礼包在两个产品里的可领取次数不一致。原因是这个字段被结算服务和订单服务同时读取,两个服务对“跨区”的理解不同:一个按账号维度计数,一个按角色维度计数。字段本身没有错,错的是没人把“两个服务共享同一个数据语义”这件事摆到桌面上。

这次事故的问题不在提交人偷懒,而在评审分级只写了“小改动可以简化流程”,却没有写清楚“什么算小改动”。按代码行数判断,两行当然小;按影响面判断,它同时改了数据模型、付费口径和两个服务的读取约定,属于最高一级。

这一节讨论治理机制的第一环:一个变更该由谁评审、谁拍板、谁只能提意见,以及一次评审结束时到底要留下什么。卷一刻画过怎么开好一次评审会,这里关心的是让这套机制长期运转的规则本身。

评审不是越重越好

把所有变更都送上架构委员会,看起来最安全,实际会迅速失效。评审资源有限,如果每个字段调整都排队,委员会要么变成橡皮图章,要么成为发布瓶颈,最后大家学会绕过它。

反过来,只评审“大项目”也不行。事故往往来自那些被归类为小的变更,因为大项目天然会被盯着,小改动才是制度盲区。

分级的目标不是减少评审数量,而是让评审强度与影响面匹配。判断影响面看四个信号,而不是看代码行数:

  • 是否跨服务:一个服务内部改实现,和两个服务改契约,风险不同。
  • 是否跨产品:是否会影响另一个产品的玩家、结算或运营活动。
  • 是否改数据模型:字段含义、精度、归属或生命周期是否变化。
  • 是否涉及付费:是否改变充值、发货、退款、补偿或对账口径。

四个信号里只要有一个为真,就不能按“小改动”处理。

四级变更与四个评审层级

变更类别典型信号评审层级决策者时间盒(示例值)
小改动单服务内部实现、无契约变化团队评审技术负责人30 分钟
跨服务两个及以上服务改接口或数据约定服务评审相关领域负责人 + 平台负责人60 分钟
跨产品影响两个产品的玩家或运营产品评审相关产品负责人 + 平台负责人90 分钟
数据与付费改数据模型、精度、归属、对账或发货架构委员会数据负责人 + 产品负责人 + 架构师120 分钟

这张表有两个容易误读的地方。

第一,层级是“最低要求”,不是“上限”。一个跨服务变更如果同时改了付费字段,应该直接进入最高一级,而不是先开服务评审再升级。降级需要理由,升级不需要。

第二,时间盒约束的是会议,不是决策质量。时间盒到了还没有结论,正确做法是记录“缺什么信息、谁在什么时间补齐”,然后休会,而不是用投票把不确定压过去。

三种权力:建议、批准、否决

治理机制最常见的失败,是把“参加了会”当成“有了决策权”。角色必须和权力绑定,否则会议记录无法解释谁该负责。

角色建议权批准权否决权主要职责
技术负责人有小改动无单服务实现与本地取舍
领域负责人有跨服务无领域模型与数据语义
平台负责人有跨服务、跨产品无平台契约与长期维护成本
产品负责人有跨产品、付费无玩家体验与商业规则
数据负责人有数据模型数据与付费类数据归属、精度与合规
架构师有数据与付费类跨产品类跨产品一致性与长期演进

建议权、批准权、否决权是三件不同的事:

  • 建议权不参与结论计算。顾问可以强烈反对,但只要没有否决权,就不能单独阻断变更。
  • 批准权是正面同意。缺少任何一个批准人,结论只能是“未决”,不能默认通过。
  • 否决权是否定能力,不需要同时是批准人。数据负责人对数据模型拥有否决权,是因为数据一旦写错,回滚成本远高于晚发一个版本。

一个角色可以同时拥有批准权和否决权,但同一件事上只能行使一种。把两种权力混在一起,会议记录会出现“他既同意又反对”的歧义。

评审材料五件套

评审材料不是背景介绍,而是让决策可复核的证据。五件材料缺一件,评审就应该退回补材料,而不是在会上现编。

材料必须回答的问题常见不合格写法
背景现在哪里疼,疼到什么程度“领导要求优化”
选项至少两个可行方案,各自代价只写一个方案,另写“不做”凑数
后果选了这个方案,哪些指标、团队、合同会变“提升性能”但没有基线和目标
风险最坏情况是什么,触发条件是什么“风险可控”
回滚出问题后多久能退回去,数据怎么办“回滚代码即可”

“选项”这一项最容易被敷衍。只有一个方案的评审不是评审,是通知。第二个方案可以是“维持现状”,但必须写清楚维持现状的代价,否则它不构成真正的比较。

“回滚”也不是一句“回滚代码”。如果新版本已经写入新字段,旧版本能不能读;如果已经发货,能不能撤销;如果已经通知玩家,怎么解释。这些都要在评审前写清楚,而不是出事后再想。

时间盒与结论记录

一次评审的有效产出不是“大家讨论得很充分”,而是一条可执行的结论。结论只有四种:批准、带条件批准、拒绝、延期。每种结论都要记录以下字段:

字段说明
结论四选一,不允许“原则上同意”
决策人实际行使批准权或否决权的人
条件仅带条件批准时存在,每条必须有 owner 和截止时间
异议未改变结论但需要留档的反对意见
复核点什么时间、看什么指标,决定是否继续

“原则上同意”是最危险的结论,因为它既不能阻止实施,也不能要求任何人负责。要么批准,要么带条件批准,要么拒绝。

带条件批准的条件必须可验收。写成“后续补监控”不算条件,写成“上线前由数据负责人在 11 月 8 日前确认对账口径,并附一份差异样本”才算。

会议结束后 24 小时内归档结论。归档不是把聊天记录贴进文档,而是把上面的字段写进一个可查询的结构。下一节的代码会把这件事做成状态机。

代码:把决策权写进裁决函数

下面的 internal/archreview/review.go 处理评审材料和投票。分级与角色定义在同一个包的 policy.go 里,文章不展开;这里关注的是材料校验、法定人数、否决权和条件约束如何变成返回值。

package archreview

import (
	"errors"
	"fmt"
	"sort"
	"strings"
	"time"
)

var (
	ErrInvalidChange = errors.New("archreview: change is incomplete")
	ErrQuorum        = errors.New("archreview: required reviewer has not voted")
	ErrSelfApproval  = errors.New("archreview: author cannot decide own change")
	ErrCondition     = errors.New("archreview: condition needs owner, text and deadline")
	ErrBadVerdict    = errors.New("archreview: unknown verdict")
)

// Option is a seriously considered alternative. RejectedBecause is mandatory:
// options invented after the decision are not alternatives.
type Option struct {
	Name            string
	RejectedBecause string
}

// Change is the material a review acts on. The five sections map to the five
// things a reviewer needs before deciding: context, options, consequences,
// risks and rollback.
type Change struct {
	ID                  string
	Class               Class
	Author              string
	Services            []string
	Products            []string
	TouchesDataModel    bool
	TouchesMonetization bool
	Background          string
	Options             []Option
	Risks               []string
	Rollback            string
}

// Validate rejects incomplete material before any reviewer spends time on it.
// The validation is deliberately about presence, not quality: quality is what
// the review meeting is for.
func (c Change) Validate() error {
	if strings.TrimSpace(c.ID) == "" || strings.TrimSpace(c.Author) == "" {
		return fmt.Errorf("%w: id or author", ErrInvalidChange)
	}
	if _, ok := TierFor(c.Class); !ok {
		return fmt.Errorf("%w: unknown class %q", ErrInvalidChange, c.Class)
	}
	if strings.TrimSpace(c.Background) == "" {
		return fmt.Errorf("%w: background", ErrInvalidChange)
	}
	if len(c.Options) < 2 {
		return fmt.Errorf("%w: at least two options", ErrInvalidChange)
	}
	for _, o := range c.Options {
		if strings.TrimSpace(o.Name) == "" || strings.TrimSpace(o.RejectedBecause) == "" {
			return fmt.Errorf("%w: every option needs a rejection reason", ErrInvalidChange)
		}
	}
	if len(c.Risks) == 0 {
		return fmt.Errorf("%w: risks", ErrInvalidChange)
	}
	if strings.TrimSpace(c.Rollback) == "" {
		return fmt.Errorf("%w: rollback", ErrInvalidChange)
	}
	if c.Class == CrossService && len(c.Services) < 2 {
		return fmt.Errorf("%w: cross-service needs two services", ErrInvalidChange)
	}
	if c.Class == CrossProduct && len(c.Products) < 2 {
		return fmt.Errorf("%w: cross-product needs two products", ErrInvalidChange)
	}
	if c.Class == DataOrMonetized && !c.TouchesDataModel && !c.TouchesMonetization {
		return fmt.Errorf("%w: data-or-monetized needs a data or monetization flag", ErrInvalidChange)
	}
	return nil
}

type Verdict string

const (
	Approved               Verdict = "approved"
	ApprovedWithConditions Verdict = "approved-with-conditions"
	Rejected               Verdict = "rejected"
	Deferred               Verdict = "deferred"
)

type Vote struct {
	Seat    Seat
	Actor   string
	Verdict Verdict
	Comment string
}

// Condition is a binding follow-up. A condition without an owner or a
// deadline is not a condition, it is a wish.
type Condition struct {
	Text  string
	Owner string
	Due   time.Time
}

type Decision struct {
	ReviewID   string
	Verdict    Verdict
	DecidedBy  []string
	Conditions []Condition
	At         time.Time
}

// Decide computes the outcome from the votes that fill the required seats.
// Advisor votes are recorded but never change the outcome. A missing
// approver or veto seat returns ErrQuorum instead of silently approving.
func Decide(c Change, votes []Vote, conditions []Condition, at time.Time) (Decision, error) {
	if err := c.Validate(); err != nil {
		return Decision{}, err
	}
	seats, _ := Requirements(c.Class)
	bySeat := make(map[Seat]Vote, len(votes))
	for _, v := range votes {
		if v.Actor == c.Author && (v.Seat.Authority == Approver || v.Seat.Authority == Veto) {
			return Decision{}, ErrSelfApproval
		}
		if v.Seat.Authority == Approver || v.Seat.Authority == Veto {
			bySeat[v.Seat] = v
		}
	}
	for _, s := range seats {
		if s.Authority == Advisor {
			continue
		}
		if _, ok := bySeat[s]; !ok {
			return Decision{}, fmt.Errorf("%w: %s", ErrQuorum, s.Role)
		}
	}
	out := Approved
	for _, s := range seats {
		if s.Authority == Advisor {
			continue
		}
		v, ok := bySeat[s]
		if !ok {
			continue
		}
		switch v.Verdict {
		case Approved:
		case ApprovedWithConditions:
			if out == Approved {
				out = ApprovedWithConditions
			}
		case Deferred:
			if out != Rejected {
				out = Deferred
			}
		case Rejected:
			out = Rejected
		default:
			return Decision{}, fmt.Errorf("%w: %q", ErrBadVerdict, v.Verdict)
		}
	}
	if out == ApprovedWithConditions {
		if len(conditions) == 0 {
			return Decision{}, fmt.Errorf("%w: conditioned approval has no condition", ErrCondition)
		}
		for _, cond := range conditions {
			if strings.TrimSpace(cond.Text) == "" || strings.TrimSpace(cond.Owner) == "" || !cond.Due.After(at) {
				return Decision{}, ErrCondition
			}
		}
	} else if len(conditions) > 0 {
		return Decision{}, fmt.Errorf("%w: conditions require a conditioned approval", ErrCondition)
	}
	return Decision{
		ReviewID:   c.ID,
		Verdict:    out,
		DecidedBy:  deciders(seats, bySeat),
		Conditions: conditions,
		At:         at,
	}, nil
}

func deciders(seats []Seat, votes map[Seat]Vote) []string {
	seen := make(map[string]bool, len(votes))
	out := make([]string, 0, len(votes))
	for _, s := range seats {
		v, ok := votes[s]
		if !ok || v.Actor == "" || seen[v.Actor] {
			continue
		}
		seen[v.Actor] = true
		out = append(out, v.Actor)
	}
	sort.Strings(out)
	return out
}

四个约束值得单独说明:

  • 材料不全直接拒绝。Validate 检查背景、两个选项、每个选项的放弃理由、风险、回滚,以及跨服务/跨产品/数据类变更的额外字段。它不判断材料写得好不好,只判断有没有。
  • 法定人数按角色算,不按人头算。同一角色来两个人不能顶两个席位;顾问缺席不影响 quorum,批准人和否决人缺席则返回 ErrQuorum。
  • 作者不能批准自己的变更。这条校验放在投票入口,而不是放在会议流程里,因为流程可以被绕开,函数不会。
  • 条件必须有时限和 owner。带条件批准如果没有条件,或者条件没有 owner、截止时间不在未来,直接返回 ErrCondition。这把“先上线再说”挡在了结论生成之前。

Decide 的返回顺序也经过设计:先校验材料,再校验 quorum,最后计算结论。这样调用方拿到的错误能区分“材料没准备好”和“人没到齐”,值班同学不需要猜。

失败路径

失败路径表现兜底
分级靠感觉同类变更有时上会、有时不上会用四个信号做判定,写进提交模板
批准人缺席仍通过会议纪要写“默认同意”缺批准人返回未决,不允许默认
顾问一票否决讨论被没有否决权的人拖住结论计算只纳入批准与否决席位
作者自批小团队里“我审我自己”入口校验作者身份,命中即拒绝
条件无主“后续优化”永远没人做条件必须有 owner 和未来截止时间
结论不可查只有聊天记录,没有结构化归档结论写入评审表,按 ID 查询

评审检查表

发起评审前:

  • 用四个信号判断类别,而不是用代码行数。
  • 确认没有降级;如果降级,写明理由和批准人。
  • 背景里有基线数字,不是形容词。
  • 至少两个方案,每个方案都有放弃理由。
  • 风险写了触发条件和最坏情况。
  • 回滚写了数据处置,不只写代码回退。

评审结束时:

  • 结论是四选一,没有“原则上同意”。
  • 决策人按角色记录,不按部门记录。
  • 每条条件都有 owner 和截止时间。
  • 异议已留档,即使没有改变结论。
  • 复核点和观察指标已写明。
  • 结论在 24 小时内进入可查询的归档。

小结

评审分级解决“哪些变更需要多重评审”,决策权解决“谁的意见能改变结论”,材料与结论记录解决“事后能不能复核”。三者缺一个,评审就会退化成两种极端:要么所有事都上会,要么所有事都可以“特事特办”。

分级要按影响面而不是代码量;权力要区分建议、批准与否决;结论要可执行、可追溯、有时限。下一节讨论这些结论如何变成可度量的技术债和适应度函数,让治理从一次性的会议变成持续运行的指标。

阅读导航:上一节:7.3 真实事故案例拆解 · 下一节:8.2 技术债与架构适应度函数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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