凌晨两点,告警发给了已经转岗的人
《裂隙战场》的支付服务在半年前从商业化组交给平台一组。交接会上双方签了文档,Wiki 也改了 owner 字段。活动夜,充值回调开始积压,值班表上平台一组没有人认领这条告警,因为告警规则仍指向原负责人的个人邮箱,而那个人两个月前已经转岗。
这不是文档问题,是所有权没有落到可执行的位置。所有权至少包含四件事:谁可以改、谁必须被通知、谁承担故障、谁有权说“不做”。只改 Wiki 上的名字,四件事一件都没有发生。
这一节讨论三个决定:一个服务归哪个团队、共享服务为什么不能由产品团队兼管、团队边界变化时如何交接。它们都围绕同一个判据:所有权是否能在事故当晚被验证。
一个服务一个 owner,数据跟着服务走
最容易被接受、也最容易被破坏的规则是“一个服务只有一个 owner 团队”。它的价值不在归属感,而在排除三种常见的推诿:
| 写法 | 表面含义 | 事故时的实际含义 |
|---|---|---|
| 两个团队共同 owner | 一起负责 | 谁也不第一时间到场 |
| owner 是某个人 | 有人负责 | 他一休假就断档 |
| owner 是“中台” | 组织负责 | 没有具体值班组可呼叫 |
共同 owner 只在一种情况下成立:两个团队分别拥有服务的不同部署实例,且实例之间没有共享状态。否则就应该选一个主 owner,另一个团队以消费者身份出现。消费者可以提需求、可以否决接口变更,但不承担该服务的首响。
数据所有权默认随服务走。理由很实际:数据结构、迁移脚本、备份恢复和服务的发布节奏绑在一起,拆开就会产生“服务回滚了、数据没回滚”的事故。确实需要拆开时,不能靠口头约定,要留下一条可检索的例外记录,写清原因、数据边界、谁批准、何时复核。代码里的 DataException 字段就是这条记录的最小载体;为空即视为意外拆分。
共享服务必须由平台团队 owner
“共享”不是指调用方多,而是指它同时满足三个条件:被两个以上产品消费、有独立的发布节奏、故障会影响多个产品。满足这三条的服务如果留在某个产品团队,会出现结构性的优先级冲突:产品团队为自己的活动排期,其他产品只能排队;出了故障,owner 先保自己的业务。
平台团队 owner 解决了优先级冲突,但引入另一个风险:平台离业务远,容易把共享服务做成“只保证不挂,不保证好用”。所以平台 owner 必须附带两个约束:接口有版本和废弃策略,容量与 SLO 对所有消费者公开。没有这两条,平台 owner 只是把排队从产品团队换到了平台团队。
反过来,一个只有单一真实消费者的“共享库”不应该进平台。共享是结果,不是目标;为了组织好看而把服务抽到平台,会增加一次跨团队沟通,却不增加复用。
| 所有权模型 | 适用条件 | 主要代价 |
|---|---|---|
| 产品团队 owner | 单一产品消费,发布节奏跟随玩法 | 多产品复用时优先级冲突 |
| 平台团队 owner | 多产品消费,接口稳定,故障跨产品 | 平台离业务远,需求响应慢 |
| 联合 owner | 仅限独立实例、无共享状态 | 责任边界模糊,事故首响缺失 |
| 外包 owner | 非核心、可替换、合同含 SLO | 变更慢,知识回流难 |
边界判据:五个信号,不做总分
本节出现的排班人数、告警比例、评分档位与团队规模均为示例值,落地时按历史告警量和实际人力校准。
团队边界不应该按组织图切,也不应该按代码目录切。可操作的判据是五个信号,每个按 1 到 5 打分,但不要把分数加总成一个“边界指数”。加总会掩盖分歧:有人把数据耦合打 5 分,有人打 2 分,平均成 3.5 之后,真正的争论消失了。
| 信号 | 要回答的问题 | 倾向拆开的信号 | 倾向合并的信号 |
|---|---|---|---|
| 变更频率 | 两边是否总在同一个迭代里改 | 一边每周改,一边每季度改 | 改动总是一起发生 |
| 发布节奏 | 能否独立发布而不互相等待 | 一边每日发,一边双周发 | 必须同批发布才安全 |
| 故障域 | 一边挂会不会拖垮另一边 | 故障影响面明显不同 | 共享同一进程或同一数据库 |
| 数据耦合 | 一次变更要不要跨库事务 | 各自有独立数据 | 频繁联表或强事务 |
| 值班负担 | 一边的告警会不会叫醒另一边 | 告警责任可以清晰分开 | 告警总是同时出现 |
判定规则可以写得很朴素:数据耦合高、变更频率低,优先合并;变更频率高且发布或值班压力大,优先拆开;故障域宽而数据耦合低,优先隔离;其余保持现状。规则少的好处是团队能记住,也能在评审时指出“我们反对的是哪一条”,而不是争论一个抽象分数。
以一个具体推演为例。《裂隙战场》的房间服和匹配服变更频率都高,但房间服有强 tick 约束、故障影响正在进行的对局,匹配服可以快速重启。两者共享玩家状态的机会很少,故障域不同,应该拆成两个 owner。反过来,商城和支付回调经常一起改,共享订单表,发布必须同批,强行拆开只会制造跨团队事务,应该保持一个 owner。
边界不是一次决定。每次组织调整、每次大版本之后,用同一张表重打一次分;分数变化超过两档,才进入拆分或合并的评审。这样边界会跟着系统演化,而不是跟着某次汇报演化。
交接不是文档搬家
交接最常见的失败是“文档齐全,但没人能值班”。一份可用的交接至少包含八类材料,每一类都要有证据,而不是“已完成”三个字:
| 主题 | 最低证据 | 没有它会怎样 |
|---|---|---|
| runbook | 最近一次按文档演练的记录 | 事故时现场猜步骤 |
| dashboard | 能打开的看板链接与指标口径 | 看到了数字也不知道好坏 |
| alerts | 告警规则、阈值、接收组 | 告警发给转岗的人 |
| on-call | 值班表、升级路径、替补 | 首响缺失 |
| data-schema | 表结构、迁移脚本、备份恢复步骤 | 回滚时不知道数据怎么办 |
| debt | 已知债务清单与到期时间 | 新团队重复踩坑 |
| rollback | 最近一次回滚演练输出 | 发布失败只能向前修 |
| access | 仓库、发布、生产权限清单 | 有责任却没有权限 |
交接的退出条件也要可验证。一个可用的标准是:接收团队连续处理三次真实告警,其中至少一次涉及发布回滚,不需要原团队远程指挥;同时,接收团队能独立完成一次灰度发布和一次数据恢复演练。达到之前,原团队保留影子值班,但不做第一决策人。
历史债务的归属最容易被含糊处理。正确做法是把债务当作服务的一部分移交给接收团队,同时在清单里标明来源、影响和偿还期限;不接收债务等于不接收服务。反过来,移交方不能把“历史遗留”当成免责声明,债务的上下文、复现步骤和曾经失败的修复方案必须一并交清。
值班覆盖是一道算术题
一个关键服务需要 168 小时覆盖。primary 和 secondary 不能是同一个人,否则 secondary 只是通讯录上的名字。一个人同时担任三个团队的主值班,看起来“经验丰富”,实际是把单点故障集中到了同一个人身上;超过两个团队就应该被规则拦下。
| 覆盖方式 | 人力下限 | 适用场景 | 风险 |
|---|---|---|---|
| 单团队周轮换 | 4 人 | 单产品、告警量可控 | 假期与离职出现空洞 |
| 双团队互备 | 每队 3 人 | 两个服务故障域接近 | 不熟悉对方业务 |
| follow-the-sun | 每时区 2 人 | 全球同服、夜间事故多 | 交接成本高 |
| 单人英雄式 | 1 人 | 不应存在 | 不可持续,必然断档 |
值班负担要进入边界判断,而不是只进入排班表。一个服务如果每周产生大量无效告警,先修告警质量,再谈加人;否则加人只是把噪声分给更多人。告警的有效性可以用“需要人工动作的比例”衡量,示例目标可以设为 30% 以上,低于这个比例说明大部分告警应该被降级或删除。
可运行片段:所有权矩阵
internal/teamownership 把上面的规则落成可测试的函数。OwnershipMatrix 检查服务是否有唯一 owner、owner 类型是否匹配、数据是否跟随服务、关键服务的团队是否有足够值班人力;同包的 Coverage 检查 168 小时覆盖与主备关系,ReviewHandoff 检查八类交接材料,Recommend 用五信号给出拆分或合并建议。
// file: internal/teamownership/ownership.go
// tier: L1
// verify: go test ./internal/teamownership/
package teamownership
import (
"fmt"
"sort"
)
// TeamKind decides which class of work a team is allowed to own.
type TeamKind string
const (
KindProduct TeamKind = "product"
KindPlatform TeamKind = "platform"
KindSRE TeamKind = "sre"
)
// Team is the minimum org record the matrix needs. Headcount is used only for
// the on-call feasibility check, not for performance evaluation.
type Team struct {
Name string
Kind TeamKind
Headcount int
}
// Service is one deployable unit with an owner and a data owner. DataException
// records an explicit decision when the two differ; an empty string means no
// decision was made and the split is treated as an accident.
type Service struct {
Name string
Owner string
DataOwner string
DataException string
Shared bool
Platform bool
Criticality int // 0 = best effort, 1 = important, 2 = player-visible outage
}
// Finding is one rule violation. Subject is a service or team name, never a
// person, so the output can be pasted into a review without blame.
type Finding struct {
Subject string
Rule string
Detail string
}
func teamIndex(teams []Team) (map[string]Team, []Finding) {
idx := make(map[string]Team, len(teams))
var out []Finding
for _, t := range teams {
if t.Name == "" {
out = append(out, Finding{Subject: "<team>", Rule: "team-without-name",
Detail: "team name is empty"})
continue
}
if _, exists := idx[t.Name]; exists {
out = append(out, Finding{Subject: t.Name, Rule: "duplicate-team",
Detail: "team declared more than once"})
continue
}
idx[t.Name] = t
}
return idx, out
}
func sortFindings(fs []Finding) {
sort.SliceStable(fs, func(i, j int) bool {
if fs[i].Subject != fs[j].Subject {
return fs[i].Subject < fs[j].Subject
}
if fs[i].Rule != fs[j].Rule {
return fs[i].Rule < fs[j].Rule
}
return fs[i].Detail < fs[j].Detail
})
}
// OwnershipMatrix answers four questions for every service: does it have one
// owner, is that owner allowed to own it, does the data follow the service,
// and can the owner sustain on-call. It never fills in a default owner.
func OwnershipMatrix(teams []Team, services []Service) []Finding {
idx, out := teamIndex(teams)
seen := make(map[string]bool, len(services))
for _, s := range services {
subject := s.Name
if subject == "" {
subject = "<service>"
}
if seen[s.Name] && s.Name != "" {
out = append(out, Finding{Subject: subject, Rule: "duplicate-service",
Detail: "service declared more than once"})
continue
}
seen[s.Name] = true
if s.Criticality < 0 || s.Criticality > 2 {
out = append(out, Finding{Subject: subject, Rule: "criticality-out-of-range",
Detail: fmt.Sprintf("criticality=%d, want 0..2", s.Criticality)})
}
owner, ok := idx[s.Owner]
switch {
case s.Owner == "":
out = append(out, Finding{Subject: subject, Rule: "missing-owner",
Detail: "service has no owning team"})
case !ok:
out = append(out, Finding{Subject: subject, Rule: "unknown-owner",
Detail: fmt.Sprintf("owner %q is not a declared team", s.Owner)})
default:
if (s.Shared || s.Platform) && owner.Kind != KindPlatform {
out = append(out, Finding{Subject: subject, Rule: "shared-service-needs-platform-owner",
Detail: fmt.Sprintf("shared/platform service owned by %s team", owner.Kind)})
}
if !s.Shared && !s.Platform && owner.Kind == KindSRE {
out = append(out, Finding{Subject: subject, Rule: "sre-owns-product-service",
Detail: "SRE team owns a game-facing service"})
}
if s.Criticality == 2 && owner.Headcount < 4 {
out = append(out, Finding{Subject: subject, Rule: "critical-team-too-small",
Detail: fmt.Sprintf("owner %q has %d people; 24x7 on-call needs at least 4",
owner.Name, owner.Headcount)})
}
}
switch {
case s.DataOwner == "":
out = append(out, Finding{Subject: subject, Rule: "data-owner-missing",
Detail: "no team owns the data"})
default:
if _, ok := idx[s.DataOwner]; !ok {
out = append(out, Finding{Subject: subject, Rule: "unknown-data-owner",
Detail: fmt.Sprintf("data owner %q is not a declared team", s.DataOwner)})
} else if s.DataOwner != s.Owner && s.DataException == "" {
out = append(out, Finding{Subject: subject, Rule: "data-ownership-split-without-decision",
Detail: fmt.Sprintf("service owner %q and data owner %q differ without a recorded exception",
s.Owner, s.DataOwner)})
}
}
}
sortFindings(out)
return out
}
本机验证命令与结果:
cd .local/checks/gsarch-lab
go test -count=1 ./internal/teamownership/
ok gsarchlab/internal/teamownership 0.287s
该包共有 5 个表驱动测试,覆盖矩阵规则、覆盖算术、交接报告、边界建议与输出确定性;gofmt -l 无输出。
三个失败路径
名义 owner。 owner 字段存在,但团队没有发布权限、不接告警、不参加容量评审。识别方法很简单:问“上一次这个服务的回滚是谁执行的”,答不上来就是名义 owner。修复不是改文档,而是把权限、告警和发布流程一起转过去。
平台背锅。 共享服务归平台,但产品仍保留配置修改权;出了事故,平台被问责却无法阻止变更。修复是把配置变更纳入平台契约:产品可以提配置需求,但不能绕过平台的审核与灰度。所有权和责任必须落在同一侧。
交接即断档。 文档齐全,原团队撤得太快,接收团队还没经历过高负载。修复是设置影子期和明确的退出条件,而不是用“已经交接”作为时间点。影子期的长度由事故复杂度决定,不由项目排期决定。
检查表
在所有权评审上,逐条回答“是”或“否”:
- 每个服务是否只有一个 owner 团队,且该团队有发布权限?
- 共享服务是否由平台团队 owner,并有版本与废弃策略?
- 数据 owner 是否等于服务 owner;不等时是否有可检索的例外记录?
- 关键服务的 owner 团队是否有至少 4 人,能排出 primary 与 secondary?
- 是否存在一个人主值班超过两个团队的情况?
- 交接的八类材料是否都有证据链接,而不是“已完成”?
- 接收团队是否独立处理过三次真实告警和一次回滚?
- 历史债务是否有清单、来源、影响和偿还期限?
- 边界五信号是否在最近一次大版本后重新打过分?
- 告警接收组是否与当前值班表一致,且不包含个人邮箱?
十项里出现两个“否”,就不应该在交接单上签字;出现四个以上,说明这次交接只是组织图变化,不是所有权变化。
小结
服务所有权要能被事故当晚验证:一个服务一个 owner,数据跟着服务走,共享服务归平台并附带契约,值班覆盖满足 168 小时和主备分离。边界用变更频率、发布节奏、故障域、数据耦合、值班负担五个信号判断,规则少而明确,不做总分。交接以证据为准,八类材料齐全只是起点,接收团队能独立处理告警和回滚才算完成。历史债务随服务走,名义 owner、平台背锅、交接断档是三个必须提前拦住的失败路径。
下一篇讨论三类团队如何分工:产品团队保留业务决策,平台团队以契约交付能力,SRE 以 SLO 和演练参与,而不是变成第二运维团队。
阅读导航:上一节:8.3 架构规则的自动化检查 · 下一节:9.2 平台团队、产品团队与 SRE 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。