《MMO 服务器开发》8.3 演练与事故复盘

备份能不能恢复、主挂了能不能切换,光看架构图不算数,只有定期真跑一遍才知道。本节把容灾做成可重复的动作:一份故障注入清单与演练剧本、一套通过判据,再加事故复盘的时间线重建与改进项闭环跟踪,全部给出可跑的 Go 实现并真跑断言,含 TTD、MaxGap 与闭环率。

前两节把备份恢复和故障切换都做出来了,但它们有一个共同的弱点:没被真跑过之前,谁也不知道它们能不能用。一份从没恢复过的备份、一个从没切换过的主备,和没有是一回事——区别只在于出事故时你会更意外。容灾能力的唯一证明方式,是定期把它拉出来跑一遍。

演练解决的是「能力是否成立」,复盘解决的是「这次为什么没兜住」。两者是一对:演练是考试,复盘是错题本。考过的题下个月可能因为一次上线又变难了,所以考试要定期;错题不订正就会重复错,所以复盘要闭环。

本节把这两件事都做成可执行的代码。演练侧给出故障注入清单、剧本结构与通过判据的判定;复盘侧给出时间线重建与改进项跟踪。所有代码都在 internal/dr/ 包下,go test 可以直接跑,断言会打印判据结果、时间线四个量与闭环率。

8.3.1 演练设计:注入清单、剧本、观察指标、通过判据

一场有价值的演练不是「把服务打挂看能不能起来」,而是有清单、有剧本、有判据的一次受控实验。四个组成部分:

故障注入清单——列出要注入哪些故障。关键是标注爆炸半径与可逆性,并据此排出「先轻后重」的执行序,避免一上来就把集群打挂。

注入手段典型对象爆炸半径能否自动恢复
kill_process备库只读进程单节点是
slow_disk数据盘 IO单节点是
network_split网关到主库跨区是
clock_skew节点时钟集群一致性是
disk_full主库磁盘全服写否
dependency_down配置中心全集群否

演练剧本——每一步写清动作、预期可观测结果、期望在多久内发生。观察指标——演练期间要盯什么。通过判据——每条判据是一个比较式,例如「切换耗时 ≤ 30s」「数据丢失行数 == 0」。

四者缺一不可:没有清单就不知道要测什么,没有剧本就没有统一的动作,没有观察指标就看不到过程,没有判据就无法判断「到底算不算通过」。

判据的判定有一个容易放水的点:缺失观测必须算失败。一个没被观测到的容灾指标,等于不知道它能不能兜住。下面这段是 L1,go test 可以直接跑:

// snippet: internal/dr/drill.go —— 判据判定:没测到 = 没达标
func EvaluateDrill(s DrillScript, obs []Observation) DrillResult {
	vals := map[string]float64{}
	for _, o := range obs {
		vals[o.Metric] = o.Value
	}
	res := DrillResult{ScriptName: s.Name}
	for _, c := range s.Criteria {
		v, ok := vals[c.Metric]
		if !ok {
			res.Missing = append(res.Missing, c.Metric) // 没测到
			res.Failed = append(res.Failed, c.ID)        // 也算失败
			continue
		}
		res.Checked++
		if !compare(v, c.Op, c.Threshold) {
			res.Failed = append(res.Failed, c.ID)
		}
	}
	res.Passed = len(res.Failed) == 0
	return res
}

SortedInjections 则按爆炸半径与可恢复性给注入项打分排序,让演练从低风险项开始。真跑的断言验证了排序结果:可逆且可自动恢复的 kill_process 排最前,不可逆的 disk_full 排最后。

观察指标要能反映「玩家视角」。 光看「进程有没有起来」不够,要同时盯服务端指标(连接数、P99 延迟、错误率)与业务指标(登录成功率、交易失败数、掉线率)。演练当天安排一个「观察员」专职读这些数字,而不是让操作员一边注入一边看盘——一个人同时做两件事,两件都会做砸。

频率与规模。 全量演练(含异地切换、全链路)每季度一次;单点演练(杀进程、慢盘、网络分区)每月一次;任何一次重大架构变更之后,追加一次针对性的专项演练。频率太高会疲劳、流于形式,太低则能力会随代码腐化悄悄退化。

8.3.2 事故复盘:时间线、根因、改进项

事故发生时是乱的,事后回看却必须清晰。复盘的第一步是把散落在聊天记录、告警系统、值班日志里的事件按时间排序,重建一条时间线。下面这段是 L1,go test 可以直接跑:

// file: internal/dr/postmortem.go
// tier: L1
// verify: go test ./internal/dr/ -run TestPostmortem -v
package dr

import (
	"errors"
	"sort"
	"time"
)

// EventKind 是事故时间线里一个事件的性质。
type EventKind string

const (
	KindAlert     EventKind = "alert"     // 告警发出
	KindDetected  EventKind = "detected"  // 人确认「出事了」
	KindDeclared  EventKind = "declared"  // 宣布事故、拉起应急
	KindMitigated EventKind = "mitigated" // 止血:影响不再扩大
	KindRecovered EventKind = "recovered" // 完全恢复
)

// IncidentEvent 是时间线上的一条记录。
type IncidentEvent struct {
	At    time.Time
	Kind  EventKind
	Actor string
	Note  string
}

// Timeline 是重建后的时间线。
type Timeline struct {
	Events []IncidentEvent
	// TTD 是「发现时间」:告警到人确认。
	TTD time.Duration
	// TTM 是「止血时间」:确认到影响不再扩大。
	TTM time.Duration
	// TTR 是「恢复时间」:告警到完全恢复。
	TTR time.Duration
	// MaxGap 是相邻事件之间的最大空档;复盘最该盯的就是它。
	MaxGap        time.Duration
	MaxGapBetween [2]time.Time
}

// ReconstructTimeline 把散落的事件按时间排序,重建关键时长与最大空档。
//
// 复盘时最容易被忽略的是 MaxGap:它标出「这段时间里没人推进」,
// 往往正是根因之外、真正拉长故障的那一段。
func ReconstructTimeline(events []IncidentEvent) (Timeline, error) {
	if len(events) == 0 {
		return Timeline{}, errors.New("dr: 时间线为空")
	}
	evs := append([]IncidentEvent(nil), events...)
	sort.Slice(evs, func(i, j int) bool { return evs[i].At.Before(evs[j].At) })
	tl := Timeline{Events: evs}

	for i := 1; i < len(evs); i++ {
		gap := evs[i].At.Sub(evs[i-1].At)
		if gap > tl.MaxGap {
			tl.MaxGap = gap
			tl.MaxGapBetween = [2]time.Time{evs[i-1].At, evs[i].At}
		}
	}
	first := func(k EventKind) (time.Time, bool) {
		for _, e := range evs {
			if e.Kind == k {
				return e.At, true
			}
		}
		return time.Time{}, false
	}
	alertAt, ok := first(KindAlert)
	if !ok {
		return tl, errors.New("dr: 时间线缺少告警事件,无法算 TTD/TTR")
	}
	if detectAt, ok := first(KindDetected); ok {
		tl.TTD = detectAt.Sub(alertAt)
		if mitigateAt, ok := first(KindMitigated); ok {
			tl.TTM = mitigateAt.Sub(detectAt)
		}
	}
	if recoverAt, ok := first(KindRecovered); ok {
		tl.TTR = recoverAt.Sub(alertAt)
	}
	return tl, nil
}

它重建出四个关键量:TTD(发现时间,告警到人确认)、TTM(止血时间,确认到影响不再扩大)、TTR(恢复时间,告警到完全恢复),以及 MaxGap(相邻事件的最大空档)。前三个是常规的 SLI;MaxGap 才是复盘里最该盯的——它标出「这段时间里没人推进」,往往正是根因之外、真正把故障拉长的那一段。真跑的用例里,止血在 6 分钟、恢复在 20 分钟,MaxGap 恰好是中间那 14 分钟的空窗。

时间线给出「发生了什么」,根因回答「为什么」。两者分开写:时间线是事实,根因是判断。根因不写「某人操作失误」——那只是触发条件,要问的是「为什么一次误操作能造成这么大影响」,答案通常落在缺少护栏、缺少灰度、缺少审批上。

复盘还要写清影响面:受影响玩家数、时长、损失的可量化值。影响面决定这次事故定级,也决定改进项排期的优先级——影响小的复盘可以慢一点,影响大的必须在下一轮迭代里落地。

一个常见的反模式是「只写不改」。复盘的产出如果不落到具体的改进项、责任人和截止时间,它就只是一篇回忆录。下一小节的跟踪器,就是把这篇回忆录变成一张可勾选的清单。

8.3.3 改进项跟踪与闭环

复盘最容易烂尾的地方是改进项:会开完、文档写完,条目挂着没人跟。下面这段是 L1,把改进项的闭环规则写成代码:

// snippet: internal/dr/improvement.go —— 闭环必须有证据,否则拒绝关闭
func (t *Tracker) Close(id, evidence string, verified bool, now time.Time) error {
	it, ok := t.items[id]
	if !ok {
		return fmt.Errorf("dr: 未知改进项 %s", id)
	}
	if evidence == "" {
		return fmt.Errorf("dr: 改进项 %s 缺少闭环证据", id)
	}
	it.Evidence = evidence
	it.ClosedAt = now
	if verified {
		it.Status = StatusVerified // 经演练或线上验证
	} else {
		it.Status = StatusDone // 已做但未验证
	}
	return nil
}

// Overdue 返回已过期但尚未闭环的改进项 ID。
func (t *Tracker) Overdue(now time.Time) []string {
	var out []string
	for _, id := range t.order {
		it := t.items[id]
		if (it.Status == StatusOpen || it.Status == StatusInProgress) && now.After(it.DueAt) {
			out = append(out, id)
		}
	}
	sort.Strings(out)
	return out
}

两个设计点:Close 强制要求证据(PR、工单、演练记录),没有证据的「已修复」会被拒绝——这是复盘闭环里最常见的假闭合;Overdue 把过期未闭环的条目单独拎出来,让它们出现在每周的运营会上而不是烂在文档里。

闭环率是运营侧最该长期盯的一个数:它衡量的是「复盘有没有真正改变系统」。如果一条线的闭环率长期停在低位,说明改进项在排队、没人推进,下一次同类事故几乎是必然的。真跑的断言结果:

=== RUN   TestDrillEvaluatePass
    drill_test.go:50: 判据全过,注入执行序=F1,F3,F2
--- PASS: TestDrillEvaluatePass (0.00s)
=== RUN   TestPostmortemTimelineAndTracker
    drill_test.go:94: TTD=2m0s TTM=4m0s TTR=20m0s 最大空档=14m0s
    drill_test.go:114: 改进项 3 条,已验证闭环 1 条,过期=[A3],闭环率=0.33
--- PASS: TestPostmortemTimelineAndTracker (0.00s)
PASS
ok  	aegis/internal/dr	0.866s

第二行把时间线的四个量一次算了出来;第三行说明三条改进项里只有一条带证据闭环、A3 已过期、闭环率 0.33——这些数字比「复盘已完成」这句话有用得多。

本节小结

  1. 演练是容灾能力的唯一证明。 有清单、有剧本、有判据的受控实验才算演练;判据缺失观测一律算失败,因为没被观测到的容灾指标等于不存在。
  2. 时间线是事实,根因是判断,两者分开写。 TTD / TTM / TTR 是常规 SLI,MaxGap 标出「没人推进」的空窗,往往才是真正拉长故障的那一段。
  3. 改进项必须带证据闭环。 没有证据的「已修复」是假闭合;把过期项单独拎出来,才能让复盘真正改变下一次故障的走向。

到这里,容灾与演练这一章收束了:备份能恢复、多活能切换、演练能重复、复盘能闭环。下一章进入全书的综合项目,把前三卷做出来的网络内核、玩法系统、分布式集群,加上这一卷的工程化能力,装配成一个可交付的《晨曦大陆》。

阅读导航:上一节:8.2 多活与故障切换 · 下一节:9.1 《晨曦大陆》玩法装配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标