分级决定叫醒谁
凌晨两点,支付回调失败率升到 0.3%(示例值)。按“影响玩家数”算,只占在线玩家的 0.3%(示例值),值班同学把它标成 S4,没有叫醒支付负责人。早上七点,积压订单超过四万笔(示例值),客服和财务同时被叫醒。文中案例与未标注来源的数字均为示例值,不是真实跑出来的数据。
同一个月还有一次相反的事故:十个玩家卡在副本里无法退出。因为其中有几个付费玩家,事故被标成 S1,指挥、沟通、执行全部被叫醒;实际上其他玩家可以重连绕过,影响面很小。
两次都错在“只看一个维度”。分级不是为了给事故排座次,而是为了决定三件事:叫醒谁、多久同步一次、谁有权做停服或回滚这类高代价决策。
五个维度
| 维度 | 要回答的问题 | 示例阈值 | 为什么重要 |
|---|---|---|---|
| 影响玩家数 | 多少人、占在线多少 | 1%、10%、50% | 绝对数和比例要一起看 |
| 影响时长 | 已经持续多久、预计多久 | 2 小时、6 小时 | 长时间小故障会累积成大事 |
| 付费影响 | 是否扣款、发货、退款 | 是/否 | 付费链路不能按普通可用性处理 |
| 可绕过 | 玩家能否继续玩或换路径 | 是/否 | 可绕过降低紧急度,但不消除问题 |
| 舆情风险 | 社区、主播、媒体是否关注 | 低/中/高 | 舆情会放大影响,也会改变沟通节奏 |
影响玩家数要用“在线玩家”或“目标人群”做分母,不能用注册用户数。一个区只有五千在线,五百人受影响就是 10%,不是“占全服 0.01% 所以没事”。
时长要同时记录“已持续”和“预计恢复”。预计恢复时间来自故障树的定位进度,不是拍脑袋;如果无法估计,就按当前等级每 30 分钟重新评估一次。
付费影响要拆成扣款、发货、退款三段。扣款成功但未发货、重复扣款、退款积压是三种不同的事故,前两种通常需要更高等级,因为它们直接伤害玩家资产。
可绕过不等于可忽略。玩家能重连绕过,说明伤害被限制住了,但根因还在;这类事故可以降一级,但不能直接关闭。
舆情风险是最难量化的维度。可操作的近似是:社区负面帖增速、主播/媒体是否提及、客服工单是否出现同一关键词。它适合做“升级触发器”,不适合单独决定初始等级。
分级规则
把五个维度组合成 S1 到 S4。下面是一组可执行的默认规则(示例值):
| 等级 | 触发条件 | 典型例子 | 响应 |
|---|---|---|---|
| S1 | 影响 >= 50% 在线;或数据丢失;或付费受损且影响 >= 10%;或高舆情且涉及付费 | 全区登录失败、结算回档、重复扣款 | 全员响应,战时频道,15 分钟同步 |
| S2 | 影响 >= 10%;或无法绕过且影响 >= 10%;或高舆情 | 大区匹配不可用、商城下单失败 | 指挥 + 沟通 + 执行,30 分钟同步 |
| S3 | 影响 >= 1% | 单玩法入口异常、部分渠道登录慢 | 指挥 + 执行,2 小时同步 |
| S4 | 影响 < 1% 且可绕过 | 少量玩家卡房间、单个皮肤展示错误 | 值班处理,每日同步 |
规则里有两类特殊处理:
- 硬下限:数据丢失、付费受损达到门槛、高舆情且涉及付费,直接抬到 S1,不允许因为“人数少”降级。
- 时长加权:有实际影响的事故,持续 2 小时升一级,持续 6 小时升两级;没有玩家受影响的事故不按时长升级,避免“挂机事故”被自动抬成 S1。
等级是下限,不是上限。事故指挥可以根据上下文提高等级并记录理由;降低等级必须满足恢复条件,不能因为“看起来不严重”就口头降级。
角色:谁指挥、谁沟通、谁执行
| 角色 | 负责 | 不负责 |
|---|---|---|
| 事故指挥 | 定优先级、做取舍、决定升级/降级、分配任务 | 不亲自 debug,不写代码 |
| 沟通 | 对内同步、对外公告、客服口径、状态页 | 不猜测原因,不承诺未确认的恢复时间 |
| 执行 | 回滚、降级、扩容、验证修复 | 不擅自扩大变更范围 |
| 记录(可选) | 时间线、决策、待办 | 不参与技术判断 |
| 高管发起人 | S1 时移除跨团队阻塞 | 不接管指挥 |
一场事故只能有一个指挥。多团队同时处理时,可以按服务拆执行小组,但事故指挥唯一;否则会出现两个人都以为对方在决策,或者同时做出互相冲突的变更。
指挥不一定是最资深的人。指挥需要的是信息整合、取舍和沟通能力;最懂某个模块的人更适合做执行,而不是一边 debug 一边协调。
战时频道与信息节奏
事故期间只保留一个主频道,所有决策、变更、验证都回到主频道记录。私聊里做的决定等于没做,交接时一定丢失。
同步节奏按等级固定:
| 等级 | 同步间隔 | 频道 | 对外 |
|---|---|---|---|
| S1 | 15 分钟 | 战时频道 + 语音 | 状态页 + 社区公告 |
| S2 | 30 分钟 | 战时频道 | 状态页 |
| S3 | 2 小时 | 事故群 | 客服口径 |
| S4 | 每日 | 工单/群 | 一般不对外 |
每条同步消息用固定结构:事实、假设、下一步、需要谁。事实是可验证的观测;假设要明确标注“未确认”;下一步要有 owner 和时间点;需要谁要具体到角色,而不是“大家看一下”。
对外沟通要用玩家能理解的语言。“匹配服务 P99 抖动”不如“部分玩家匹配时间变长,正在恢复”;不要在没有确认前承诺“十分钟修好”。
交接必须写成文字,至少包含当前等级、影响面、已尝试的变更、当前假设、下一个检查点和负责人。口头交接在凌晨最容易丢信息。
升级与降级
升级由触发器驱动,不靠感觉:
| 触发器 | 说明 | 动作 |
|---|---|---|
| 影响面扩大 | 受影响比例跨过 1%/10%/50% | 升一级 |
| 付费确认 | 确认扣款/发货/退款受损 | 至少 S2;达到门槛直接 S1 |
| 舆情升温 | 社区负面帖激增、主播/媒体关注 | 至少 S2,高舆情且涉及付费到 S1 |
| 时长越界 | 有影响且持续 2 小时/6 小时 | 升一级/两级 |
| 数据丢失 | 确认不可恢复或范围不明 | 直接 S1 |
| 指挥判断 | 信息不足但风险高 | 可升一级,记录理由 |
升级一次只升一级,每次记录触发器和时间。连续升级说明初始分级或信息收集有问题,复盘时要看,而不是只表扬“升级及时”。
降级比升级更严格:
- 玩家可见的症状已经消失,不只是某个指标变绿;
- 关键链路验证通过,包括登录、进房、结算、支付中受影响的部分;
- 稳定观察窗口达到约定值(例如 30 分钟,示例值),没有复发;
- 降级一次只降一级,S1 到 S4 不允许跳级。
宣布恢复不等于事故结束。恢复是“玩家可以正常使用”,结束是“根因找到、修复验证、复盘安排”。S1/S2 事故即使恢复,也要保留战时频道直到复盘启动,避免复发时重新拉人。
失败路径
| 失败模式 | 后果 | 防护 |
|---|---|---|
| 分级通胀 | 所有事故都成 S1,值班疲劳 | 硬门槛 + 复盘校准,不用“重视程度”定级 |
| 分级通缩 | 付费/数据问题被标成 S4 | 硬下限、付费链路单独规则 |
| 指挥亲自 debug | 没人协调,信息不同步 | 指挥只做取舍,执行另派人 |
| 沟通沉默 | 谣言、客服口径混乱 | 固定同步节奏,即使没有进展也要发 |
| 升级无交接 | 重复排查、变更冲突 | 升级必须附带文字交接 |
| 过早宣布恢复 | 同一事故反复重开 | 恢复判据 + 稳定窗口 |
| 等级不随影响更新 | 同步节奏和审批权限错配 | 每次同步重估影响面 |
代码:分级、角色与升降级
internal/incidentseverity/severity.go 把五个维度变成等级和响应计划,response.go 处理升级与降级。几个关键选择:
- 比例按在线玩家计算,输入校验会拒绝“受影响人数大于在线人数”这种脏数据;
- 硬下限先于时长加权执行,数据丢失和付费受损不会被小比例稀释;
- 等级决定角色、通知列表和同步间隔,避免“定了级但没人知道该叫谁”;
- 升级必须带触发器,一次一级;降级必须确认恢复,一次一级;
- 没有玩家受影响的事故不按时长自动升级。
// file: internal/incidentseverity/severity.go
// tier: L1
// verify: go test ./internal/incidentseverity/
package incidentseverity
import (
"errors"
"fmt"
"time"
)
var (
ErrInvalidInput = errors.New("incidentseverity: invalid input")
ErrAlreadyAtTop = errors.New("incidentseverity: incident is already S1")
)
type Severity string
const (
S1 Severity = "S1"
S2 Severity = "S2"
S3 Severity = "S3"
S4 Severity = "S4"
)
func (s Severity) valid() bool { return s.level() != 0 }
func (s Severity) level() int {
switch s {
case S1:
return 1
case S2:
return 2
case S3:
return 3
case S4:
return 4
default:
return 0
}
}
func fromLevel(n int) Severity {
switch n {
case 1:
return S1
case 2:
return S2
case 3:
return S3
default:
return S4
}
}
func (s Severity) worse() Severity { return fromLevel(s.level() - 1) }
func (s Severity) better() Severity { return fromLevel(s.level() + 1) }
type PublicRisk string
const (
RiskLow PublicRisk = "low"
RiskMedium PublicRisk = "medium"
RiskHigh PublicRisk = "high"
)
type Input struct {
AffectedPlayers int
OnlinePlayers int
Duration time.Duration
PaymentAffected bool
Workaround bool // true means players can still play or bypass the failure
PublicRisk PublicRisk
DataLoss bool
}
type Role string
const (
IncidentCommander Role = "incident_commander"
Communications Role = "communications"
Operations Role = "operations"
ExecutiveSponsor Role = "executive_sponsor"
)
type Plan struct {
Severity Severity
Reasons []string
Roles []Role
Notify []string
UpdateEvery time.Duration
WarRoom bool
}
func planFor(sev Severity) Plan {
switch sev {
case S1:
return Plan{
Severity: S1,
Roles: []Role{IncidentCommander, Communications, Operations, ExecutiveSponsor},
Notify: []string{"pager", "leadership", "customer_support", "community"},
UpdateEvery: 15 * time.Minute,
WarRoom: true,
}
case S2:
return Plan{
Severity: S2,
Roles: []Role{IncidentCommander, Communications, Operations},
Notify: []string{"pager", "leadership", "customer_support"},
UpdateEvery: 30 * time.Minute,
WarRoom: true,
}
case S3:
return Plan{
Severity: S3,
Roles: []Role{IncidentCommander, Operations},
Notify: []string{"oncall", "customer_support"},
UpdateEvery: 2 * time.Hour,
}
default:
return Plan{
Severity: S4,
Roles: []Role{Operations},
Notify: []string{"oncall"},
UpdateEvery: 24 * time.Hour,
}
}
}
func atLeast(current, floor Severity) Severity {
if current.level() > floor.level() {
return floor
}
return current
}
func worsen(sev Severity, levels int) Severity {
n := sev.level() - levels
if n < 1 {
n = 1
}
return fromLevel(n)
}
// Classify turns five observable facts into a severity and a response plan.
// Hard floors run before duration: data loss or confirmed payment damage cannot
// be talked down to S3 just because the affected ratio is small.
func Classify(in Input) (Plan, error) {
if in.OnlinePlayers <= 0 {
return Plan{}, fmt.Errorf("%w: online players must be positive", ErrInvalidInput)
}
if in.AffectedPlayers < 0 || in.AffectedPlayers > in.OnlinePlayers {
return Plan{}, fmt.Errorf("%w: affected players out of range", ErrInvalidInput)
}
if in.Duration < 0 {
return Plan{}, fmt.Errorf("%w: duration cannot be negative", ErrInvalidInput)
}
if in.PublicRisk != "" && in.PublicRisk != RiskLow && in.PublicRisk != RiskMedium && in.PublicRisk != RiskHigh {
return Plan{}, fmt.Errorf("%w: unknown public risk %q", ErrInvalidInput, in.PublicRisk)
}
ratio := float64(in.AffectedPlayers) / float64(in.OnlinePlayers)
sev := S4
reasons := make([]string, 0, 5)
switch {
case ratio >= 0.5:
sev = S1
reasons = append(reasons, "half or more of online players affected")
case ratio >= 0.1:
sev = S2
reasons = append(reasons, "at least 10% of online players affected")
case ratio >= 0.01:
sev = S3
reasons = append(reasons, "at least 1% of online players affected")
default:
reasons = append(reasons, "limited player impact")
}
if in.DataLoss {
sev = atLeast(sev, S1)
reasons = append(reasons, "data loss")
}
if in.PaymentAffected && ratio >= 0.1 {
sev = atLeast(sev, S1)
reasons = append(reasons, "payment affected for a material share of players")
}
if in.PublicRisk == RiskHigh {
sev = atLeast(sev, S2)
reasons = append(reasons, "high public attention")
if in.PaymentAffected {
sev = atLeast(sev, S1)
reasons = append(reasons, "payment plus public attention")
}
}
if !in.Workaround && ratio >= 0.1 {
sev = atLeast(sev, S2)
reasons = append(reasons, "no player workaround")
}
if in.AffectedPlayers > 0 {
switch {
case in.Duration >= 6*time.Hour:
sev = worsen(sev, 2)
reasons = append(reasons, "unresolved for six hours or more")
case in.Duration >= 2*time.Hour:
sev = worsen(sev, 1)
reasons = append(reasons, "unresolved for two hours or more")
}
}
p := planFor(sev)
p.Reasons = reasons
return p, nil
}
落地时建议把 Classify 的输出直接写进事故单:等级、原因列表、角色、同步间隔、通知列表。事故指挥在频道里只需要贴一次事故单,后续同步都引用它,减少“现在到底是几级”的争论。
检查表
- 事故单记录了五个维度,而不是只写“影响较大”;
- 影响比例用在线玩家或目标人群做分母;
- 数据丢失、付费受损、高舆情有硬下限;
- 一场事故只有一个指挥,角色和替补明确;
- 主频道唯一,决策和变更都有记录;
- 同步间隔与等级匹配,没有进展也要同步;
- 升级触发器和降级判据写清楚;
- 宣布恢复有稳定观察窗口,S1/S2 复盘已排期。
小结
事故分级的核心不是“严重程度”四个字,而是资源分配:叫醒多少人、多久同步一次、谁有权做高代价决策。五个维度里,影响面和付费决定下限,可绕过性决定紧急度,舆情决定沟通节奏,时长负责把“拖很久的小问题”抬到该有的位置。
分级之后还有两件事不能省:一是把响应角色和信息节奏固定下来,避免指挥一边 debug 一边协调;二是把恢复判据写清楚,避免过早宣布恢复。事故结束后的复盘与改进闭环在 7.2 节展开,本节只负责把事故接住。
阅读导航:上一节:6.2 错误预算与发布节奏 · 下一节:7.1 故障树与定位路径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。