《互联网产品的服务端架构·规模化与架构演进》13.2 架构健康度自评

「回声」年度架构评审把五个维度的加权分做成了团队排行榜,结果少报维度、只挑高分的团队排到了第一。本节解释权重、缺失维度归一化与 60/80 阈值的设计,给出加权评分与等级映射的 Go 实现和测试,并复盘一次用分数排名替代问题清单、导致安全债被平均掉的评审事故。

年度评审上,一张表格变成了排行榜

「回声」每年做一次架构评审。最初的流程很朴素:每个团队准备一页材料,讲过去一年做了什么、哪里欠债、明年准备还什么。会议开得很长,但至少信息是完整的,因为每个团队都要面对同一群听众的追问。

第三年,公司规模变大,评审改成异步:各团队填一张表,按五个维度打 0 到 100 分,评审委员会汇总后给出“架构健康度”。表格收上来,有人提议把总分排个序,做成红黄绿看板,方便领导一眼看出谁做得好。这个提议听起来只是可视化,实际上改变了整个评审的性质。

分数一旦进入排行榜,填写者就会开始优化分数,而不是描述事实。有团队只填自己有数据的三个维度,避开还在补的安全项;有团队把“明年计划做”写成“已完成”;也有团队如实填了低分,结果在资源分配会上被追问“为什么你们最差”。到第四年,评审委员会收到的分数整体上涨了十二分,但线上事故数量并没有下降。

问题不在于评分本身,而在于使用方式。健康度分数适合回答“同一个团队今年比去年好了还是差了”,不适合回答“哪个团队更值得拿资源”。前者是纵向比较,可以控制维度和权重不变;后者是横向排名,会奖励“少报”和“挑维度”。

这一节把自评拆成三件事:维度怎么选、权重怎么定、分数怎么用。然后给出一个可测试的加权评分函数,以及一次把分数当排名用出来的事故。

五个维度与一套权重

维度不是越多越好。维度太多,填写成本上升,最后大家凭印象打分;维度太少,又会把不同性质的问题压成一个数。「回声」最终保留五个维度,覆盖“系统能不能用、快不快、改得动吗、守得住吗、养得起吗”:

维度权重主要证据自评时最容易骗自己的地方
availability30SLO 达成率、故障复盘、切换演练记录把“没出过事故”当成“高可用”
performance25核心接口 p99、容量水位、压测报告只看平均值,不看尾延迟
maintainability20发布频率、回滚耗时、变更失败率把“代码能跑”当成“容易维护”
security15权限审计、漏洞修复时长、密钥轮换用“内网系统”解释未修复漏洞
cost10单位请求成本、闲置资源、预算偏差只看账单总额,不看成本归属

权重之和是 100,但这不意味着每个维度都必须有数据才能算分。权重表达的是“在资源有限时,哪个维度更不该欠债”,而不是“哪个维度更重要”。可用性权重最高,是因为它一旦失守,其他维度的努力都归零;成本权重最低,不是因为它不重要,而是因为它通常可以通过治理手段后置处理,且短期波动不至于让产品停摆。

权重必须版本化。调整权重是一次口径变更,不是一次参数微调。口径变了,今年的分数就不能和去年直接比较;如果非要比较,应该用旧权重把两年数据各算一遍,而不是拿新分数去解释旧结论。很多“架构健康度逐年提升”的报告,其实是权重悄悄向高分维度倾斜的结果。

缺失维度不能当成 0 分

假设一个团队只报了可用性和性能,都是 90 分。如果按满分 100 直接算,总分是 (90×30 + 90×25) / 100 = 49.5,看起来像“不及格”;但团队并不是在这两个维度上做得差,而是另外三个维度没有数据。把缺失当成 0 分,会把“没测”误判成“做得很差”。

更合理的做法是只对已报维度归一化:(90×30 + 90×25) / (30+25) = 90。这个分数回答的是“在你报告的范围内,你做得怎么样”,而不是“你整体有多健康”。它适合做纵向趋势,但不适合跨团队排名,因为两个团队报告的维度集合可能不同。

归一化不是免费午餐。它会让“少报维度”看起来没有代价,甚至因为避开了弱项而得到更高分。解决办法不是回到把缺失当 0 分,而是在报告里显式列出“覆盖了哪些维度、缺了哪些维度”,并把覆盖率作为独立指标展示。分数负责描述已测部分,覆盖率负责提醒未测部分,两者不能合并成一个数。

阈值是行动指令,不是评价标签

「回声」把分数分成三档:低于 60 是危险,60 到 79 是亚健康,80 及以上是健康。阈值的作用不是给人贴标签,而是触发不同的动作:

  • 危险:暂停新功能排期,先处理阻断项,两周内复评。
  • 亚健康:允许继续交付,但每个季度必须消化固定比例的欠债。
  • 健康:重点转向预防性投入,例如演练、容量规划和成本优化。

阈值应该由团队自己定义,但一旦定义就要稳定。把阈值从 60 改成 50,不会让系统变健康,只会让报告变好看。阈值调整应该和权重调整一样,走显式的口径变更流程,并保留旧口径下的历史分数。

一次“少报一项”的排名事故

第四年评审,委员会真的把总分做成了排行榜。A 团队只填了可用性和性能,两项都是 92 分,归一化后总分 92,排第一。B 团队完整填了五项,可用性 85、性能 80、可维护性 70、安全 55、成本 65,加权后 73,排在中游。领导在资源分配会上说,A 团队是标杆,B 团队要“向标杆学习”。

两个月后,B 团队在例行权限审计里发现一个越权读取接口,影响范围可控,但需要停机修复。B 团队按流程上报,评审委员会这才注意到:A 团队根本没有填安全维度,也没有做过权限审计。进一步检查发现,A 团队有一个高危漏洞已经挂了四个月,因为“不在今年 OKR 里”一直没排期。

事故的根因不是 A 团队隐瞒,而是评分机制奖励了“少报”。归一化让缺失维度不扣分,排行榜又让高分获得资源和话语权,两个设计单独看都合理,合在一起就变成了“谁报得少谁赢”。B 团队如实报告安全短板,反而在排名里吃了亏,这对组织的信号非常糟糕。

更隐蔽的问题是权重变更。那年年初,委员会把成本权重从 15 调到 10,把可用性从 25 调到 30,但没有通知所有团队。A 团队用的是旧权重表,B 团队用的是新权重表,两份分数在同一个排行榜上比较,本身就没有可比性。委员会后来复盘时承认:排行榜看起来很客观,实际上把口径差异、覆盖差异和权重版本差异全部藏进了一个整数里。

修复方案不是取消评分,而是改变分数的用途。总分只用于同一团队、同一口径下的纵向比较;跨团队展示时,必须同时给出维度覆盖、阻断项清单和权重版本。任何 severity 达到阻断级别的发现,都不能被加权平均掉,必须单独列在报告最前面。分数可以排序,但不能替代问题清单。

把评分写成可解释的纯函数

下面的实现把权重写成常量表,把“缺失维度归一化”和“分数范围校验”放进同一个函数。它不读取配置、不访问数据库,也不关心报告怎么排版,因为这些都会让“同样的输入是否得到同样的分数”难以验证。

// file: internal/scalebase/health.go
// tier: L1
// verify: go test ./internal/scalebase/

package scalebase

import (
	"fmt"
	"math"
)

// weights 是各维度在总分里的权重,总和为 100。
// 权重写成常量表而不是参数,是为了让“今年和去年的分数可比”;
// 权重本身可以调整,但调整必须是一次显式的版本变更。
var weights = map[string]int{
	"availability":    30,
	"performance":     25,
	"maintainability": 20,
	"security":        15,
	"cost":            10,
}

// Dimension 是一项自评结果。Name 必须出现在权重表里。
type Dimension struct {
	Name  string
	Score int
}

// Score 计算加权平均分。
// 只对传入的维度计权:如果团队还没有成本数据,总分按其余维度的权重归一化,
// 而不是把缺失维度当成 0 分,否则“没测”会被误读成“做得很差”。
func Score(ds []Dimension) (int, error) {
	if len(ds) == 0 {
		return 0, fmt.Errorf("scalebase: no dimensions to score")
	}
	seen := make(map[string]struct{}, len(ds))
	totalWeight, acc := 0, 0
	for i, d := range ds {
		w, ok := weights[d.Name]
		if !ok {
			return 0, fmt.Errorf("scalebase: dimension %q at index %d has no weight", d.Name, i)
		}
		if _, dup := seen[d.Name]; dup {
			return 0, fmt.Errorf("scalebase: dimension %q is duplicated", d.Name)
		}
		seen[d.Name] = struct{}{}
		if d.Score < 0 || d.Score > 100 {
			return 0, fmt.Errorf("scalebase: dimension %q score %d is out of 0..100", d.Name, d.Score)
		}
		totalWeight += w
		acc += w * d.Score
	}
	return int(math.Round(float64(acc) / float64(totalWeight))), nil
}

// Level 把分数映射成一句人话。
// 阈值是产品决策,不是自然常数:60 以下意味着“先止血”,
// 60 到 79 意味着“能跑但欠债”,80 以上才允许谈“健康”。
func Level(score int) string {
	switch {
	case score < 60:
		return "危险"
	case score < 80:
		return "亚健康"
	default:
		return "健康"
	}
}

Score 对未知维度直接报错,而不是忽略。忽略未知维度看起来更“宽容”,但会让拼写错误悄悄生效:availabilty 少一个 i,这个维度就被跳过,总分仍然能算出来,报告里却少了一项。对于自评工具,静默容错比失败更危险。

重复维度也报错。如果同一个维度出现两次,权重会被重复累计,分数会向这个维度倾斜。调用方应该先在数据入口去重,而不是让评分函数猜测哪一条才是真的。

math.Round 而不是直接截断,是为了避免“79.6 分被显示成 79、落进亚健康”的边界争议。四舍五入也有争议,但至少规则简单、可解释。真正重要的不是用哪种舍入方式,而是把方式写进文档并保持不变。

Level 不校验分数范围,因为它的职责只是映射。负数会被归入“危险”,大于 100 会被归入“健康”;这些异常输入应该在 Score 或数据入口被挡住。让每个函数只做一件事,测试才能写得清楚。

测试:权重、归一化与阈值边界

评分函数最容易出错的地方不是公式,而是边界:缺失维度、重复维度、越界分数、阈值两侧。下面的测试把这些行为固定下来。

// file: internal/scalebase/health_test.go
// tier: L1
// verify: go test ./internal/scalebase/

package scalebase

import "testing"

func TestScoreWeightedAverage(t *testing.T) {
	ds := []Dimension{
		{Name: "availability", Score: 90},
		{Name: "performance", Score: 80},
		{Name: "maintainability", Score: 70},
		{Name: "security", Score: 60},
		{Name: "cost", Score: 50},
	}
	got, err := Score(ds)
	if err != nil {
		t.Fatalf("Score: %v", err)
	}
	if got != 75 {
		t.Fatalf("Score = %d, want 75", got)
	}
}

func TestScoreNormalizesPartialInput(t *testing.T) {
	got, err := Score([]Dimension{
		{Name: "availability", Score: 100},
		{Name: "cost", Score: 0},
	})
	if err != nil {
		t.Fatalf("Score: %v", err)
	}
	if got != 75 {
		t.Fatalf("partial Score = %d, want 75", got)
	}
	if got, err := Score([]Dimension{{Name: "security", Score: 88}}); err != nil || got != 88 {
		t.Fatalf("single dimension = %d, %v, want 88, nil", got, err)
	}
}

func TestScoreRejectsBadInput(t *testing.T) {
	cases := [][]Dimension{
		nil,
		{{Name: "velocity", Score: 80}},
		{{Name: "availability", Score: -1}},
		{{Name: "availability", Score: 101}},
		{{Name: "availability", Score: 80}, {Name: "availability", Score: 90}},
	}
	for i, ds := range cases {
		if _, err := Score(ds); err == nil {
			t.Fatalf("case %d: want error, got nil", i)
		}
	}
}

func TestLevelBoundaries(t *testing.T) {
	cases := map[int]string{
		-1:  "危险",
		0:   "危险",
		59:  "危险",
		60:  "亚健康",
		79:  "亚健康",
		80:  "健康",
		100: "健康",
	}
	for score, want := range cases {
		if got := Level(score); got != want {
			t.Fatalf("Level(%d) = %q, want %q", score, got, want)
		}
	}
}

TestScoreWeightedAverage 用五个满分附近的分数算出一个整数,方便手算核对:(90×30 + 80×25 + 70×20 + 60×15 + 50×10) / 100 = 75。如果实现里把权重顺序写错,或者把权重当成百分比之外的数,这个用例会立刻失败。

TestScoreNormalizesPartialInput 锁住两个行为:只报两个维度时按这两个维度的权重归一化;只报一个维度时直接返回该维度分数。第二个断言看起来多余,但它能抓住“总权重被硬编码成 100”的实现错误。

TestScoreRejectsBadInput 把五类坏输入放在一起:空切片、未知维度、负分、超过 100、重复维度。表驱动写法让新增校验时只需要加一行,而不是复制一段测试。错误信息的具体文字没有断言,因为文字会变;测试只关心“是否拒绝”。

TestLevelBoundaries 覆盖 59、60、79、80 四个阈值两侧,外加负数和 100。边界测试的价值在于防止“把 < 写成 <=”这类低级错误,而这种错误在代码评审里很难靠肉眼发现。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/scalebase/ -run 'TestScore|TestLevel' -count=1 -v

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

=== RUN   TestScoreWeightedAverage
--- PASS: TestScoreWeightedAverage (0.00s)
=== RUN   TestScoreNormalizesPartialInput
--- PASS: TestScoreNormalizesPartialInput (0.00s)
=== RUN   TestScoreRejectsBadInput
--- PASS: TestScoreRejectsBadInput (0.00s)
=== RUN   TestLevelBoundaries
--- PASS: TestLevelBoundaries (0.00s)
PASS
ok  	webapp/internal/scalebase	0.260s

四个用例全部通过,退出码为 0。-run 只筛选了评分与等级相关的用例,同包的路线图和发现汇总测试没有执行;需要全量确认时去掉 -run 再跑一次。

小结

架构健康度自评的价值,不在于算出一个精确的分数,而在于强迫团队用同一套维度描述同一类问题。权重让取舍显式化,归一化让缺失维度不被误判成低分,阈值把分数转成行动指令。三者缺一不可,但也都不能替代问题清单。

分数可以排序,但排序的对象应该是“同一团队、同一口径下的不同时间”,而不是“不同团队、不同覆盖、不同权重版本下的同一时刻”。一旦把分数做成排行榜,填写者就会开始管理分数,而不是管理架构;这时候分数越高,报告离事实可能越远。

下一节是全书收口:当路线图和自评都做完,剩下的问题是产品、架构与人如何一起演进。

阅读导航:上一节:13.1 从 MVP 到规模化的路线图 · 下一节:13.3 结语:产品、架构与人的共同演进 。

常见错误与边界

  • 把缺失维度当成 0 分。这样会把“没测”误判成“做得差”,也会让团队为了分数去补数据,而不是补能力。
  • 把归一化当成免费午餐。只报强项会得到更高分,必须同时展示维度覆盖率,否则分数会奖励少报。
  • 跨团队比较不同口径的分数。维度集合、权重版本、阈值定义任一不同,分数就不可比。
  • 让阻断项参与加权平均。一个高危安全问题不应该被十个低危项平均掉,必须单独列出。
  • 频繁调整权重却不留版本。口径变更后,旧分数不能直接拿来解释新结论。
  • 用分数替代证据。分数是摘要,SLO 曲线、复盘记录和演练报告才是事实。
  • 只测正常路径。空输入、未知维度、重复维度、越界分数和阈值边界都应该有明确行为。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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