平台团队不是需求池
《裂隙战场》要接排行榜,《群岛纪元》要改支付,《泡泡工坊》要加公告。三个产品各自拉一个群,各自找一位平台同学,各自约定一个“下周给你”。到了下周,三个需求都变成了口头承诺,没有人能说清哪条接口已经稳定、哪条还在试验。以下场景与数字均为示例值。
平台的产品是六类能力
北辰把平台能力收敛成六类。每一类都必须回答三个问题:谁维护、谁调用、平台不负责什么。
| 能力 | 维护方 | 主要调用方 | 平台不负责 |
|---|---|---|---|
| 账号 | 身份平台组 | 三款产品 | 渠道账号绑定策略、实名策略的产品侧文案 |
| 支付 | 交易平台组 | 三款产品 | 商品定价、活动折扣、退款审核规则 |
| 匹配 | 对战平台组 | 《裂隙战场》《泡泡工坊》 | 分段规则、机器人投放策略 |
| 排行榜 | 数据平台组 | 三款产品 | 榜单展示样式、奖励发放 |
| 社交 | 社交平台组 | 《群岛纪元》《泡泡工坊》 | 好友推荐权重、公会玩法 |
| 公告 | 运营平台组 | 三款产品 | 公告内容审核、投放排期 |
契约六要素
| 要素 | 必须写清 | 缺失后的典型后果 |
|---|---|---|
| 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 内部开发者平台 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。