《游戏服务端架构·核心系统与契约》12.3 平台 SLA 与产品团队的契约

平台与产品团队真正会争吵的是事故之后:99.9% 的可用性按哪条链路算,平台发版造成的失败算谁的预算,延迟 SLA 的分母要不要包含失败请求。本文把可用性、延迟、吞吐与恢复时间拆成可计算条款,给出错误预算、燃烧率与违约判定的 Go 实现,并说明变更通知、灰度窗口和回滚承诺怎么写进契约。

复盘会上的三个问题

支付回调延迟四十分钟的那次事故,复盘会开了两个小时,前一个小时都在争三个问题:

  1. 平台承诺的是 99.9% 可用性,这四十分钟算不算违约?
  2. 延迟升高是因为渠道方回调变慢,平台自己的服务是健康的,预算算谁的?
  3. 产品说“我们的支付成功率掉了两个点”,平台说“我们只统计成功请求的延迟”,两边看的不是同一个分母。

这三个问题都不是技术细节,而是契约没写清楚。SLA 不是一句“我们保证稳定”,而是一组可计算、可举证、可争议的条款。本节把条款拆开,并给出一个能跑出数字的实现。

按能力分别定义

平台把所有能力写成同一个 99.9% 没有意义。登录挂掉和报表晚一小时,对玩家的影响完全不同。我们的做法是按能力定义指标、目标、测量点和排除项:

能力指标示例目标测量点排除项
账号登录可用性99.95%网关到账号服务的成功响应玩家网络错误、客户端版本过旧
支付下单成功率99.9%平台收到渠道成功回调并完成记账渠道明确返回失败、风控拦截
支付回调延迟延迟达标率99% 在 2 秒内渠道回调进入到平台记账完成渠道侧排队时间
社交读取可用性99.9%平台 API 成功响应调用方超时取消
配置分发可用性 + 生效时间99.99%,1 分钟内边缘节点拉取成功并可校验版本客户端未联网
数据管道完整性99.9% 事件在 5 分钟内可见事件写入到可查询产品主动丢弃的调试事件

表里的数字都是示例值,不是行业标准。重要的是每一行都有测量点和排除项:没有测量点的 SLA 无法举证,没有排除项的 SLA 一定会在第一次事故里被扩大解释。

四个指标与各自的陷阱

指标定义常见陷阱
可用性成功请求 / 总请求分母偷偷排除失败请求,数字变好看
延迟成功请求中低于阈值的比例把失败请求算进延迟分母,或只统计服务端处理时间
吞吐单位时间完成的请求数把容量上限当成承诺吞吐,活动一来就违约
恢复时间从故障确认到服务恢复的时长从“修复完成”开始算,忽略发现与确认时间

我们明确了两条口径:延迟只统计成功请求,失败请求计入可用性,不重复计入延迟;恢复时间从监控确认故障开始算,不从工程师开始处理算。口径写进契约,争议就少一半。

错误预算怎么算

可用性目标 99.9%,意味着一个窗口内允许 0.1% 的失败。这个 0.1% 就是错误预算。以 30 天为例,总时长 43200 分钟,预算约 43.2 分钟(示例值);如果目标 99.95%,预算只剩约 21.6 分钟。

预算有三种用法:

  • 剩余预算充足:可以按计划发布,不必为每次灰度过度紧张。
  • 预算接近耗尽:暂停非必要变更,先补监控与容量。
  • 预算已经超支:触发违约评审,平台与产品共同决定是降目标、加投入还是改架构。

燃烧率(burn rate)是消耗速度相对预算的倍数。预算刚用掉一半、窗口才过一半,燃烧率是 1;窗口过了一半、预算用掉八成,燃烧率是 1.6,应该触发预警。治理细节(多窗口告警、冻结规则)留到卷四展开,这里只保证指标算得出来。

变更通知、灰度与回滚

SLA 不只约束故障,也约束变更。平台发版造成的失败,和机房断电一样计入预算。因此契约必须写清通知、灰度和回滚:

变更类型提前通知灰度方式回滚承诺(示例值)
兼容新增1 个工作日不需要,可直接全量发现问题可即时关闭开关
行为变更5 个工作日1% → 10% → 50% → 100%30 分钟内回到旧行为
破坏性变更90 天按游戏名单逐批迁移旧版本保留 90 天
紧急修复事后 1 个工作日内补通知按影响面选择最小范围保留上一版本镜像

“提前通知”不是发给所有人群里吼一声,而是发给每个产品团队登记的接口人,并附上变更内容、影响能力、回滚方式和验证方法。没有接口人的产品,平台可以拒绝变更。

事故责任边界与联合响应

事故发生时,最容易发生的是互相等待。契约要提前把责任分成三类:

故障类型判定依据平台责任产品责任
平台缺陷平台自身代码、配置、容量导致止损、修复、出具时间线启用降级、安抚玩家
产品误用超配额、错误参数、绕过契约提供证据与限流保护修正调用、承担超限部分
上游依赖渠道、云厂商、运营商故障切换、重试、通报进展调整活动节奏、准备补偿

联合响应只设一个 incident commander,通常由平台值班担任,因为平台掌握跨产品视角;产品团队负责玩家侧沟通与降级开关。两边各自有 runbook,但共用一条时间线,避免事后各写一份互相矛盾的复盘。

产品侧的降级责任必须写进契约,不能等事故时再商量:

  • 配置读取失败时使用本地缓存,并在恢复后校验版本。
  • 支付回调积压时先落队列,不在请求路径里重试。
  • 社交读取失败时隐藏相关入口,而不是让整个大厅不可用。

平台可以提供降级工具,但不能替产品决定“隐藏哪个入口”。这也是 12.1 那条边界的延续:平台拥有事实,产品拥有策略。

代码:把 SLA 算成可争论的数字

internal/platformsla 不采集数据,只负责把样本算成报告。evaluate.go 做四件事:按窗口过滤样本、按指标决定分母、计算达成率与错误预算、判定是否违约。

两个口径直接写进代码:可用性的分母是所有样本;延迟的分母只包含成功请求,失败请求交给可用性指标。窗口边界是左开右闭,避免同一批样本被两个相邻窗口重复计算。

// file: internal/platformsla/evaluate.go
// tier: L1
// verify: go test ./internal/platformsla/
package platformsla

import (
	"fmt"
	"time"
)

// EvaluateAt filters samples to the window ending at now, then computes the
// achieved level and the remaining error budget. The cutoff is exclusive and
// now itself is included, so a caller can evaluate a closed interval.
func EvaluateAt(s SLA, samples []Sample, now time.Time) (Report, error) {
	if err := s.Validate(); err != nil {
		return Report{}, err
	}
	min := s.MinSamples
	if min <= 0 {
		min = 1
	}
	cutoff := now.Add(-s.Window)
	in := make([]Sample, 0, len(samples))
	for _, sample := range samples {
		if sample.At.After(cutoff) && !sample.At.After(now) {
			in = append(in, sample)
		}
	}
	if len(in) < min {
		return Report{}, fmt.Errorf("%w: %d samples, need %d", ErrInsufficientData, len(in), min)
	}

	report := Report{Capability: s.Capability, Budget: 1 - s.Target}
	switch s.Objective {
	case Availability:
		report.Total = len(in)
		for _, sample := range in {
			if !sample.Success {
				report.Failures++
			}
		}
	case Latency:
		for _, sample := range in {
			if !sample.Success {
				continue
			}
			report.Total++
			if sample.Latency > s.Threshold {
				report.Failures++
			}
		}
		if report.Total < min {
			return Report{}, fmt.Errorf("%w: %d successful samples, need %d", ErrInsufficientData, report.Total, min)
		}
	}

	report.Achieved = float64(report.Total-report.Failures) / float64(report.Total)
	report.Burned = 1 - report.Achieved
	report.Remaining = report.Budget - report.Burned
	report.BurnRate = report.Burned / report.Budget
	report.Breached = report.Achieved < s.Target
	return report, nil
}

下面这组测试覆盖预算的三个位置(用一半、刚好用完、超支)、延迟分母的口径,以及窗口边界。

// file: internal/platformsla/budget_test.go
// tier: L1
package platformsla

import (
	"errors"
	"testing"
	"time"
)

func TestErrorBudgetMath(t *testing.T) {
	sla := SLA{Capability: "payment", Objective: Availability, Target: 0.99, Window: 5 * time.Minute}
	tests := []struct {
		name          string
		failures      int
		wantRemaining float64
		wantBurnRate  float64
		wantBreached  bool
	}{
		{"half of budget", 5, 0.005, 0.5, false},
		{"budget exhausted", 10, 0, 1, false},
		{"budget overspent", 20, -0.01, 2, true},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			report, err := EvaluateAt(sla, availabilitySamples(1000, tc.failures), evalNow)
			if err != nil {
				t.Fatalf("EvaluateAt: %v", err)
			}
			if !closeTo(report.Remaining, tc.wantRemaining) {
				t.Fatalf("Remaining = %v, want %v", report.Remaining, tc.wantRemaining)
			}
			if !closeTo(report.BurnRate, tc.wantBurnRate) {
				t.Fatalf("BurnRate = %v, want %v", report.BurnRate, tc.wantBurnRate)
			}
			if report.Breached != tc.wantBreached {
				t.Fatalf("Breached = %v, want %v", report.Breached, tc.wantBreached)
			}
		})
	}
}

func TestLatencyExcludesFailures(t *testing.T) {
	sla := SLA{
		Capability: "matchmaking",
		Objective:  Latency,
		Target:     0.99,
		Threshold:  200 * time.Millisecond,
		Window:     5 * time.Minute,
		MinSamples: 100,
	}
	samples := append(latencySamples(990, 10, 250*time.Millisecond), availabilitySamples(500, 500)...)
	report, err := EvaluateAt(sla, samples, evalNow)
	if err != nil {
		t.Fatalf("EvaluateAt: %v", err)
	}
	if report.Total != 1000 || report.Failures != 10 || !closeTo(report.Achieved, 0.99) {
		t.Fatalf("report = %+v, want 990 of 1000 successful calls under threshold", report)
	}
}

func TestWindowCutoff(t *testing.T) {
	sla := SLA{Capability: "data", Objective: Availability, Target: 0.99, Window: 5 * time.Minute, MinSamples: 1}
	tests := []struct {
		name      string
		at        time.Time
		wantTotal int
	}{
		{"inside window", evalNow.Add(-time.Minute), 1},
		{"before cutoff", evalNow.Add(-10 * time.Minute), 0},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			report, err := EvaluateAt(sla, []Sample{{At: tc.at, Success: true}}, evalNow)
			if tc.wantTotal == 0 {
				if !errors.Is(err, ErrInsufficientData) {
					t.Fatalf("EvaluateAt() error = %v, want ErrInsufficientData", err)
				}
				return
			}
			if err != nil || report.Total != tc.wantTotal {
				t.Fatalf("report = %+v, err = %v", report, err)
			}
		})
	}
}

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/platformsla
$ go vet ./internal/platformsla/...
$ go test -count=1 ./internal/platformsla/...
ok  	gsarchlab/internal/platformsla

gofmt -l 与 go vet 均无输出。platformsla 的 8 个测试函数共 36 个子用例通过,覆盖 SLA 校验、可用性预算、延迟口径、窗口过滤与样本不足。表中的目标值和预算分钟数都是示例,不是线上实测。

失败模式与兜底

失败表现兜底
分母被悄悄缩小达成率长期高于体感契约固定分母定义,样本从同一测量点采集
延迟混入失败请求故障时延迟反而“变好”延迟只统计成功请求,失败计入可用性
窗口边界重复计算同一批失败被算两次或漏算左开右闭,评估时间点显式传入
样本不足就下结论凌晨低峰误判违约MinSamples 下限,不足返回明确错误
变更未计入预算发版故障被当成“计划内”所有平台变更失败都计入错误预算
降级责任不清事故时互相等待契约列明产品侧降级项与平台侧工具

常见错误与边界

第一,所有能力共用一个可用性目标。登录、报表、公告的故障代价不同,统一目标只会让重要的能力被平均掉。

第二,只写目标不写测量点。没有测量点的 SLA 在事故时无法举证,最后只能靠印象谈判。

第三,把上游故障全部排除。上游可以单独定义,但不能默认不算;否则平台永远不会违约。

第四,错误预算只做报表不做决策。预算耗尽仍然照常发布,等于没有预算。

第五,降级方案写在平台侧。平台可以提供开关,但隐藏哪个入口、给什么提示,必须由产品决定。

小结

平台 SLA 的价值不在于写一个漂亮的百分比,而在于事故发生后三方能在同一组数字上对话:按能力定义指标,固定分母与测量点,用错误预算约束变更,用通知、灰度和回滚承诺约束平台自己,用降级责任约束产品侧。代码只做一件事——把样本算成达成率、预算和违约判定,让争论有共同的输入。

到这里,平台服务化的三个问题——收什么、怎么隔离、怎么承诺——已经形成闭环。下一章把三款产品的实际契约放进同一张矩阵,检查哪些能力可以共用、哪些必须分叉。

阅读导航:上一节:12.2 多游戏复用与租户隔离 · 下一节:13.1 三款产品共用平台的契约矩阵 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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