复盘会上的三个问题
支付回调延迟四十分钟的那次事故,复盘会开了两个小时,前一个小时都在争三个问题:
- 平台承诺的是 99.9% 可用性,这四十分钟算不算违约?
- 延迟升高是因为渠道方回调变慢,平台自己的服务是健康的,预算算谁的?
- 产品说“我们的支付成功率掉了两个点”,平台说“我们只统计成功请求的延迟”,两边看的不是同一个分母。
这三个问题都不是技术细节,而是契约没写清楚。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 三款产品共用平台的契约矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。