《游戏服务端架构·平台化与架构治理》1.1 平台团队与产品团队的契约

账号、支付、匹配、排行榜、社交、公告六类平台能力,如果没有写成契约,就会退化成谁都在提需求、谁都不负责的需求池。本文给出 SLA、配额、计费口径、变更窗口、故障联络人与退出条件六要素,并用 Go 实现能力清单校验、错误预算和跨零点变更冻结判断。

平台团队不是需求池

《裂隙战场》要接排行榜,《群岛纪元》要改支付,《泡泡工坊》要加公告。三个产品各自拉一个群,各自找一位平台同学,各自约定一个“下周给你”。到了下周,三个需求都变成了口头承诺,没有人能说清哪条接口已经稳定、哪条还在试验。以下场景与数字均为示例值。

平台的产品是六类能力

北辰把平台能力收敛成六类。每一类都必须回答三个问题:谁维护、谁调用、平台不负责什么。

能力维护方主要调用方平台不负责
账号身份平台组三款产品渠道账号绑定策略、实名策略的产品侧文案
支付交易平台组三款产品商品定价、活动折扣、退款审核规则
匹配对战平台组《裂隙战场》《泡泡工坊》分段规则、机器人投放策略
排行榜数据平台组三款产品榜单展示样式、奖励发放
社交社交平台组《群岛纪元》《泡泡工坊》好友推荐权重、公会玩法
公告运营平台组三款产品公告内容审核、投放排期

契约六要素

要素必须写清缺失后的典型后果
SLA可用性目标、延迟分位、统计窗口故障时争论“这算不算达标”
配额每秒请求、突发上限、每日操作量一个产品打满共享池,其他产品陪葬
计费口径计费单位、单价、免费额度季度结算时才发现账单对不上
变更通知窗口提前多久通知、哪些时段禁止变更活动高峰期平台发版,产品被动接锅
故障联络人一线、升级人、沟通频道告警响了,没人知道该叫醒谁
退出条件提前通知期、数据导出期、迁移方案想换平台时发现数据拿不走

SLA 必须带窗口和分位

“可用性 99.9%”单独出现时没有意义。是按月算还是按季度算?延迟看平均还是 p99?统计窗口不同,同一套系统可以同时“达标”和“不达标”。

30 天 × (1 - 99.9%) = 43 分 12 秒

上面这组数字是示例值:它说明 99.9% 的月度错误预算只有约 43 分钟。如果团队把计划内维护也算进去,实际可用的故障时间会更少。契约里要明确哪些时段不计入,否则每次故障都会先吵口径。

可用性目标也不应该写成 100%:没有误差预算,既无法度量,也无法取舍。代码里的 SLA.Validate 直接拒绝 >= 1 的目标。

配额是上限,不是建议

配额要写三个数:稳态每秒请求、突发上限、每日操作量。突发上限低于稳态速率时,这个配额自相矛盾,调用方无论如何都会超限。

以下为示例值:账号能力给《裂隙战场》每秒 2000 次、突发 4000 次、每日 500 万次;给《泡泡工坊》每秒 800 次、突发 1600 次、每日 200 万次。配额不是“尽量别超”,而是超过就拒绝或降级,并且调用方能提前看到余量。

计费口径要在接入前定

“按调用量计费但没有单位”是最常见的坑:月底账单出来,平台按网关请求算,产品按业务成功数算,两边都觉得自己有理。免费能力也要写清单价为零,避免后来被追认为“临时免费”。

变更窗口要能跨零点

变更窗口包含两部分:提前通知期和每周冻结时段。通知期从公告发出开始算,不从代码评审通过开始算。冻结时段按部署方本地时间定义,必须支持跨零点,例如周二 23:00 到周三 02:00。

以下为示例值:重大变更提前 72 小时通知;每周二 19:00–23:00 和周二 23:00–周三 02:00 冻结;春节和暑期大版本期间额外冻结。冻结不是“建议避开”,而是部署流水线直接拒绝。

故障联络人要写具体的人

联系人字段至少有三个:一线值班、升级人、沟通频道。只写一个团队邮箱,等于没有联系人。值班表可以轮换,但“谁在什么时候负责升级”必须可查询。

退出条件决定平台会不会变成锁定

退出条件包含提前通知期、数据导出窗口和迁移方案。没有退出条件的平台,短期看留住了调用方,长期看失去了信任:产品不敢深度接入,因为不知道将来怎么走。

以下为示例值:退出提前 90 天通知;停止服务后数据保留 30 天可导出;迁移方案必须写明目标格式、字段映射和回滚点。退出条件不是鼓励离开,而是让接入决策可逆。

四种反模式

反模式表现后果纠正动作
平台变需求池每个产品直接找平台同学排期优先级由声音决定统一入口、公开路线图、契约评审
产品各自造轮子三个团队各写一套排行榜数据口径分裂、重复运维平台提供 Golden Path,例外要登记
口头承诺无观测“放心吧,很稳”故障时无法判定责任SLA 写进契约并接监控
平台无退出机制数据格式私有、导出要排期产品被锁定,平台失去监督退出条件与导出接口前置

契约评审检查表

  • 能力名称、维护方、调用方列表已登记。
  • SLA 的可用性、延迟分位、统计窗口三项齐全。
  • 配额包含稳态速率、突发上限、每日操作量,且突发不低于稳态。
  • 计费模式、单位、单价、免费额度已确认。
  • 变更提前通知期不少于组织下限。
  • 冻结时段覆盖产品峰值,且支持跨零点。
  • 一线值班、升级人、沟通频道均已填写。
  • 退出通知期、数据导出期、迁移方案均已评审。
  • 能力清单中不存在同名重复登记。
  • 契约变更本身也走通知窗口。

可运行片段

下面的包把六要素变成可校验的结构。Validate 拒绝缺项,ErrorBudget 把可用性目标换算成允许的停机时间,ChangePolicy.Allow 同时检查通知期和冻结窗口。能力清单的去重与排序在配套的 catalog.go 中。

// file: internal/platformcontract/contract.go
// tier: L1
// verify: go test ./internal/platformcontract/
package platformcontract

import (
	"errors"
	"fmt"
	"time"
)

var (
	ErrName      = errors.New("platformcontract: capability name is empty")
	ErrOwner     = errors.New("platformcontract: owner is empty")
	ErrConsumers = errors.New("platformcontract: consumers are empty")
	ErrContact   = errors.New("platformcontract: primary and escalation contacts are required")
	ErrSLA       = errors.New("platformcontract: invalid sla")
	ErrQuota     = errors.New("platformcontract: invalid quota")
	ErrBilling   = errors.New("platformcontract: invalid billing mode")
	ErrNotice    = errors.New("platformcontract: notice is shorter than policy")
	ErrExit      = errors.New("platformcontract: incomplete exit plan")
	ErrFreeze    = errors.New("platformcontract: change falls in a freeze window")
	ErrDuplicate = errors.New("platformcontract: duplicate capability")
)

// SLA is a promise with a measurement window. Availability must stay below 1:
// a target of exactly 1 leaves no error budget and cannot be measured.
type SLA struct {
	Availability float64
	LatencyP99   time.Duration
	Window       time.Duration
}

func (s SLA) Validate() error {
	if s.Availability <= 0 || s.Availability >= 1 {
		return fmt.Errorf("%w: availability %v must be in (0,1)", ErrSLA, s.Availability)
	}
	if s.LatencyP99 <= 0 || s.Window <= 0 {
		return fmt.Errorf("%w: latency and window must be positive", ErrSLA)
	}
	return nil
}

// ErrorBudget turns the target into allowed downtime inside the window.
func (s SLA) ErrorBudget() time.Duration {
	if s.Validate() != nil {
		return 0
	}
	return time.Duration(float64(s.Window) * (1 - s.Availability))
}

// Quota is a per-consumer ceiling. A burst below the steady rate would make
// the quota impossible to honor, so it is rejected instead of auto-raised.
type Quota struct {
	RequestsPerSecond int
	Burst             int
	DailyOps          int
}

func (q Quota) Validate() error {
	if q.RequestsPerSecond <= 0 || q.DailyOps <= 0 {
		return fmt.Errorf("%w: rate and daily operations must be positive", ErrQuota)
	}
	if q.Burst < q.RequestsPerSecond {
		return fmt.Errorf("%w: burst %d is below rate %d", ErrQuota, q.Burst, q.RequestsPerSecond)
	}
	return nil
}

const (
	BillingFree         = "free"
	BillingMetered      = "metered"
	BillingSeat         = "seat"
	BillingRevenueShare = "revenue_share"
)

// Billing records the unit the platform will invoice. A metered capability
// without a unit is not a price, it is a future argument.
type Billing struct {
	Mode      string
	Unit      string
	UnitPrice float64
}

func (b Billing) Validate() error {
	switch b.Mode {
	case BillingFree:
		if b.UnitPrice != 0 {
			return fmt.Errorf("%w: free capability must have zero price", ErrBilling)
		}
	case BillingMetered, BillingSeat, BillingRevenueShare:
		if b.Unit == "" || b.UnitPrice < 0 {
			return fmt.Errorf("%w: %s needs a unit and a non-negative price", ErrBilling, b.Mode)
		}
	default:
		return fmt.Errorf("%w: %q", ErrBilling, b.Mode)
	}
	return nil
}

// Freeze is a recurring weekly window in the deployer's local time. When
// EndMinute is not greater than StartMinute it crosses midnight and starts on
// Weekday.
type Freeze struct {
	Weekday     time.Weekday
	StartMinute int
	EndMinute   int
}

func (f Freeze) validate() error {
	if f.StartMinute < 0 || f.StartMinute >= 24*60 || f.EndMinute < 0 || f.EndMinute > 24*60 {
		return fmt.Errorf("%w: minutes out of range", ErrFreeze)
	}
	if f.StartMinute == f.EndMinute {
		return fmt.Errorf("%w: empty window on %s", ErrFreeze, f.Weekday)
	}
	return nil
}

func (f Freeze) contains(at time.Time) bool {
	minute := at.Hour()*60 + at.Minute()
	if f.StartMinute < f.EndMinute {
		return at.Weekday() == f.Weekday && minute >= f.StartMinute && minute < f.EndMinute
	}
	if at.Weekday() == f.Weekday && minute >= f.StartMinute {
		return true
	}
	next := time.Weekday((int(f.Weekday) + 1) % 7)
	return at.Weekday() == next && minute < f.EndMinute
}

// ChangePolicy decides when a change may land. Notice is counted from the
// announcement, not from the code review.
type ChangePolicy struct {
	Notice  time.Duration
	Freezes []Freeze
}

func (p ChangePolicy) Validate() error {
	if p.Notice <= 0 {
		return fmt.Errorf("%w: change notice must be positive", ErrNotice)
	}
	for _, f := range p.Freezes {
		if err := f.validate(); err != nil {
			return err
		}
	}
	return nil
}

// Allow answers two questions separately: was the change announced early
// enough, and does the deploy time sit outside every freeze window?
func (p ChangePolicy) Allow(announcedAt, deployAt time.Time) error {
	if err := p.Validate(); err != nil {
		return err
	}
	if deployAt.Before(announcedAt) || deployAt.Sub(announcedAt) < p.Notice {
		return fmt.Errorf("%w: need %s, got %s", ErrNotice, p.Notice, deployAt.Sub(announcedAt))
	}
	for _, f := range p.Freezes {
		if f.contains(deployAt) {
			return fmt.Errorf("%w: %s %02d:%02d-%02d:%02d", ErrFreeze,
				f.Weekday, f.StartMinute/60, f.StartMinute%60, f.EndMinute/60, f.EndMinute%60)
		}
	}
	return nil
}

// Contact names humans, not a group alias. An alias with no owner is how a
// page goes unanswered during an incident.
type Contact struct {
	Primary    string
	Escalation string
	Channel    string
}

func (c Contact) Validate() error {
	if c.Primary == "" || c.Escalation == "" {
		return ErrContact
	}
	return nil
}

// Exit is the condition under which a consumer may leave. ExportDays is how
// long data remains retrievable after the capability stops serving.
type Exit struct {
	Notice     time.Duration
	ExportDays int
	Plan       string
}

func (e Exit) Validate(p Policy) error {
	if e.Notice < p.MinExitNotice {
		return fmt.Errorf("%w: exit notice %s is below %s", ErrExit, e.Notice, p.MinExitNotice)
	}
	if e.ExportDays <= 0 || e.Plan == "" {
		return fmt.Errorf("%w: export window and migration plan are required", ErrExit)
	}
	return nil
}

// Capability is one row of the platform catalog. Consumers are the teams that
// may call it; an empty list means nobody has agreed to depend on it yet.
type Capability struct {
	Name      string
	Owner     string
	Consumers []string
	SLA       SLA
	Quota     Quota
	Billing   Billing
	Change    ChangePolicy
	Contact   Contact
	Exit      Exit
}

// Policy holds the organization-wide floors every capability must meet.
type Policy struct {
	MinChangeNotice time.Duration
	MinExitNotice   time.Duration
}

func DefaultPolicy() Policy {
	return Policy{MinChangeNotice: 72 * time.Hour, MinExitNotice: 90 * 24 * time.Hour}
}

func (c Capability) Validate(p Policy) error {
	if c.Name == "" {
		return ErrName
	}
	if c.Owner == "" {
		return ErrOwner
	}
	if len(c.Consumers) == 0 {
		return ErrConsumers
	}
	if err := c.Contact.Validate(); err != nil {
		return err
	}
	if err := c.SLA.Validate(); err != nil {
		return err
	}
	if err := c.Quota.Validate(); err != nil {
		return err
	}
	if err := c.Billing.Validate(); err != nil {
		return err
	}
	if c.Change.Notice < p.MinChangeNotice {
		return fmt.Errorf("%w: change notice %s is below %s", ErrNotice, c.Change.Notice, p.MinChangeNotice)
	}
	if err := c.Change.Validate(); err != nil {
		return err
	}
	return c.Exit.Validate(p)
}

读代码:三个容易写错的判断

第一,SLA.Validate 拒绝 Availability >= 1。很多团队把 100% 写进合同,结果既没有误差预算,也无法在故障后计算剩余额度。契约层直接拒绝,比事后解释便宜。

第二,ErrorBudget 用统计窗口乘不可用比例,而不是用“一个月 30 天”硬编码。窗口是契约的一部分,换窗口必须重新评审,不能由报表同学临时改。

第三,Freeze.contains 把跨零点窗口拆成“当天起点之后”和“次日终点之前”两段。只判断 Weekday 和分钟数的实现,会把周三凌晨的变更放过去,而这正是玩家在线的高峰尾巴。

失败路径与兜底

失败路径产品侧动作平台侧动作
平台能力短时不可用本地缓存、降级开关、排队重试按 SLA 启动故障流程,更新状态页
配额被共享池挤占提前申请预留配额按租户隔离池,提供余量查询
计费口径争议保留调用侧业务成功数提供网关与业务双层账单
契约变更未通知拒绝升级,要求回滚变更单强制关联通知记录
能力准备下线启动迁移计划提供导出工具与双跑窗口

与后面章节的边界

这一节只定义“平台承诺什么”。平台能力怎么自助交付,是 1.2 内部开发者平台 的内容;能力版本怎么演进,是 1.3 平台服务的版本与兼容 的内容;团队边界与所有权怎么划分,留到 9.1 团队边界与服务所有权 。

小结

平台团队与产品团队的关系,不是“谁给谁做需求”,而是一组可验证的承诺。六类能力、六项契约要素、四种反模式、一张评审检查表,构成了这份承诺的最小完整集。

阅读导航:上一节:目录与阅读路径 · 下一节:1.2 内部开发者平台 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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