《游戏服务端架构·平台化与架构治理》6.3 事故分级与响应

从两次分级失准的夜间事故出发,说明影响玩家数、持续时长、付费影响、可绕过性和舆情风险五个维度怎么组合成 S1 到 S4,谁担任指挥、沟通和执行角色,战时频道按什么节奏同步信息,以及升级、降级和宣布恢复的判据,并附一个可运行、可测试的 Go 分级与响应实现。

分级决定叫醒谁

凌晨两点,支付回调失败率升到 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 一边协调。

战时频道与信息节奏

事故期间只保留一个主频道,所有决策、变更、验证都回到主频道记录。私聊里做的决定等于没做,交接时一定丢失。

同步节奏按等级固定:

等级同步间隔频道对外
S115 分钟战时频道 + 语音状态页 + 社区公告
S230 分钟战时频道状态页
S32 小时事故群客服口径
S4每日工单/群一般不对外

每条同步消息用固定结构:事实、假设、下一步、需要谁。事实是可验证的观测;假设要明确标注“未确认”;下一步要有 owner 和时间点;需要谁要具体到角色,而不是“大家看一下”。

对外沟通要用玩家能理解的语言。“匹配服务 P99 抖动”不如“部分玩家匹配时间变长,正在恢复”;不要在没有确认前承诺“十分钟修好”。

交接必须写成文字,至少包含当前等级、影响面、已尝试的变更、当前假设、下一个检查点和负责人。口头交接在凌晨最容易丢信息。

升级与降级

升级由触发器驱动,不靠感觉:

触发器说明动作
影响面扩大受影响比例跨过 1%/10%/50%升一级
付费确认确认扣款/发货/退款受损至少 S2;达到门槛直接 S1
舆情升温社区负面帖激增、主播/媒体关注至少 S2,高舆情且涉及付费到 S1
时长越界有影响且持续 2 小时/6 小时升一级/两级
数据丢失确认不可恢复或范围不明直接 S1
指挥判断信息不足但风险高可升一级,记录理由

升级一次只升一级,每次记录触发器和时间。连续升级说明初始分级或信息收集有问题,复盘时要看,而不是只表扬“升级及时”。

降级比升级更严格:

  1. 玩家可见的症状已经消失,不只是某个指标变绿;
  2. 关键链路验证通过,包括登录、进房、结算、支付中受影响的部分;
  3. 稳定观察窗口达到约定值(例如 30 分钟,示例值),没有复发;
  4. 降级一次只降一级,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 故障树与定位路径 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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