《互联网产品的服务端架构·规模化与架构演进》12.2 决策权与评审

「回声」的支付团队把一个可逆的小改动送进架构评审,排了四天队,最后在凌晨绕过流程上线,反而放大了后续故障。本节按可逆性与影响面给决策分级,把“谁拍板、多久必须拍板”写成可测试的函数,并讨论评审队列为什么会变成新的瓶颈、团队自决又应该留下什么记录。

一个上线计划为什么在评审会上转了三次

「回声」的支付团队要在回调链路里加一个幂等键前缀,用来区分“重试”和“重复支付”。改动本身很小:写入时多拼一个固定字符串,读取时按同样规则解析,历史数据不需要迁移,出问题把开关关掉就能回到旧逻辑。

团队按流程提交了架构评审。评审会每周一次,议题排得很满,这个改动第一次上会时被问“影响面有多大”,团队回答“只影响支付回调”,评审结论是“下次带上压测数据”;第二次上会时压测数据有了,又被问“为什么不用现成的幂等框架”,团队解释框架不覆盖这个场景,评审结论是“再确认一下框架能力”;第三次上会时框架负责人不在,议题顺延。

从提交到通过用了十六天。这十六天里,支付回调的重复请求一直在产生,运营每天手工核对。团队最后等不及,在某个凌晨把开关打开上线了,没有评审记录。上线本身没出问题,但两周后另一个团队做类似改动时,找不到这次决策的依据,又把同样的讨论重新走了一遍。

复盘时大家发现,问题不在于评审太严格,而在于分级缺失:一个可逆、影响面局限在单个服务内的改动,和一次不可逆的协议变更,走了同一条队列、同一套等待时间。队列被低风险决策占满,高风险决策反而排不上;团队为了推进业务,只能绕过流程。绕过一次之后,流程的约束力就少了一分。

更隐蔽的代价是信息丢失。凌晨上线的改动没有留下评审记录,其他团队无法从中学到“这个场景为什么不用现成框架”。同一个问题在半年内被重新讨论了三次,每次都要重新查资料、重新找人。评审的价值本来有一部分是沉淀决策依据,绕过流程之后,这部分价值直接归零。

先看可逆性,再看影响面

决策分级的第一个问题不是“这个决定重不重要”,而是“做错了能不能低成本退回来”。可逆性决定了决策的错误成本上限;影响面决定了错误会波及多少系统、多少用户、多少团队。两个维度组合起来,才能回答“谁应该拍板”。

「回声」采用的规则是:可逆且影响分低于 3 的决策,团队自己定;不可逆或影响分达到 3 的决策,进入架构评审。影响分用 0 到 5 表示,0 是单服务内部、无外部可见变化,5 是跨多个系统、涉及数据迁移或对外协议。

决策类型可逆性影响分拍板人决策时限
调整单服务缓存 TTL可逆1团队3 天
新增内部只读接口可逆1团队3 天
修改日志字段格式可逆2团队3 天
新增跨服务调用链路可逆3架构评审2 天
修改对外 API 语义不可逆3架构评审2 天
更换主键或分片键不可逆5架构评审1 天

表里的时限是“最长等待时间”,不是“必须讨论这么久”。影响分越高,等待的代价越大,所以时限反而更短。一个影响分 5 的决策拖一周,业务可能已经错过了窗口;限时一天,至少逼着评审给出“通过、否决、或者明确还缺什么”的结论,而不是无限顺延。

可逆性只决定拍板人,不直接决定时限。不可逆的决策需要更多信息,但不等于可以慢慢拖;正确的做法是提前准备材料、指定必须到场的人,把评审开成一次有结论的会议,而不是一轮又一轮的信息收集。

影响分怎么打

影响分最怕两种极端:所有人都填 5,分级就失去意义;所有人都填 1,评审就形同虚设。比较可操作的做法是给每一档写一个可观察的判据:0 是只改实现、外部无感知;1 是单服务内可见;2 是同团队多个服务可见;3 是跨团队调用或数据结构变化;4 是涉及外部用户或资金;5 是不可逆的协议、主键或合规变更。

打分时只回答“如果做错了,最坏情况波及哪里”,不回答“这个功能重不重要”。功能重要不等于影响分高,一个重要的营销页面可能只改前端;一个不起眼的字段格式变化可能影响所有下游系统。把这两个问题分开,分数才会稳定。

分数出现分歧时,取更高的那一档,并在记录里写明分歧点。评审的时间应该花在“为什么你觉得是 3 而我觉得是 4”,而不是花在“到底该不该做”。前者能暴露未知依赖,后者通常已经在需求阶段讨论过。

评审不是审批

架构评审最容易退化成审批:团队交材料,评审组挑毛病,改完再交,直到没人反对。这种模式下,评审组成为瓶颈,团队把评审当成障碍,双方都在消耗。更有效的定位是“共同承担决策后果的会议”:评审组负责确认影响面、识别跨团队依赖、记录取舍,团队负责提供数据和执行。

要让评审不变成审批,至少要做到三件事:议题按影响分排序,高影响决策优先;每次评审必须给出明确结论和负责人;被否决的决策要写清楚否决理由和重新提交的条件。没有这三件事,评审就会退化成“谁声音大谁说了算”。

一个实用的检验是看评审会的产出形态。如果产出是一份会议纪要,说明大家还在“讨论”;如果产出是一组带负责人和截止时间的行动项,说明评审在“决策”。纪要可以很长,行动项通常很短;短不代表草率,它代表分歧已经被收敛到可以执行的程度。

评审组也不应该对所有技术细节负责。它的职责是确认影响面、识别跨团队依赖、记录取舍和风险,而不是替团队做实现选型。把实现细节也纳入评审,会让会议时间被低价值讨论占满,真正需要跨团队协调的问题反而没时间处理。

评审队列本身也需要容量管理。假设每周能认真处理的议题是八个(案例假设值),第九个之后就会开始走过场。队列满了之后,要么增加评审频次,要么把低影响议题分流给团队自决。最差的选择是既不扩容也不分流,让队列无限变长,最后所有人都在等。

还有一个常被忽略的指标:从提交到结论的时间。如果这个指标持续上升,说明分级或容量出了问题,而不是团队不够配合。把它放在评审看板的第一行,比统计“评审了多少个议题”更能反映流程健康度。

团队自决不等于没人看

团队自己拍板,不意味着决策不需要记录。可逆决策也应该留下一条简短记录:改了什么、为什么改、怎么回滚、观察哪些指标。这条记录不需要上会,但要在变更系统里可查。它的作用不是监督,而是在半年后有人问“为什么这里是这个值”时,能找到一个比“当时好像讨论过”更可靠的答案。

记录的长度可以很短,四条信息就够:背景一句话、选择一句话、回滚方式一句话、观察窗口一句话。超过四条的记录往往没人写,也没人看。关键不是格式完整,而是当变更出问题时,另一个人能凭这条记录判断“能不能直接回滚”,而不是先花半小时找作者。

一次“等评审”拖出来的故障

2026 年 6 月,「回声」的订单团队发现某个查询在高峰期偶尔超过两秒。原因是 order 表缺少一个联合索引,查询计划走了全表扫描。加索引的 SQL 已经写好,在预发环境验证过,回滚只需要 DROP INDEX。

团队判断这属于“数据库变更”,按惯例提交了架构评审。评审队列当时积压了十一个议题,这个索引排在第九。等待期间,订单列表的慢查询比例从 0.3% 上升到 2.1%,客服开始收到“订单加载不出来”的投诉。

第四天晚上,值班同学在群里问能不能先加索引,评审组的一位成员回复“先上吧,明天补记录”。索引加上后,慢查询立刻恢复。但“先上吧”这三个字没有进入任何系统,补记录的事情在第二天的迭代里被遗忘了。

两周后,另一个团队做容量评估时看到 order 表多了一个索引,不知道是谁加的、为什么加,于是在自己的变更里把它删掉以“减少写入开销”。慢查询再次出现,这一次赶上了大促前的压测,问题被放大成一次 P1 事故。

事故复盘时,争议的焦点不是“该不该加索引”,而是“为什么一个可逆的低风险变更要等四天”。如果团队一开始就有权自决,索引会在当天加上,并且留下变更记录;如果评审队列按影响分排序,这个议题也不会排在十一个议题的第九位。两个机制缺失叠加,最终把一个五分钟的变更拖成了两周后的事故。

还有一个细节值得记录:第二个团队删索引时并不是出于恶意。他们在做写入容量评估,看到索引没有登记来源,按“无主资源可以清理”的惯例处理。这说明变更记录不只是审计材料,它还是别人判断“这个东西能不能动”的依据。没有记录,合法的清理也会变成事故。

把决策权写成可测试的函数

配套代码 internal/org/decision.go 把分级规则固化成两个纯函数:Who 回答“谁拍板”,SLA 回答“最多等几天”。它不读取组织架构、不查询评审队列,因为这些运行时状态会让规则本身变得不可测试。

// file: internal/org/decision.go
// tier: L1
// verify: go test ./internal/org/

package org

// Decision 是一次需要确定拍板人的架构决策。
type Decision struct {
	Kind       string
	Impact     int  // 0-5,越大影响面越广
	Reversible bool // 能否以低成本回滚
}

const (
	// OwnerTeam 表示由提出决策的团队自行拍板。
	OwnerTeam = "team"
	// OwnerArch 表示必须进入架构评审。
	OwnerArch = "architecture-review"
)

const highImpact = 3

// Who 返回决策应由谁拍板。可逆且影响低于 highImpact 的决策由团队自己定;
// 不可逆或影响达到 highImpact 的决策进入架构评审。
func Who(d Decision) string {
	if !d.Reversible || d.Impact >= highImpact {
		return OwnerArch
	}
	return OwnerTeam
}

// SLA 返回该类决策的时限(天)。影响越大,允许的决策时间越短。
func SLA(d Decision) int {
	switch {
	case d.Impact >= 4:
		return 1
	case d.Impact == 3:
		return 2
	case d.Impact >= 1:
		return 3
	default:
		return 7
	}
}

Who 的判断顺序是先看可逆性,再看影响分。这个顺序很重要:一个影响分只有 1 但不可逆的决策,比如删除一个已经对外承诺的字段,仍然需要评审,因为错误成本不是由影响面决定的,而是由“能不能退回来”决定的。反过来,影响分 4 但可逆的决策,比如一次可以随时关闭的实验,也进入评审,因为波及面太大,团队内部看不到全部后果。

Impact 没有做负数校验。负数在业务上通常表示“还没评估”,Who 会把它当成低影响、SLA 会给出 7 天,相当于把未评估的决策放进最宽松的档位。这是一种有意的保守:与其让未评估的决策直接进入评审、挤占队列,不如让团队先补评估。真正要防止的是“随手填个 0 绕过评审”,这需要流程约束,不是函数能解决的。

SLA 用阶梯而不是线性公式。线性公式看起来优雅,但会把“影响分 2”和“影响分 3”的差异压得很小,而这两个档位恰恰对应“团队自决”和“必须评审”的边界。阶梯让边界清晰,也方便在评审看板上按档位设置提醒。

两个函数都不返回错误。决策分级的输入是结构化的,类型系统已经限制了大部分坏输入;如果为每个字段都加校验,调用方会写大量重复的错误处理,反而掩盖真正的业务判断。需要更严格校验的场景,应该在构造 Decision 的地方做,而不是在纯函数里做。

这套函数可以嵌入三个地方:变更系统的提交表单,在团队选择“申请评审”时实时显示分级结果;评审看板的排序逻辑,按影响分和提交时间排优先级;以及事后审计脚本,检查是否有高影响变更绕过了评审。三处使用同一份规则,才能避免“表单说不用评审、看板说必须评审”的矛盾。

函数不处理“评审组人选”。谁该参加评审取决于系统清单、历史负责人和当前值班表,这些是运行时数据,放进纯函数会让规则难以测试。更合理的分层是:Who 只回答“团队还是评审”,具体到人由评审系统根据系统 owner 和影响范围计算。

测试:分级边界与时限

测试重点锁住三件事:可逆性优先于影响分、影响分 3 是评审的边界、时限随影响分单调不增。再加一条独立性检查,确保未来有人把可逆性塞进时限计算时会被发现。

// file: internal/org/decision_test.go
// tier: L1
// verify: go test ./internal/org/

package org

import "testing"

func TestWho(t *testing.T) {
	cases := []struct {
		name string
		d    Decision
		want string
	}{
		{"reversible low impact", Decision{Kind: "library", Impact: 1, Reversible: true}, OwnerTeam},
		{"irreversible low impact", Decision{Kind: "schema", Impact: 1, Reversible: false}, OwnerArch},
		{"reversible high impact", Decision{Kind: "protocol", Impact: 3, Reversible: true}, OwnerArch},
		{"boundary below threshold", Decision{Kind: "flag", Impact: 2, Reversible: true}, OwnerTeam},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := Who(tc.d); got != tc.want {
				t.Fatalf("Who(%+v) = %q, want %q", tc.d, got, tc.want)
			}
		})
	}
}

func TestSLAGradedByImpact(t *testing.T) {
	cases := []struct {
		impact int
		want   int
	}{
		{-1, 7}, {0, 7}, {1, 3}, {2, 3}, {3, 2}, {4, 1}, {5, 1},
	}
	for _, tc := range cases {
		if got := SLA(Decision{Impact: tc.impact}); got != tc.want {
			t.Fatalf("SLA(impact=%d) = %d, want %d", tc.impact, got, tc.want)
		}
	}
}

func TestSLAIsIndependentOfReversibility(t *testing.T) {
	for _, impact := range []int{0, 2, 4} {
		a := SLA(Decision{Impact: impact, Reversible: true})
		b := SLA(Decision{Impact: impact, Reversible: false})
		if a != b {
			t.Fatalf("impact %d: reversible=%d irreversible=%d", impact, a, b)
		}
	}
}

TestWho 用表驱动覆盖了四种组合。最关键的两条是“不可逆低影响”和“可逆高影响”:前者验证可逆性单独就能触发评审,后者验证影响分单独就能触发评审。只测其中一条,实现很容易退化成“只看影响分”或“只看可逆性”。

TestSLAGradedByImpact 覆盖了负值、0 到 5 的每一档,并断言 3 和 4 之间有时限跳变。这个跳变对应“评审边界”和“最高影响”两个档位,是规则里最容易被改错的地方。测试把具体天数写死,是为了让规则变更必须显式修改测试,而不是悄悄漂移。

TestSLAIsIndependentOfReversibility 是一个防回归用例。直觉上很容易认为“不可逆的决策应该给更长时间”,但本节的规则是时限只由影响分决定:不可逆决定的是谁来拍板,不是可以拖多久。没有这条用例,后续维护者可能出于好意把可逆性加进 SLA,导致同一影响分的决策出现两个时限。

测试没有覆盖 Kind 字段。Kind 目前只用于记录和展示,不参与分级;如果未来要按决策类型设置不同规则,应该先明确类型枚举,再补测试,而不是让自由文本影响判断。

测试也没有断言“所有影响分都有对应时限”。SLA 的 default 分支会吞掉负数和 0,这看起来像遗漏,实际上是有意的兜底:输入异常时给出最长时限,让问题在流程里暴露,而不是让函数 panic 或返回 0 天。真正需要严格校验的调用方,应该在构造 Decision 时拒绝非法影响分。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/org/ -run 'TestWho|TestSLA' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestWho
=== RUN   TestWho/reversible_low_impact
=== RUN   TestWho/irreversible_low_impact
=== RUN   TestWho/reversible_high_impact
=== RUN   TestWho/boundary_below_threshold
--- PASS: TestWho (0.00s)
    --- PASS: TestWho/reversible_low_impact (0.00s)
    --- PASS: TestWho/irreversible_low_impact (0.00s)
    --- PASS: TestWho/reversible_high_impact (0.00s)
    --- PASS: TestWho/boundary_below_threshold (0.00s)
=== RUN   TestSLAGradedByImpact
--- PASS: TestSLAGradedByImpact (0.00s)
=== RUN   TestSLAIsIndependentOfReversibility
--- PASS: TestSLAIsIndependentOfReversibility (0.00s)
PASS
ok  	webapp/internal/org	0.210s

全部用例通过,退出码为 0。-run 'TestWho|TestSLA' 只筛选了决策分级相关用例,同包的归属检测与技术债测试没有被执行;需要全量确认时去掉 -run 再跑一次。

小结

决策分级的目的不是减少评审,而是把评审留给真正需要它的决策。可逆性回答“错了能不能退”,影响分回答“错了会波及谁”,两个问题都有了答案,拍板人和时限就不应该再靠会议现场临时决定。

评审队列一旦被低风险决策占满,团队就会开始绕过流程;绕过一次之后,流程的约束力会持续下降。给可逆的低影响决策明确的团队授权,给高影响决策明确的时限和结论要求,比不断增加评审环节更能保护决策质量。

下一节讨论技术债:边界和决策权都清楚之后,仍然会有一批“知道该做但永远排不上”的事情,需要用另一套排序方法处理。

一个可以直接落地的检查是:随机抽十条最近三个月的变更记录,看有多少条能回答“谁拍的板、依据是什么、怎么回滚”。如果超过三条答不上来,说明分级和记录还没有真正执行,而不是规则本身有问题。

阅读导航:上一节:12.1 团队边界与系统边界 · 下一节:12.3 技术债与演进路线 。

常见错误与边界

  • 所有决策都上评审。评审队列会被低风险议题占满,高影响决策反而排不上,团队开始绕过流程。
  • 只看影响分,不看可逆性。一个影响面很小但不可逆的决策,错误成本可能远高于一次可回滚的大范围实验。
  • 把时限当成讨论时长。时限是“最长等待”,不是“必须开够这么多天会”;高影响决策应该更早给结论。
  • 评审只给意见、不给结论。没有“通过、否决、缺什么”的明确输出,议题会在队列里反复流转。
  • 团队自决后不留记录。可逆决策也需要变更记录和回滚方案,否则半年后没人解释得清现状。
  • 用自由文本描述影响分。没有统一口径,5 分和 3 分全凭感觉,分级规则形同虚设。
  • 让评审组承担执行责任。评审组负责识别影响和记录取舍,执行与回滚仍然是团队的职责。
  • 把“没评估”当成低影响。未评估的决策应该先补评估,而不是默认走最宽松的团队自决档位。
  • 只记录结论,不记录依据。半年后回看时,结论本身无法解释取舍,依据才是可复用的部分。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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