99.95% 的登录成功率,玩家还在骂
《裂隙战场》大版本更新后的第一个周末,登录看板显示 99.95%(示例值),值班同学认为可以收工;客服同时收到十几条“排队卡死、重进才好”的反馈。把当天事件逐条拉出来,问题出在三个口径:
- 第一次登录超时的玩家,客户端自动重试成功,事件表里只留下一条 good;
- 维护窗口内的失败被报表整体排除,但那次维护比公告多拖了十二分钟(示例值);
- 玩家在排队界面主动关掉客户端,被标成
player_quit,不计入分母。
三个口径单独看都“有道理”,合在一起就把一次真实的可感知故障洗成了达标。SLO 的第一课不是选目标,而是先回答:分母是谁,什么算好,什么可以排除。文中案例与未标注来源的数字均为示例值,不是真实跑出来的数据。
先选 SLI,再谈 SLO
SLI 是“玩家想完成的动作”,SLO 是这个动作在一段时间内应该成功的比例。动作选错,后面的目标、预算、告警都会跟着错。游戏服务端至少需要五类候选 SLI:
| SLI | good(计入分子) | bad(计入分母) | 排除项 |
|---|---|---|---|
| 登录成功率 | 30 秒内拿到有效会话并进入大厅 | 明确失败、超时、5xx、会话无效 | 计划维护、客户端版本不支持、排队前主动取消 |
| 进房成功率 | 匹配成功后 15 秒内进入房间并收到开局快照 | 超时、房间不存在、重复分配 | 玩家主动取消匹配 |
| 对局完成率 | 对局正常结算并回到大厅 | 服务端崩溃、全员掉线且无法重连 | 玩家主动投降、正常退出 |
| 结算成功率 | 结算幂等落库且客户端确认 | 重复结算、丢失、超时未确认 | 计划维护 |
| 商城下单成功率 | 下单成功、支付回调确认、发货完成 | 扣款未发货、重复扣款、回调丢失 | 玩家取消支付、渠道明确拒绝 |
支付渠道拒绝不是服务端失败,但它需要独立的看板和告警,不能因为“不是我们的错”就静默排除。否则渠道故障会以“下单成功率正常、客服工单暴涨”的形式出现。
五类 SLI 的粒度也不一样。登录和进房是流量型,适合按请求数算比例;对局完成和结算是会话型,分母是“开始的对局/结算单”,不是 HTTP 请求;商城下单则必须把“下单”和“发货”分开看,只统计下单成功会漏掉扣款后未发货。相关指标口径在第 5.2 节已经展开,这里只保留能变成承诺的部分。
分母比分子更容易做错
分母是“玩家发起的一次意图”,不是“服务端收到的一次请求”。客户端重试、SDK 自动重连、轮询都会放大请求数;如果不按 intent_id 去重,一次失败加三次重试会变成三条 good,成功率反而更高。
只统计已经进入登录队列的玩家是另一种偏差。被网关直接拒绝、在版本检查阶段被挡下、在排队页崩溃的玩家根本不会出现在队列事件里。分母必须从“玩家点击登录”开始,而不是从“登录服收到请求”开始。
低流量 SLI 要特别小心。某个区一天只有 200 次登录,99.9% 的目标意味着一天最多允许 0.2 次失败;一个失败就会让日达标率变成 99.5%。这类 SLI 应该用周窗口或 28 天窗口,并在报表上标注样本量,而不是用日达标率直接卡发布。
时间窗要和承诺一致。对外承诺用 28 天滚动窗口,诊断用 1 小时和 6 小时窗口;不要用自然月,月初重置会让团队在月末“冲预算”,也会让月初的故障被下个月的成绩掩盖。
维护窗口不是天然的排除项。计划内维护只有同时满足“提前公告”“在承诺窗口外”“有变更单”才能排除;临时重启、紧急回滚、容量不足导致的维护都要计入。
排除项:只排除“不是服务端责任且玩家没有受损”的事件
排除规则要满足五个条件:
- 写在事件里,不做事后剔除。事件入库时带
exclusion枚举,报表只做聚合,避免“先看结果再决定删哪条”。 - 每个排除项有 owner 和审计。谁标的、为什么标、当天比例多少,要能查。
- 排除比例有阈值。单个 SLI 的排除率超过 5%(示例值)要审查,超过 10%(示例值)视为口径事故。
- 排除项不能跨 SLI 复用。“玩家主动退出”对登录成功率可能合理,对结算成功率就不合理。
- 维护排除要带公告链接和窗口。没有公告的维护一律按故障计。
| 排除项 | 可以排除的场景 | 不能排除的场景 |
|---|---|---|
| 计划维护 | 提前公告且落在承诺窗口外 | 临时重启、紧急回滚、容量不足 |
| 玩家主动退出 | 登录排队前取消、匹配中取消 | 对局中掉线、结算前退出 |
| 重复重试 | 同一 intent_id 的重复请求 | 玩家手动重试仍然失败 |
| 客户端版本不支持 | 旧版本被强制升级 | 服务端协议不兼容导致新版本失败 |
排除项的本质是“把责任从服务端移走”,所以它必须有边界。玩家主动退出可以从可用性里排除,但要单独进入留存和流失分析;否则团队会同时失去可用性口径和产品信号。
延迟:达标率之外还要看尾部
登录成功率 99.9% 可能由“30 秒内成功”定义,但玩家体感是 3 秒还是 28 秒差别巨大。只定义一个成功比例,会把“活着但很慢”算成健康。
分位数要看三个:p50 看常态,p95 看大多数玩家的上限,p99 看尾部。平均值既会被少数极慢请求拉高,也会被大量快请求掩盖,不适合单独做承诺。
| 指标 | 阈值(示例值) | 用途 |
|---|---|---|
| 登录 p95 | <= 800ms | 日常体验 |
| 登录 p99 | <= 2s | 尾部与超时边界 |
| 进房 p95 | <= 300ms | 匹配后的等待 |
| 结算 p99 | <= 500ms | 结算确认 |
| 重连成功率 | >= 99% | 掉线恢复 |
| 服务端 tick p99 | <= 50ms | 对局公平性 |
这些数字必须用自己的压测和真实分位数校准,不能照抄。延迟 SLI 也不适合和成功率混成一个复合分数:一个 99.9% 的成功率加一个很差的 p99,平均之后看不出问题。
目标怎么定:从玩家能感知的损失倒推
| SLO | 28 天等效不可用(按时间折算) | 适用 |
|---|---|---|
| 99.9% | 40 分 19 秒 | 大多数在线服务 |
| 99.95% | 20 分 10 秒 | 登录、支付等关键路径 |
| 99.99% | 4 分 2 秒 | 只有少数强承诺 |
这张表是时间等效,不是“可以停机这么久”。事件型 SLI 的预算按失败事件数算,和停机时长不是一回事。
不要给所有 SLI 都定 99.99%。目标越高,成本越高,非故障发布被冻结的时间也越长;一个不影响玩家的后台接口和一个登录入口不应该用同一个数字。目标还要写清楚“玩家能感知的损失”:掉线 30 秒和登录慢 3 秒不是同一种伤害,前者应该进更高等级的事故流程。
目标变更必须版本化。中途改口径让当月达标,是最常见也最伤信任的做法;如果确实要改,保留旧口径的报表并注明生效日期。
分区与分渠道
全球平均会掩盖一个区。下面是一张典型的区域报表(示例值):
| 区域 | 登录成功率 | 样本量 | 判断 |
|---|---|---|---|
| 华东 | 99.97% | 120 万 | 达标 |
| 东南亚 | 99.82% | 8 万 | 未达标,需单独行动 |
| 南美 | 99.90% | 9 千 | 样本不足,按周聚合 |
分区 SLO 和全球 SLO 的关系要提前定:是“每个区都必须达标”,还是“全球达标即可、单区只做观察”。前者更贴近玩家体验,但低流量区会被样本量折磨;后者容易让一个小区的长期故障被平均掉。常见折中是“大区独立承诺,小区按周聚合后承诺”。
支付渠道也要分开。不同渠道的回调延迟、重试策略、拒付率差异很大;把渠道混在一起,一次渠道故障会污染整个商城 SLI,也会让渠道负责人看不到自己的问题。
口径落地:一个可测试的计算包
internal/slodefinition/slo.go 把事件、排除项、窗口、达标率和分区拆开,latency.go 单独处理延迟分位。核心约束只有几条:
Evaluate只处理指定 SLI 和半开窗口[Start, End),避免边界事件被重复计算;- 排除事件不进分母,未知 outcome 直接报错,避免脏数据被静默吞掉;
allowedBad用 epsilon 处理1-0.9这类浮点误差;Breakdown按区域或渠道分组,空 key 归到unknown;Percentile用 nearest-rank,便于向产品解释“第 95 个样本”而不是插值公式。
// file: internal/slodefinition/slo.go
// tier: L1
// verify: go test ./internal/slodefinition/
package slodefinition
import (
"errors"
"fmt"
"math"
"time"
)
var (
ErrInvalidSpec = errors.New("slodefinition: invalid spec")
ErrInvalidTarget = errors.New("slodefinition: target must be in (0,1]")
ErrInvalidWindow = errors.New("slodefinition: window end must be after start")
ErrInvalidOutcome = errors.New("slodefinition: unknown outcome")
ErrInvalidPercentile = errors.New("slodefinition: percentile must be in (0,1]")
)
// SLI names the user-visible action being promised. Each one has a different
// denominator; mixing them produces a number that cannot drive a decision.
type SLI string
const (
LoginSuccess SLI = "login_success"
EnterRoomSuccess SLI = "enter_room_success"
MatchCompletion SLI = "match_completion"
SettlementSuccess SLI = "settlement_success"
ShopOrderSuccess SLI = "shop_order_success"
)
type Outcome string
const (
Good Outcome = "good"
Bad Outcome = "bad"
)
// Exclusion marks events that are removed from both numerator and denominator.
// The exclusion must be a property of the event, not a post-hoc judgement made
// after the SLO has already missed.
type Exclusion string
const (
NoExclusion Exclusion = ""
Maintenance Exclusion = "maintenance"
PlayerQuit Exclusion = "player_quit"
DuplicateRetry Exclusion = "duplicate_retry"
)
type Event struct {
SLI SLI
Outcome Outcome
Exclusion Exclusion
At time.Time
Region string
Channel string
}
type Spec struct {
SLI SLI
Target float64
Start time.Time
End time.Time
}
type Report struct {
SLI SLI
Target float64
Good int
Bad int
Excluded int
Eligible int
Attainment float64
Met bool
BadAllowed int
BadLeft int
NoData bool
}
func validateSpec(spec Spec) error {
if spec.Target <= 0 || spec.Target > 1 {
return ErrInvalidTarget
}
if !spec.End.After(spec.Start) {
return ErrInvalidWindow
}
return nil
}
func allowedBad(target float64, eligible int) int {
if eligible == 0 {
return 0
}
// The epsilon absorbs binary floating point error such as 1-0.9 = 0.0999...
return int(math.Floor((1-target)*float64(eligible) + 1e-9))
}
// Evaluate computes one SLI over the half-open window [Start, End).
// Events of other SLIs and events outside the window are ignored. An excluded
// event never enters the denominator, even when its outcome is bad.
func Evaluate(events []Event, spec Spec) (Report, error) {
if err := validateSpec(spec); err != nil {
return Report{}, err
}
r := Report{SLI: spec.SLI, Target: spec.Target, NoData: true}
for _, e := range events {
if e.SLI != spec.SLI {
continue
}
if e.At.Before(spec.Start) || !e.At.Before(spec.End) {
continue
}
if e.Exclusion != NoExclusion {
r.Excluded++
continue
}
switch e.Outcome {
case Good:
r.Good++
case Bad:
r.Bad++
default:
return Report{}, fmt.Errorf("%w: %q", ErrInvalidOutcome, e.Outcome)
}
}
r.Eligible = r.Good + r.Bad
if r.Eligible == 0 {
return r, nil
}
r.NoData = false
r.Attainment = float64(r.Good) / float64(r.Eligible)
r.Met = r.Attainment >= spec.Target
r.BadAllowed = allowedBad(spec.Target, r.Eligible)
r.BadLeft = r.BadAllowed - r.Bad
return r, nil
}
type Dimension string
const (
ByRegion Dimension = "region"
ByChannel Dimension = "channel"
)
// Breakdown evaluates the same spec per region or per channel. A merged
// global number can hide one region that misses the target every day.
func Breakdown(events []Event, spec Spec, dim Dimension) (map[string]Report, error) {
if err := validateSpec(spec); err != nil {
return nil, err
}
if dim != ByRegion && dim != ByChannel {
return nil, fmt.Errorf("%w: unknown dimension %q", ErrInvalidSpec, dim)
}
groups := make(map[string][]Event)
for _, e := range events {
key := e.Region
if dim == ByChannel {
key = e.Channel
}
if key == "" {
key = "unknown"
}
groups[key] = append(groups[key], e)
}
out := make(map[string]Report, len(groups))
for key, group := range groups {
r, err := Evaluate(group, spec)
if err != nil {
return nil, err
}
out[key] = r
}
return out, nil
}
接入顺序建议是“事件表 -> 口径服务 -> 报表与发布门禁”。不要让每个报表自己写 SQL,否则同一个 SLI 会出现三种分母。上线前做三次校验:抽样人工核对、与旧报表对账、分区汇总等于全局。
| 失败路径 | 表现 | 处理 |
|---|---|---|
| 重复事件 | 成功率虚高、预算突然变多 | 按 intent_id 幂等,入口去重 |
| 时区错位 | 日界附近的故障被切到两天 | 统一 UTC 日界,报表注明时区 |
| 分母漂移 | 某天 eligible 突然下降 | 对 eligible 量做告警,不只看比例 |
| 排除率过高 | 指标好看但工单上涨 | 排除率阈值、owner 复核、抽样审计 |
检查表
- 每个 SLI 都有明确的 good、bad、excluded 定义;
- 分母是玩家意图,重试和轮询已经合并;
- 时间窗与对外承诺一致,低流量有最小样本策略;
- 分区和支付渠道有独立口径;
- 延迟有 p95、p99,不只看平均;
- 排除率有阈值、owner 和审计记录;
- 目标有成本和体验依据,变更已版本化。
小结
SLO 不是“服务活着”的另一种说法,而是“玩家完成了一个动作”的承诺。定义 SLO 的难点不在目标数字,而在分母、排除项和时间窗:分母选错会幸存者偏差,排除项滥用会洗掉故障,时间窗选错会让团队在错误的时间被奖励。先把这四件事写清楚,再谈 99.9% 还是 99.95%。
下一节把这些承诺变成预算:预算怎么滚动、燃尽率怎么看、以及预算状态如何决定一次发布能不能上车。
阅读导航:上一节:5.3 告警设计与值班 · 下一节:6.2 错误预算与发布节奏 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。