成熟度不是打分游戏
很多团队做过架构成熟度评估:发一张问卷,每个维度打一到五分,算个平均分,写进季度汇报。第二年再发一次,分数涨了,问题还在。
问题不在维度选得不对,而在评估方式。自评问卷测的是“团队觉得自己怎么样”,不是“系统实际能承受什么”。一个团队可以把“有 SLO”打四分,但如果你问他错误预算耗尽后哪次发布被拦下,他答不出来,那这条 SLO 就没有进入运行。
这一节换一种做法:每个等级都必须有可观察的证据。等级不是荣誉,是描述;评估不是打分,是找差距。成熟度模型的价值在于让讨论聚焦到“证据够不够”,而不是“我们算几级”。
六个维度
维度不能太多。每多一个维度,评估成本上升,跨团队可比性下降。下面六个维度覆盖了从拓扑到成本的主要决策面,且每个都能找到产物级证据。
| 维度 | 要回答的问题 | 典型证据 |
|---|---|---|
| 拓扑与边界 | 服务怎么切,状态归谁,故障域多大 | 拓扑图、状态归属表、故障域演练记录 |
| 契约与数据 | 接口、错误码、幂等、迁移怎么约定 | 契约文档、契约测试、迁移回滚脚本 |
| 规模与容灾 | 容量怎么估,峰值怎么扛,故障怎么切 | 容量表、压测报告、切换演练记录 |
| 可观测与 SLO | 看见什么,承诺什么,预算怎么用 | 仪表盘、SLI 定义、错误预算门禁记录 |
| 平台与治理 | 复用什么,谁负责,规则怎么执行 | 平台契约、发布列车、缺口报告 |
| 成本与合规 | 钱花在哪,数据留多久,区域怎么选 | 成本分摊表、留存策略、合规清单 |
六个维度之间不是加法关系。拓扑与边界错了,容量表再精细也可能白算;契约与数据没有幂等,容灾切换会把重复请求放大成资损。评估时要允许“上游维度拖累下游维度”的解释,而不是每个维度独立打分后互不相干。
维度顺序也不是优先级。它只是评估时的固定顺序,保证不同团队的报告可以并排比较。真正的优先级由当前约束决定:买量期的产品可能先补规模与容灾,合规审查前的产品可能先补成本与合规。
五个等级与可观察证据
等级描述的是“这件事以什么方式发生”,不是“做得好不好”。同一维度在不同产品上目标等级可以不同,低等级不必然是缺陷。
| 等级 | 名称 | 可观察证据 | 常见误判 |
|---|---|---|---|
| 1 | 临时 | 靠个人经验处理,没有固定流程,事后补记录 | 把“救火成功”当成能力 |
| 2 | 重复 | 相同问题有相似处理方式,但依赖少数人 | 把“某个人会”当成组织能力 |
| 3 | 定义 | 流程写成文档并被遵守,新成员能按文档完成 | 把“文档存在”当成“流程执行” |
| 4 | 管理 | 有度量、有阈值、有门禁,偏离会被发现并纠正 | 把“有报表”当成“有门禁” |
| 5 | 优化 | 能基于度量主动调整目标与机制,并验证调整有效 | 把“工具很多”当成“持续优化” |
等级 3 和等级 4 的差别最容易被夸大。文档、模板、检查清单只能证明“定义过”;要证明“管理”,必须拿出一次偏离被拦下的记录,例如错误预算耗尽后发布被推迟、容量预测偏差触发扩容、契约破坏性变更被门禁拒绝。
等级 5 也不是“更高级的工具”。它要求组织能改变自己的规则:SLO 目标根据真实用户体验调整,容量模型根据成本结构重算,平台契约根据接入反馈废弃。没有“调整后被验证”的记录,等级 5 就只是等级 4 的另一个说法。
证据的三种来源
评估不能只看文档,也不能只靠访谈。三类证据各有用处,但不能互相替代。
产物抽查
产物是最硬的证据:契约文件、容量表、演练记录、账单、门禁日志。抽查时要看时间戳和变更痕迹,不看封面。一份两年前写好、之后再没改过的“容灾预案”,只能证明当时定义过,不能证明现在还能用。
抽查还要看“失败版本”。只看成功演练记录,无法判断系统是否真的能发现失败;要看一次失败的演练、一次被回滚的发布、一次预测偏差,以及之后改了什么。
访谈
访谈用来解释产物背后的判断:为什么这样切边界,为什么这个 SLO 定在 99.9%(示例值),为什么这条契约还没废弃。访谈不能单独支撑高等级,因为它无法验证执行。
访谈要问具体事件,不问抽象能力。“上一次容量预测偏差超过 30%(示例值)是什么时候,怎么发现的?”比“你们的容量管理成熟吗?”有用得多。
证据清单
评估前先列清单:每个维度、每个目标等级,需要哪几类证据,由谁提供。清单在评估前发给团队,避免现场临时找材料,也避免评估者凭印象打分。
| 目标等级 | 最低证据要求 | 可以由访谈单独支撑 |
|---|---|---|
| 1–2 | 任意一类证据 | 是 |
| 3 | 至少一份产物或演练记录 | 否 |
| 4 | 产物 + 一次偏离被纠正的记录 | 否 |
| 5 | 产物 + 一次规则调整及其验证 | 否 |
这张表是下面代码里的核心规则:等级 3 及以上,访谈不足以支撑。没有这条规则,评估一定会通胀。
评分:短板决定总体
成熟度不用平均分。平均分会让一个维度的高分掩盖另一个维度的缺失:拓扑与边界 5 分、可观测与 SLO 1 分,平均 3 分(示例值),看起来“中等”,实际上系统出了故障没人能定位。
更合理的总体判断是最低等级:整体成熟度受最弱维度约束。 这不是说所有维度都必须一样高,而是说在讨论“能不能承担更大规模”时,最弱的那一环决定上限。
差距报告也不该只写“当前 3 分,目标 4 分”。它要写清哪个维度、差在哪类证据、补齐证据的最小动作是什么。下面是一份示例报告(示例值,不是本机运行结果):
dimension observability-slo: level 2 repeatable, target 4 managed
dimension cost-compliance: level 3 defined, target 4 managed
第一条说明该维度还停留在“靠少数人处理”,连定义都算不上;第二条说明已有文档和流程,但缺少偏离被纠正的记录。两条的补齐动作完全不同:前者要先写 SLI 与告警责任,后者要先把错误预算接进发布门禁。
可运行片段
下面的 maturitymodel.go 把评估报告做成可校验结构。Validate 检查六个维度是否齐全、证据是否足以支撑所报等级;Overall 取最低等级;Gaps 生成差距报告。
// file: internal/maturitymodel/maturitymodel.go
// tier: L1
// verify: go test ./internal/maturitymodel/
package maturitymodel
import (
"errors"
"fmt"
)
// ErrReport marks an assessment that cannot be defended in a review.
var ErrReport = errors.New("maturitymodel: invalid report")
// Dimension is one axis of the model. The list is closed on purpose: an
// organization that invents a seventh dimension every quarter never finishes
// a comparison.
type Dimension string
const (
TopologyBoundary Dimension = "topology-boundary"
ContractData Dimension = "contract-data"
ScaleDR Dimension = "scale-dr"
ObservabilitySLO Dimension = "observability-slo"
PlatformGovernance Dimension = "platform-governance"
CostCompliance Dimension = "cost-compliance"
)
// Dimensions is the fixed assessment order, also used to keep reports stable.
var Dimensions = []Dimension{
TopologyBoundary,
ContractData,
ScaleDR,
ObservabilitySLO,
PlatformGovernance,
CostCompliance,
}
func validDimension(d Dimension) bool {
for _, known := range Dimensions {
if d == known {
return true
}
}
return false
}
// Level is deliberately small. A five-point scale is enough to separate
// "nothing exists" from "it runs itself"; more points invite false precision.
type Level int
const (
LevelAdHoc Level = 1
LevelRepeatable Level = 2
LevelDefined Level = 3
LevelManaged Level = 4
LevelOptimizing Level = 5
)
func (l Level) Name() string {
switch l {
case LevelAdHoc:
return "ad-hoc"
case LevelRepeatable:
return "repeatable"
case LevelDefined:
return "defined"
case LevelManaged:
return "managed"
case LevelOptimizing:
return "optimizing"
}
return "unknown"
}
func (l Level) valid() bool { return l >= LevelAdHoc && l <= LevelOptimizing }
// EvidenceKind is the source of a claim. Interviews are useful context but
// they are self-report; only artifacts and drills survive an audit.
type EvidenceKind string
const (
Interview EvidenceKind = "interview"
Artifact EvidenceKind = "artifact"
Drill EvidenceKind = "drill"
)
func (k EvidenceKind) valid() bool {
switch k {
case Interview, Artifact, Drill:
return true
}
return false
}
// Supports is the core anti-inflation rule: a dimension claimed at level 3 or
// above needs at least one artifact or drill output to back it. Without this
// rule every team scores itself "defined" and the model becomes a survey.
func (k EvidenceKind) Supports(level Level) bool {
if !k.valid() || !level.valid() {
return false
}
return level <= LevelRepeatable || k == Artifact || k == Drill
}
// Evidence must point at something a reviewer can open or rerun. Ref is a
// path, ticket id or dashboard URL; it is not free-form praise.
type Evidence struct {
Kind EvidenceKind
Ref string
}
// Assessment is one dimension's level plus its evidence. Note records the
// caveat the reviewer wants the next assessor to see.
type Assessment struct {
Dimension Dimension
Level Level
Evidence []Evidence
Note string
}
// Report is a complete assessment. Completeness is structural: all six
// dimensions must appear exactly once before the score means anything.
type Report struct {
Items []Assessment
}
// Validate rejects missing dimensions, duplicate dimensions, unknown levels,
// evidence-free claims and interview-only claims above level 2.
func (r Report) Validate() error {
seen := make(map[Dimension]bool, len(Dimensions))
for i, a := range r.Items {
switch {
case !validDimension(a.Dimension):
return fmt.Errorf("%w: item[%d] has unknown dimension %q", ErrReport, i, a.Dimension)
case seen[a.Dimension]:
return fmt.Errorf("%w: dimension %s assessed twice", ErrReport, a.Dimension)
case !a.Level.valid():
return fmt.Errorf("%w: dimension %s has level %d", ErrReport, a.Dimension, a.Level)
case len(a.Evidence) == 0:
return fmt.Errorf("%w: dimension %s has no evidence", ErrReport, a.Dimension)
}
seen[a.Dimension] = true
for j, e := range a.Evidence {
switch {
case !e.Kind.valid():
return fmt.Errorf("%w: dimension %s evidence[%d] has unknown kind %q", ErrReport, a.Dimension, j, e.Kind)
case e.Ref == "":
return fmt.Errorf("%w: dimension %s evidence[%d] has no reference", ErrReport, a.Dimension, j)
case !e.Kind.Supports(a.Level):
return fmt.Errorf("%w: dimension %s level %s needs artifact or drill, not %s",
ErrReport, a.Dimension, a.Level.Name(), e.Kind)
}
}
}
for _, d := range Dimensions {
if !seen[d] {
return fmt.Errorf("%w: dimension %s not assessed", ErrReport, d)
}
}
return nil
}
// Overall returns the lowest level, not the average. Maturity is bounded by
// the weakest dimension: a brilliant topology cannot compensate for no SLO.
// An empty report returns 0 so callers can distinguish "no data" from level 1.
func (r Report) Overall() Level {
if len(r.Items) == 0 {
return 0
}
low := LevelOptimizing
for _, a := range r.Items {
if a.Level < low {
low = a.Level
}
}
return low
}
// Gaps lists every dimension below target in the canonical order. Missing
// dimensions are reported as "not assessed" rather than silently skipped.
func (r Report) Gaps(target Level) []string {
byDim := make(map[Dimension]Assessment, len(r.Items))
for _, a := range r.Items {
byDim[a.Dimension] = a
}
var out []string
for _, d := range Dimensions {
a, ok := byDim[d]
if !ok {
out = append(out, fmt.Sprintf("dimension %s: not assessed", d))
continue
}
if a.Level < target {
out = append(out, fmt.Sprintf("dimension %s: level %d %s, target %d %s",
d, a.Level, a.Level.Name(), target, target.Name()))
}
}
return out
}
代码只做结构判断,不判断证据质量。一份产物路径可以合法,但内容可能过期;一次演练记录可以存在,但可能没有失败场景。后者仍然要由评估者抽查,代码的作用是防止“没证据也敢报高等级”。
读代码:证据强度是一条硬规则
EvidenceKind.Supports 是整份实现里最重要的方法。等级 1 到 2 允许访谈支撑,等级 3 及以上必须是产物或演练。这条规则直接对应前面那张最低证据表,不是通用最佳实践。
Validate 还强制六个维度各出现一次。缺一个维度就报错,而不是默默按已提供的维度算总分。缺口被隐藏,评估就会变成“只评我们擅长的部分”。
Overall 返回最低等级,空报告返回 0。0 和 1 必须区分:0 表示没有数据,1 表示有数据但处于临时状态。把两者混在一起,报告会误导读者。
Gaps 按固定维度顺序输出,缺失维度写成“not assessed”,而不是跳过。固定顺序让不同团队的报告可以逐行对比;显式缺失让读者知道“没评”和“评了但低”是两回事。
代码没有实现“证据质量评分”。这是故意的。质量判断需要看内容、问上下文、比对历史,无法从一个结构体里推出来。工具负责防通胀,人负责判断真实性。
一次评估怎么组织
评估的成本主要在协调,不在打分。下面是一套可以直接用的流程。
- 定目标等级:按产品和当前约束定,不按行业标杆定。买量期的产品与长线运营的产品,目标可以不同。
- 列证据清单:每个维度、每个目标等级,写清需要什么证据、由谁提供、截止时间。
- 提前抽查:评估前先看一遍材料,标出时间过久、范围不符、只有结论没有过程的证据。
- 访谈验证:针对疑点问具体事件,不问抽象能力;每个高等级结论至少追问一次“上一次失效是什么时候”。
- 现场对表:按六个维度逐项确认等级,记录分歧点和缺失证据,不急着算总分。
- 输出差距报告:每个差距写清补齐动作、负责人和复查日期。
- 三个月后复查:只看上次的补齐动作是否完成,不重新全面评估。
这套流程的重点是第 3 步和第 4 步。只做访谈,评估会变成听汇报;只做材料抽查,评估会变成查文档。两者配合,才能既看到产物,又理解产物背后的判断。
失败路径
自评通胀。 每个团队都倾向于高报,尤其是把“有计划”当成“已实施”。兜底是等级 3 以上必须有产物或演练证据,且评估者要抽查内容。
平均分掩盖短板。 总分好看,最弱维度没人管。兜底是总体取最低等级,差距报告按维度列出。
把等级当 KPI。 一旦和绩效挂钩,团队会生产证据而不是解决问题。兜底是评估结果只用于定差距和排优先级,不用于排名。
证据只覆盖成功路径。 只有成功演练、没有失败记录,说明系统可能没有真正的失败检测。兜底是每次评估至少抽查一次失败或回滚记录。
维度无限扩张。 每个团队都想加一个自己关心的维度,最后报告无法比较。兜底是维度固定为六个,新增需求先证明它不能被现有维度覆盖。
评估频率过高。 每季度全量评估会耗尽团队精力,也会让证据生产变成表演。兜底是全量评估半年一次,中间只复查差距项。
成熟度的边界
成熟度高不等于架构好。一个团队可以在六个维度都做到等级 4,但仍然选择了一个不适合自己品类的拓扑;流程只能保证决策被执行,不能保证决策正确。
成熟度也不能跨产品直接比较。三款产品的目标等级不同:《裂隙战场》的赛事峰值要求规模与容灾更高,《群岛纪元》的长线经济要求契约与数据更高,《泡泡工坊》的买量波动要求成本与合规更敏感。拿同一张分数表排名,会诱导团队追求与自己约束无关的等级。
最重要的边界是:成熟度模型描述能力,不决定目标。 目标来自产品阶段、合规要求和成本约束。先有约束,再谈等级;顺序反过来,评估就会变成一场自我感觉良好的仪式。
小结
成熟度评估要回答的不是“我们几级”,而是“每个维度有什么证据、离目标差什么”。六个维度给出讨论的固定坐标,五个等级给出描述执行方式的共同语言,证据规则防止自评通胀,短板规则防止平均分掩盖风险。
代码能校验结构和证据类型,不能校验证据真假。评估者仍然要抽查产物、追问事件、记录分歧。下一节离开评估,回到全书主张:架构是取舍,不是清单。
阅读导航:上一节:13.1 四卷回顾:从品类到平台 · 下一节:13.3 结语:架构是取舍,不是清单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。