一个“顺便帮我们看一下”的请求
「回声」的平台组成立于 2025 年底,5 个人,目标是“让业务团队不用关心基础设施”。最初只做两件事:统一的发布流水线和日志采集。因为确实省事,口碑很快传开。
九个月后,服务数量从 12 个涨到 40 个(案例假设值),平台组的即时通讯窗口变成了请求广场:
- “帮我们建一个 Kafka topic,顺便定一下分区数。”
- “Redis 集群最近偶发超时,你们看一下是不是平台的问题。”
- “CDN 缓存命中率掉了,能不能加一条规则?”
- “MySQL 这个查询慢,DBA 说让我们找平台。”
- “能不能给我们的服务配一个值班同学?”
每一条请求单独看都合理,合在一起就暴露了一个结构性问题:平台没有说清楚自己提供什么。没有清单,业务方只能按“谁看起来像负责基础设施”来猜;平台组则被拖进一个又一个本不属于自己的领域,发布流水线的迭代反而停摆了两个月。
这一节讨论能力边界。它不是组织架构图,而是一份可以被代码执行、被故障复盘引用的契约:每项能力有唯一 Owner,有可度量的 SLA,也有明确写出的“不负责”。
平台卖的是被托管的契约,不是所有基础设施
一个常见的误解是:既然叫“平台”,就应该覆盖从机器到数据库的所有层。现实恰好相反。平台的价值来自收敛:把少数几件高频、标准化程度高的事情做到可重复、可审计,然后把剩下的交还给领域团队。
判断一项能力该不该进平台,可以问四个问题:
- 它是否被多个团队以相似方式使用?只有一两个团队用的东西,做成平台能力通常是过度建设。
- 它是否有稳定的接口和可度量的结果?无法度量的东西,无法写 SLA。
- 平台是否能承担它的故障责任?如果平台没有权限、没有值班、没有回滚手段,就不该把它列进清单。
- 领域团队是否仍然需要理解它?如果业务方必须懂分区键和副本因子,平台只是换了个工单入口。
把「回声」的实际能力放进一张表,边界会清楚很多:
| 能力 | 唯一 Owner | SLA | 平台负责 | 平台不负责 |
|---|---|---|---|---|
| 发布流水线 | 平台组 | 0.995 | 构建、制品存储、晋级门禁、审计记录 | 业务代码质量、发布窗口选择 |
| 日志采集 | 可观测组 | 0.999 | 采集代理、传输、索引与查询入口 | 业务日志内容与脱敏规则 |
| 服务网格 | 平台组 | 0.995 | sidecar 注入、mTLS、基础重试与超时 | 业务级熔断阈值、接口语义 |
| 缓存集群 | 存储组 | 0.99 | 集群供给、扩缩容、故障转移 | 键设计、过期策略、热点治理 |
| 消息队列 | 消息组 | 0.995 | broker 运维、topic 配额、消费位点监控 | 消费逻辑、重复消费幂等 |
| 数据库 | DBA 组 | 0.999 | 实例供给、备份、慢查询采集 | 索引设计、SQL 改写、分库分表方案 |
这张表最重要的不是 SLA 数字,而是“唯一 Owner”和“平台不负责”两列。案例中的团队最初把缓存写成“平台 + DBA 共同负责”,结果故障时两边都在等对方;后来改成存储组唯一 Owner,平台只负责把申请转过去,定位时间从小时级降到分钟级。
SLA 也必须可度量。0.99 不是一句口号,它要配一个测量窗口(例如自然月)、一个指标口径(成功请求数除以总请求数)、一组排除项(计划内维护、客户侧网络故障)。没有这些,SLA 只是谈判时的形容词。
能力清单要能回答“谁、承诺什么、不做什么”
一份可执行的能力清单,至少要有三个字段:
Name:能力的稳定标识。它应该和工单系统、监控大盘、告警路由里的名字一致,避免同一个东西在三处有三个叫法。Owner:唯一负责的团队,不是个人。个人会休假、转岗,团队才是责任主体。空 Owner 意味着这项能力不该出现在清单里。SLA:承诺的可用性,取值在 0 到 1 之间。它不是“目标”,而是“达不到就要触发复盘和补偿”的承诺。
除了这三个字段,清单还应该记录版本和生效时间。能力会演进:缓存集群从单可用区变成三可用区,SLA 可能从 0.99 提到 0.995;数据库换成新的托管方案,责任边界也可能变化。没有版本,业务方引用的是哪一版契约就说不清。
清单之外的东西,平台要敢于说“不”。说“不”不是拒人千里,而是给出一条替代路径:这项能力由谁负责、应该走哪个入口、平台能提供什么辅助。案例中的平台组后来把请求分成三类:清单内直接受理;清单外但属于基础设施的,转给对应 Owner 并抄送;既不在清单内也不属于基础设施的,明确拒绝并说明原因。工单量没有下降,但无效流转少了一半。
一次没有 Owner 的缓存事故
2026 年 5 月的一个周三(案例假设值),「回声」的商品详情接口开始间歇性超时。监控显示 Redis 集群的命中率从 96% 掉到 61%,大量请求穿透到 MySQL,数据库连接池很快打满。
值班同学先找平台组。平台组的回答是:“缓存集群是 DBA 建的,我们只负责网络。”DBA 的回答是:“集群是平台申请的资源,运维应该找平台。”两边都没有错,因为能力清单里那一行写的是“缓存:平台/DBA”,没有唯一 Owner。
排查过程被拉长了:
- 19:05,告警触发,值班在平台群里发问,没有人认领。
- 19:22,平台组确认不是网络问题,建议找 DBA。
- 19:40,DBA 确认集群本身健康,但指出最近有一次扩容,可能是槽位迁移导致命中率下降。
- 20:10,业务团队被拉进来,才发现当天下午有人批量导入商品,键的命名规则和线上不一致,产生了大量新键。
- 21:50,命中率恢复,故障持续约 2 小时 45 分钟,期间商品详情 p99 从 120ms 涨到 3.8s,订单转化率下降约 6%。
事后复盘,直接原因是键设计问题,但真正的放大器是责任边界不清。清单里没有唯一 Owner,告警没有路由到具体团队,扩容和键规范也没有人负责解释。平台组随后做了三件事:
- 把缓存的 Owner 明确为存储组,平台只保留资源申请入口;
- 把“键命名与过期策略”写进“平台不负责”一列,并在申请时强制业务方填写;
- 给缓存能力补上 SLA 和测量口径,低于 0.99 时自动触发复盘。
这次事故也解释了为什么准入函数要拒绝“没有 Owner”的能力。一个没有责任人的条目,在系统里看起来像能力,在故障时却是一个黑洞。
把准入规则写成函数
下面的代码实现能力准入:申请方带着团队名和能力名来,平台在清单里查找,只有 Owner、SLA 都合法且达到最低承诺时才放行。
// file: internal/pipeline/platform.go
// tier: L1
// verify: go test ./internal/pipeline/
package pipeline
import (
"fmt"
"math"
"strings"
)
// MinSLA 是内部平台愿意托管一项能力的最低可用性承诺。
// 低于它说明“平台托管”只是换了个地方部署,并没有换来更稳的边界。
const MinSLA = 0.99
// Capability 描述平台对外提供的一项能力。
// Owner 是唯一负责的团队,SLA 是该能力承诺的可用性(0.99 表示 99%)。
// 没有 Owner 的条目不是平台能力,只是没人认领的中间件。
type Capability struct {
Name string
Owner string
SLA float64
}
// Request 是业务团队的申请:谁要用哪项能力。
type Request struct {
Team string
Capability string
}
// Admit 判断一次能力申请是否被平台接受。
// 接受时返回一句可直接写进审批记录的决定说明;拒绝时 error 说明原因。
//
// 拒绝条件刻意保持“可解释”:
// - 申请方或能力名为空,无法定位责任主体;
// - 能力不在清单里,平台不为清单外的东西背书;
// - 能力没有 Owner,出故障时找不到人;
// - SLA 缺失、越界或低于 MinSLA,平台无法承诺可用的托底。
func Admit(r Request, caps []Capability) (string, error) {
team := strings.TrimSpace(r.Team)
if team == "" {
return "", fmt.Errorf("pipeline: request has no team")
}
name := strings.TrimSpace(r.Capability)
if name == "" {
return "", fmt.Errorf("pipeline: request from %q has no capability", team)
}
for _, c := range caps {
if c.Name != name {
continue
}
if strings.TrimSpace(c.Owner) == "" {
return "", fmt.Errorf("pipeline: capability %q has no owner", name)
}
if math.IsNaN(c.SLA) || math.IsInf(c.SLA, 0) {
return "", fmt.Errorf("pipeline: capability %q has non-finite SLA", name)
}
if c.SLA <= 0 || c.SLA > 1 {
return "", fmt.Errorf("pipeline: capability %q has invalid SLA %v", name, c.SLA)
}
if c.SLA < MinSLA {
return "", fmt.Errorf("pipeline: capability %q SLA %.3f is below platform minimum %.3f",
name, c.SLA, MinSLA)
}
return fmt.Sprintf("admit team=%s capability=%s owner=%s sla=%.3f",
team, name, c.Owner, c.SLA), nil
}
return "", fmt.Errorf("pipeline: capability %q is not offered", name)
}
几个设计点。
MinSLA 被定义成包级常量,而不是每次调用传入。平台的最低承诺应该由平台治理规则统一决定,不能让每个申请方自己带一个阈值;否则 SLA 会变成谈判结果,而不是工程约束。0.99 是案例中的选择,真实团队应该根据自己的故障预算和值班能力来定。
Admit 返回两个值:一句可写入审批记录的决定说明,以及一个错误。拒绝时说明放在 error 里,调用方可以直接展示给申请方,不需要再拼一段人类可读的理由。把“为什么拒绝”做成返回值的一部分,比只返回布尔值更有利于减少反复沟通。
空 Team 和空 Capability 都在入口拒绝。Team 为空意味着无法确定谁在使用这项能力,后续的配额、账单和故障通知都找不到人;Capability 为空通常来自表单没填完或上游变量拼错,放行只会产生一条无意义的授权记录。
SLA 的校验分三层:非有限值(NaN、正负无穷)、越界(小于等于 0 或大于 1)、低于平台下限。分开处理是因为它们的修复方式不同:非有限值通常是解析或计算错误,越界通常是录入错误,低于下限则是治理决策,需要平台和申请方一起讨论,而不是简单改个数字。
函数没有检查能力名是否重复。清单里出现两个同名能力,说明配置管理已经出了问题,应该在加载清单时做唯一性校验并拒绝启动,而不是在每次准入时猜测用哪一条。这个取舍让函数保持简单,也把配置质量问题留在了更早的阶段。
测试:拒绝要给出原因
准入逻辑的测试重点不是“能不能通过”,而是“每一种拒绝是否可解释”。
// file: internal/pipeline/platform_test.go
// tier: L1
// verify: go test ./internal/pipeline/
package pipeline
import (
"math"
"strings"
"testing"
)
func TestAdmitAcceptsOwnedCapability(t *testing.T) {
caps := []Capability{
{Name: "log-pipeline", Owner: "observability", SLA: 0.995},
{Name: "feature-flag", Owner: "release", SLA: 0.99},
}
got, err := Admit(Request{Team: "payments", Capability: "log-pipeline"}, caps)
if err != nil {
t.Fatalf("admit: %v", err)
}
for _, want := range []string{"payments", "log-pipeline", "observability", "0.995"} {
if !strings.Contains(got, want) {
t.Fatalf("decision %q does not contain %q", got, want)
}
}
}
func TestAdmitRejectsUnknownCapability(t *testing.T) {
caps := []Capability{{Name: "log-pipeline", Owner: "observability", SLA: 0.995}}
if _, err := Admit(Request{Team: "payments", Capability: "mysql"}, caps); err == nil {
t.Fatal("unknown capability: want error")
}
}
func TestAdmitRejectsMissingOwner(t *testing.T) {
caps := []Capability{{Name: "cache", Owner: " ", SLA: 0.999}}
if _, err := Admit(Request{Team: "feed", Capability: "cache"}, caps); err == nil {
t.Fatal("missing owner: want error")
}
}
func TestAdmitRejectsLowOrInvalidSLA(t *testing.T) {
cases := []struct {
name string
sla float64
}{
{"below-min", 0.95},
{"zero", 0},
{"above-one", 1.2},
{"nan", math.NaN()},
{"inf", math.Inf(1)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caps := []Capability{{Name: "queue", Owner: "messaging", SLA: tc.sla}}
if _, err := Admit(Request{Team: "order", Capability: "queue"}, caps); err == nil {
t.Fatalf("SLA %v: want error", tc.sla)
}
})
}
}
func TestAdmitRejectsEmptyRequest(t *testing.T) {
caps := []Capability{{Name: "queue", Owner: "messaging", SLA: 0.99}}
if _, err := Admit(Request{Capability: "queue"}, caps); err == nil {
t.Fatal("empty team: want error")
}
if _, err := Admit(Request{Team: "order"}, caps); err == nil {
t.Fatal("empty capability: want error")
}
}
TestAdmitAcceptsOwnedCapability 不仅检查没有错误,还检查返回的决定说明里包含团队、能力、Owner 和 SLA。这段字符串会进入审批记录,字段缺失会让审计时无法还原当时的判断依据。
TestAdmitRejectsUnknownCapability 覆盖清单外能力。这里的预期是拒绝,而不是“自动创建一条”。平台不为清单外的东西背书,这是边界能够成立的前提。
TestAdmitRejectsMissingOwner 用只含空格的 Owner 测试。空格在 YAML 和表单里很常见,直接比较空字符串会漏掉;函数内部用 TrimSpace 归一化后再判断。
TestAdmitRejectsLowOrInvalidSLA 用子测试分别覆盖低于下限、0、大于 1、NaN 和正无穷。子测试的命名让失败输出直接指向具体类别,不需要再去读断言消息。
测试没有覆盖“清单本身重复”和“清单为空”。前者应该在配置加载层测试,后者在业务上等价于“平台还没有任何能力”,返回拒绝是合理行为。把测试边界划清楚,比在一个函数里塞进所有场景更容易维护。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/pipeline/ -run 'TestAdmit' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestAdmitAcceptsOwnedCapability
--- PASS: TestAdmitAcceptsOwnedCapability (0.00s)
=== RUN TestAdmitRejectsUnknownCapability
--- PASS: TestAdmitRejectsUnknownCapability (0.00s)
=== RUN TestAdmitRejectsMissingOwner
--- PASS: TestAdmitRejectsMissingOwner (0.00s)
=== RUN TestAdmitRejectsLowOrInvalidSLA
=== RUN TestAdmitRejectsLowOrInvalidSLA/below-min
=== RUN TestAdmitRejectsLowOrInvalidSLA/zero
=== RUN TestAdmitRejectsLowOrInvalidSLA/above-one
=== RUN TestAdmitRejectsLowOrInvalidSLA/nan
=== RUN TestAdmitRejectsLowOrInvalidSLA/inf
--- PASS: TestAdmitRejectsLowOrInvalidSLA (0.00s)
--- PASS: TestAdmitRejectsLowOrInvalidSLA/below-min (0.00s)
--- PASS: TestAdmitRejectsLowOrInvalidSLA/zero (0.00s)
--- PASS: TestAdmitRejectsLowOrInvalidSLA/above-one (0.00s)
--- PASS: TestAdmitRejectsLowOrInvalidSLA/nan (0.00s)
--- PASS: TestAdmitRejectsLowOrInvalidSLA/inf (0.00s)
=== RUN TestAdmitRejectsEmptyRequest
--- PASS: TestAdmitRejectsEmptyRequest (0.00s)
PASS
ok webapp/internal/pipeline 0.394s
全部用例通过,退出码为 0。-run 'TestAdmit' 也会匹配子测试,因为父测试名包含该前缀;子测试的通过行会缩进显示在父测试下面。
小结
内部平台的能力边界,本质上是责任边界。清单里每多一项能力,平台就多一份值班、告警和故障复盘的义务;没有 Owner 和 SLA 的条目,不是能力,只是把风险换了个地方存放。
写清楚“不负责”比写清楚“负责”更重要。业务方需要知道遇到问题该找谁,平台也需要知道什么可以拒绝、拒绝后指向哪里。把准入规则做成可测试的函数,是让这份契约在每次申请时都被执行,而不是停留在 wiki 里。
下一节讨论平台采纳与反悔成本:即使边界清楚,业务团队也未必愿意迁移;迁移之后想退出,代价可能比想象中更高。
阅读导航:上一节:8.1 环境、制品与发布流水线 · 下一节:8.3 平台采纳与反悔成本 。
常见错误与边界
- 把“平台”当成“所有基础设施”的代名词。能力越多,责任越分散;清单应该收敛,而不是扩张。
- 能力条目没有唯一 Owner。写成“平台/DBA”等于没有 Owner,故障时双方都会等对方先动。
- SLA 只写目标不写口径。没有测量窗口、指标定义和排除项的数字,无法在复盘时使用。
- 允许申请方自带 SLA 阈值。阈值应该由平台治理规则统一决定,否则会变成逐单谈判。
- 对清单外请求“先接了再说”。一旦接受,清单就失去边界意义,下一次拒绝会更难。
- 只返回布尔值的准入结果。拒绝原因是审计和沟通的一部分,应该作为返回值明确给出。
- 忽略 NaN、无穷和空白字符串。这些值在表单和配置解析中很常见,必须在入口归一化并拒绝。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。