一次“小改动”绕过评审,两周后才被发现
《群岛纪元》的付费礼包需要增加一个“跨区领取”字段。改动只有两行:一行业务结构体,一行数据库映射。提交人按团队惯例判断这是小改动,没有发起跨服务评审,直接走普通代码合并。
两周后,运营发现同一个礼包在两个产品里的可领取次数不一致。原因是这个字段被结算服务和订单服务同时读取,两个服务对“跨区”的理解不同:一个按账号维度计数,一个按角色维度计数。字段本身没有错,错的是没人把“两个服务共享同一个数据语义”这件事摆到桌面上。
这次事故的问题不在提交人偷懒,而在评审分级只写了“小改动可以简化流程”,却没有写清楚“什么算小改动”。按代码行数判断,两行当然小;按影响面判断,它同时改了数据模型、付费口径和两个服务的读取约定,属于最高一级。
这一节讨论治理机制的第一环:一个变更该由谁评审、谁拍板、谁只能提意见,以及一次评审结束时到底要留下什么。卷一刻画过怎么开好一次评审会,这里关心的是让这套机制长期运转的规则本身。
评审不是越重越好
把所有变更都送上架构委员会,看起来最安全,实际会迅速失效。评审资源有限,如果每个字段调整都排队,委员会要么变成橡皮图章,要么成为发布瓶颈,最后大家学会绕过它。
反过来,只评审“大项目”也不行。事故往往来自那些被归类为小的变更,因为大项目天然会被盯着,小改动才是制度盲区。
分级的目标不是减少评审数量,而是让评审强度与影响面匹配。判断影响面看四个信号,而不是看代码行数:
- 是否跨服务:一个服务内部改实现,和两个服务改契约,风险不同。
- 是否跨产品:是否会影响另一个产品的玩家、结算或运营活动。
- 是否改数据模型:字段含义、精度、归属或生命周期是否变化。
- 是否涉及付费:是否改变充值、发货、退款、补偿或对账口径。
四个信号里只要有一个为真,就不能按“小改动”处理。
四级变更与四个评审层级
| 变更类别 | 典型信号 | 评审层级 | 决策者 | 时间盒(示例值) |
|---|---|---|---|---|
| 小改动 | 单服务内部实现、无契约变化 | 团队评审 | 技术负责人 | 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 技术债与架构适应度函数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。