《游戏服务端架构·平台化与架构治理》9.1 团队边界与服务所有权

《裂隙战场》的支付服务换了团队半年,告警仍发到已转岗同事的邮箱。本节用所有权矩阵回答服务归谁、数据归谁、共享服务为什么必须由平台团队 owner,给出变更频率、发布节奏、故障域、数据耦合、值班负担五条边界判据,以及交接清单、影子值班与历史债务归属的可执行做法。

凌晨两点,告警发给了已经转岗的人

《裂隙战场》的支付服务在半年前从商业化组交给平台一组。交接会上双方签了文档,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。修复不是改文档,而是把权限、告警和发布流程一起转过去。

平台背锅。 共享服务归平台,但产品仍保留配置修改权;出了事故,平台被问责却无法阻止变更。修复是把配置变更纳入平台契约:产品可以提配置需求,但不能绕过平台的审核与灰度。所有权和责任必须落在同一侧。

交接即断档。 文档齐全,原团队撤得太快,接收团队还没经历过高负载。修复是设置影子期和明确的退出条件,而不是用“已经交接”作为时间点。影子期的长度由事故复杂度决定,不由项目排期决定。

检查表

在所有权评审上,逐条回答“是”或“否”:

  1. 每个服务是否只有一个 owner 团队,且该团队有发布权限?
  2. 共享服务是否由平台团队 owner,并有版本与废弃策略?
  3. 数据 owner 是否等于服务 owner;不等时是否有可检索的例外记录?
  4. 关键服务的 owner 团队是否有至少 4 人,能排出 primary 与 secondary?
  5. 是否存在一个人主值班超过两个团队的情况?
  6. 交接的八类材料是否都有证据链接,而不是“已完成”?
  7. 接收团队是否独立处理过三次真实告警和一次回滚?
  8. 历史债务是否有清单、来源、影响和偿还期限?
  9. 边界五信号是否在最近一次大版本后重新打过分?
  10. 告警接收组是否与当前值班表一致,且不包含个人邮箱?

十项里出现两个“否”,就不应该在交接单上签字;出现四个以上,说明这次交接只是组织图变化,不是所有权变化。

小结

服务所有权要能被事故当晚验证:一个服务一个 owner,数据跟着服务走,共享服务归平台并附带契约,值班覆盖满足 168 小时和主备分离。边界用变更频率、发布节奏、故障域、数据耦合、值班负担五个信号判断,规则少而明确,不做总分。交接以证据为准,八类材料齐全只是起点,接收团队能独立处理告警和回滚才算完成。历史债务随服务走,名义 owner、平台背锅、交接断档是三个必须提前拦住的失败路径。

下一篇讨论三类团队如何分工:产品团队保留业务决策,平台团队以契约交付能力,SRE 以 SLO 和演练参与,而不是变成第二运维团队。

阅读导航:上一节:8.3 架构规则的自动化检查 · 下一节:9.2 平台团队、产品团队与 SRE 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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