《互联网产品的服务端架构·规模化与架构演进》8.2 内部平台的能力边界

平台组只有 5 个人,却收到从建 Kafka topic 到调 MySQL 参数的各种请求。本节把“平台提供什么、不提供什么”写成带 Owner 与 SLA 的能力清单,给出准入判断的实现与测试,并复盘一次缓存集群没有责任人、故障三小时才找到归属的事故。

一个“顺便帮我们看一下”的请求

「回声」的平台组成立于 2025 年底,5 个人,目标是“让业务团队不用关心基础设施”。最初只做两件事:统一的发布流水线和日志采集。因为确实省事,口碑很快传开。

九个月后,服务数量从 12 个涨到 40 个(案例假设值),平台组的即时通讯窗口变成了请求广场:

  • “帮我们建一个 Kafka topic,顺便定一下分区数。”
  • “Redis 集群最近偶发超时,你们看一下是不是平台的问题。”
  • “CDN 缓存命中率掉了,能不能加一条规则?”
  • “MySQL 这个查询慢,DBA 说让我们找平台。”
  • “能不能给我们的服务配一个值班同学?”

每一条请求单独看都合理,合在一起就暴露了一个结构性问题:平台没有说清楚自己提供什么。没有清单,业务方只能按“谁看起来像负责基础设施”来猜;平台组则被拖进一个又一个本不属于自己的领域,发布流水线的迭代反而停摆了两个月。

这一节讨论能力边界。它不是组织架构图,而是一份可以被代码执行、被故障复盘引用的契约:每项能力有唯一 Owner,有可度量的 SLA,也有明确写出的“不负责”。

平台卖的是被托管的契约,不是所有基础设施

一个常见的误解是:既然叫“平台”,就应该覆盖从机器到数据库的所有层。现实恰好相反。平台的价值来自收敛:把少数几件高频、标准化程度高的事情做到可重复、可审计,然后把剩下的交还给领域团队。

判断一项能力该不该进平台,可以问四个问题:

  • 它是否被多个团队以相似方式使用?只有一两个团队用的东西,做成平台能力通常是过度建设。
  • 它是否有稳定的接口和可度量的结果?无法度量的东西,无法写 SLA。
  • 平台是否能承担它的故障责任?如果平台没有权限、没有值班、没有回滚手段,就不该把它列进清单。
  • 领域团队是否仍然需要理解它?如果业务方必须懂分区键和副本因子,平台只是换了个工单入口。

把「回声」的实际能力放进一张表,边界会清楚很多:

能力唯一 OwnerSLA平台负责平台不负责
发布流水线平台组0.995构建、制品存储、晋级门禁、审计记录业务代码质量、发布窗口选择
日志采集可观测组0.999采集代理、传输、索引与查询入口业务日志内容与脱敏规则
服务网格平台组0.995sidecar 注入、mTLS、基础重试与超时业务级熔断阈值、接口语义
缓存集群存储组0.99集群供给、扩缩容、故障转移键设计、过期策略、热点治理
消息队列消息组0.995broker 运维、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。

排查过程被拉长了:

  1. 19:05,告警触发,值班在平台群里发问,没有人认领。
  2. 19:22,平台组确认不是网络问题,建议找 DBA。
  3. 19:40,DBA 确认集群本身健康,但指出最近有一次扩容,可能是槽位迁移导致命中率下降。
  4. 20:10,业务团队被拉进来,才发现当天下午有人批量导入商品,键的命名规则和线上不一致,产生了大量新键。
  5. 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、无穷和空白字符串。这些值在表单和配置解析中很常见,必须在入口归一化并拒绝。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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