《游戏服务端架构·规模化、全球化与成本》8.1 备份、恢复与演练

《泡泡工坊》的支付订单曾被误删,《群岛纪元》的世界状态在赛季末回档过一次。本节按账号支付、玩家资产、世界状态、日志四级划分备份对象,推导 RPO 与备份频率的关系,给出恢复演练的校验项与确定性抽样方法,并用可运行的 Go 夹具演示计划评审、恢复报告判定与抽样复现。

备份的成功标准是“能回来”

《泡泡工坊》上线第二个月,运营在一次数据修复中误执行了删除条件,支付订单表少了当天的一批记录。备份任务显示每小时成功,但真到恢复时,DBA 发现最近三份全量备份都来自同一个只读副本,而那个副本的主从同步在一周前就断了。备份文件存在、大小正常、校验和也能对上,唯独内容是旧的。

这件事的教训不是“要多做备份”,而是:备份任务的“成功”只证明文件写出来了,不证明数据能回来。 能回来的判据是恢复演练:在隔离环境里真的恢复一次,用业务口径校验,并记录耗时。

《群岛纪元》的第二次事故更接近游戏特有的问题。赛季结算后,世界地图状态需要回档到结算前一刻,但备份里只有每天凌晨的全量快照,事件日志又因为分区切换丢了两个小时。结果是玩家的城市等级回来了,联盟占领关系却对不上。对 SLG 来说,世界状态不是“可选备份”,它的恢复粒度直接决定回档是否可解释。

这一节讨论:备份对象怎么分级,RPO 由什么决定,恢复演练恢复到哪里、比什么,以及如何用可运行的夹具把“计划评审”和“恢复验收”变成可重复的检查。

按后果分级,而不是按数据量

备份计划最常见的错误是按库的大小排序:最大的库最先做,最小的库顺手做。正确的排序依据是丢失后的后果,而不是存储成本。

分级内容丢失后果建议目标 RPO(示例)是否可缺席
账号与支付账号、登录票据、订单、渠道流水无法登录、资损、合规问题1 分钟以内不可
玩家资产背包、货币、抽卡记录、邮件附件回档即资损与舆情5–15 分钟不可
世界状态房间、地图、联盟、赛季进度回档后关系错乱5–60 分钟,看品类视品类
日志对局日志、审计日志、风控事件无法追溯、合规缺口按合规要求按合规

分级的直接作用是决定预算。账号与支付值得为秒级日志投递付费;玩家资产可以用分钟级增量;世界状态要看品类——《裂隙战场》的房间只活十几分钟,房间状态丢了重开一局即可;《群岛纪元》的世界状态活几个月,丢了没法“重开一局”。

日志容易被当成“反正都存着”的附属品。但日志的恢复目标不是“还原业务”,而是“还原事实”。如果日志和对局数据在同一套备份里、同一个故障域里,那么一次机房级故障会同时带走业务和证据。日志至少要有独立的保留路径与保留期;涉及跨境的日志还要先确认数据驻留要求,参见数据驻留与跨境 。

RPO 由最短的恢复路径决定

RPO 是“最多能接受丢多久的数据”,它不由“备份做了多少次”决定,而由从最后一次可用副本到故障时刻之间,还有没有别的恢复路径决定。

机制能覆盖的窗口代价典型用途
全量备份上一次全量至今存储与备份窗口最大兜底基线
增量备份上一次增量至今恢复要回放链条玩家资产
日志/变更流最后一次落盘至今持续写入与保留成本账号、支付、世界状态

所以一个对象的有效 RPO 是三者中最短的那个:有日志投递时看日志落盘间隔,没有日志时看增量间隔,连增量都没有才看全量间隔。把这条规则写进评审工具,比写在文档里可靠——文档会过期,代码不会。

RTO 是另一个维度:RPO 决定“丢多少”,RTO 决定“停多久”。恢复一份 2 TB 的全量备份可能需要几小时,而业务只允许停 30 分钟,这时真正的答案不是“买更快的磁盘”,而是“把恢复拆成两层”:先恢复登录与资产让玩家进来,再在后台恢复世界状态。恢复顺序是架构决策,第 8.3 节会展开。

还有一个常被忽略的约束:备份窗口会和生产争抢 I/O。全量备份放在凌晨并不等于没有影响,SLG 的离线推进和定时结算也常在凌晨跑。备份任务要能被限速,也要能因为生产压力让路;一个把数据库拖垮的备份任务,本身就是故障源。

备份必须自证:清单、校验和、抽样

要让“备份成功”变成可审计的事实,至少需要三样东西:

  1. 清单(manifest):这次备份包含哪些对象、各自的起始与结束位点、行数、大小、创建时间。
  2. 校验和:文件级校验和只能证明“没被篡改或截断”,不能证明“内容正确”;它必须和清单里的行数、位点一起看。
  3. 抽样键:从业务主键里确定性地抽一批记录,恢复后逐条比对。抽样必须可复现,否则下次演练抽到另一批,问题就被掩盖了。

抽样不要用 ORDER BY random()。它每次结果不同,无法回答“上次那批数据这次还对吗”。用“主键 + 固定盐”的哈希排序取前 N 条,盐写进演练报告;同样的数据、同样的盐,永远得到同一批样本。

抽样比例要与数据分级匹配:支付订单可以全量比对金额与状态;玩家资产抽千分之一到百分之一;世界状态抽关键实体(联盟、首都、赛季排名)而不是随机行。抽样的目标不是覆盖率,而是用最小代价证伪。

恢复演练:恢复到哪里、比什么

恢复演练最常见的失败不是恢复不出来,而是“恢复出来了,但没人敢说它对”。所以演练要先定义验收项:

验收项比什么不通过说明
结构表数量、行数、索引备份不完整或恢复脚本漏表
内容校验和、关键字段聚合文件损坏或编码/时区问题
业务抽样记录逐条比对备份位点与业务预期不一致
时间从开始恢复到可服务RTO 不达标,或恢复步骤不可并行
隔离恢复环境不连生产演练本身可能污染生产

恢复目标环境必须是隔离的。把备份恢复到生产库的另一个 schema、再让业务连过去,是事故复发的常见路径:连接串、缓存、消息队列都可能把“演练”变成“二次故障”。

演练频率也要分级:账号与支付每季度至少一次全链路恢复;玩家资产每半年一次;世界状态按赛季开始前做一次。频率不是拍脑袋,它对应“如果现在坏了,我们上一次验证这套流程是什么时候”。

演练报告要能回答三个问题:恢复到哪个备份、丢了多长时间的数据、用了多久恢复。只写“演练成功”的报告没有价值,因为它无法在下次事故时被引用。

保留期决定你能回到多久以前

保留期经常被当成纯成本项:多留一天就多一天存储费,于是能砍就砍。但它同时决定了“最坏情况下你能回到多久以前”。如果一次逻辑损坏在两周后才被发现,而备份只保留七天,那么备份再完整也救不了。

保留期要按“发现时间”而不是“故障时间”设计:

数据典型发现延迟建议保留(示例)依据
支付订单小时到天180 天以上渠道退款期与审计要求
玩家资产天到周30–90 天客诉追溯与回档窗口
世界状态赛季内覆盖整个赛季赛季结算需要回看
对局日志周到月按合规与反作弊要求证据链完整性

保留期还要和“删除”对齐。很多团队只定义了保留多久,没定义到期怎么删、谁能删、删之前要不要归档。结果是要么不敢删、存储无限增长,要么误删了还在审计期内的数据。删除策略应该和备份策略写在同一个文档里,并由同一套权限控制。

另一个成本项是恢复环境。恢复演练需要一套隔离的数据库与对象存储,规模可以比生产小,但不能“借用生产备机”。把演练环境做成按需创建、用完销毁,比长期养一套闲置集群更划算;前提是创建过程本身被演练过,而不是事故当天第一次执行。

复盘:演练报告要留下什么

一次演练的价值,取决于它留下了多少可复用的信息。报告至少包含:

  1. 备份标识与清单摘要:从哪个位点恢复,包含哪些对象。
  2. 恢复目标与耗时:每个阶段的开始与结束时间,瓶颈在哪一步。
  3. 校验结果:结构、内容、业务抽样分别通过还是失败,失败项的具体记录。
  4. 数据损失:恢复点与故障点之间的时间差,以及已知丢失的业务范围。
  5. 阻塞点:哪些步骤需要人工、哪些脚本报错、哪些权限缺失。
  6. 改进项与负责人:每条改进要有截止时间,否则下次演练还会遇到同一个阻塞。

报告里最有用的是第 5 项。恢复流程的真正瓶颈往往不是磁盘速度,而是“找不到某台机器的登录方式”“证书过期”“某张表没人知道归谁管”。这些问题的修复成本很低,但只有演练能暴露它们。

演练结束后要清理隔离环境,但不要清理报告。报告应该进入长期存档,并在下一次事故时作为第一手参考。一个能追溯到历次演练记录、恢复耗时趋势和未闭环改进项的团队,才真正拥有恢复能力,而不只是拥有备份文件。

可运行片段:计划评审与恢复验收

internal/backupdrill 把上面的规则落成两个可测试的部分。plan.go 负责评审备份计划:检查必需分级是否覆盖、每个对象的有效 RPO 是否超过目标、保留期与全量间隔是否缺失,以及世界状态是否有增量或日志路径。输出按对象与规则排序,保证同样输入得到同样报告,便于在 CI 里比对。

// file: internal/backupdrill/plan.go
// tier: L1
// verify: go test ./internal/backupdrill/
package backupdrill

import (
	"fmt"
	"sort"
	"time"
)

// Tier 是备份对象的分级,分级决定 RPO 上限,而不是由数据量决定。
type Tier string

const (
	// TierAccount 覆盖账号、登录票据与支付订单,缺失即不可接受。
	TierAccount Tier = "account_payment"
	// TierAsset 覆盖玩家资产与背包,回档即资损。
	TierAsset Tier = "player_asset"
	// TierWorld 覆盖房间与世界状态,能否缺席取决于品类。
	TierWorld Tier = "world_state"
	// TierLog 覆盖审计与对局日志,按合规要求保留。
	TierLog Tier = "log_compliance"
)

// Object 描述一个逻辑备份对象:一张表、一个库或一类事件流。
type Object struct {
	Name          string
	Tier          Tier
	FullInterval  time.Duration
	IncrInterval  time.Duration
	LogShipping   bool
	LogFlush      time.Duration
	RetentionDays int
}

// EffectiveRPO 返回最坏情况下可能丢失的时间窗口。
// 日志投递优先,因为它是唯一能覆盖“两次快照之间”的机制。
func (o Object) EffectiveRPO() time.Duration {
	if o.LogShipping && o.LogFlush > 0 {
		return o.LogFlush
	}
	if o.IncrInterval > 0 && (o.FullInterval == 0 || o.IncrInterval < o.FullInterval) {
		return o.IncrInterval
	}
	return o.FullInterval
}

// Requirement 是目标分级允许的最大 RPO;Must 表示该分级不可缺席。
type Requirement struct {
	Tier   Tier
	MaxRPO time.Duration
	Must   bool
}

// Plan 是待评审的备份计划。
type Plan struct {
	Objects      []Object
	Requirements []Requirement
}

// Finding 是一条评审结论;Code 稳定,便于脚本聚合。
type Finding struct {
	Object string
	Code   string
	Detail string
}

// Validate 检查覆盖、RPO 与保留期。输出排序,保证同样输入得到同样报告。
func (p Plan) Validate() []Finding {
	var out []Finding
	covered := make(map[Tier]bool, len(p.Objects))
	for _, o := range p.Objects {
		covered[o.Tier] = true
		if o.RetentionDays <= 0 {
			out = append(out, Finding{o.Name, "retention_missing", "retention_days must be positive"})
		}
		if o.FullInterval <= 0 {
			out = append(out, Finding{o.Name, "full_interval_missing", "full backup interval must be positive"})
		}
		if o.Tier == TierWorld && o.IncrInterval == 0 && !o.LogShipping {
			out = append(out, Finding{o.Name, "world_no_delta", "world state needs delta or log shipping"})
		}
	}
	for _, r := range p.Requirements {
		if r.Must && !covered[r.Tier] {
			out = append(out, Finding{string(r.Tier), "tier_uncovered", "required tier has no backup object"})
		}
		for _, o := range p.Objects {
			if o.Tier != r.Tier {
				continue
			}
			if rpo := o.EffectiveRPO(); rpo > r.MaxRPO {
				out = append(out, Finding{
					o.Name,
					"rpo_exceeded",
					fmt.Sprintf("rpo=%s exceeds max=%s", rpo, r.MaxRPO),
				})
			}
		}
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Object != out[j].Object {
			return out[i].Object < out[j].Object
		}
		return out[i].Code < out[j].Code
	})
	return out
}

同一包的 drill.go 负责恢复验收:DrillRules.Check 把恢复报告转成结论,校验和、行数漂移、抽样数量、抽样不一致、恢复耗时任何一项不过都算失败;SampleKeys 用加盐 FNV 哈希做确定性抽样,同样的键集合与盐永远得到同样的样本。两段合起来,才是“备份计划可评审、恢复结果可判定”。

本机验证命令与结果:

cd .local/checks/gsarch-lab
gofmt -l internal/backupdrill
go vet ./internal/backupdrill/
go test -count=1 ./internal/backupdrill/

gofmt -l 无输出,go vet 退出码 0,go test 输出 ok gsarchlab/internal/backupdrill,退出码 0。该包有 5 个表驱动测试,覆盖有效 RPO 计算、计划评审的覆盖与超限、恢复报告判定、确定性抽样与边界。

三个反模式

反模式一:只备份,不恢复。 备份文件的存在感很强,恢复能力的存在感很弱,资源自然流向看得见的一侧。解法是把“最近一次成功恢复的时间”做成和“最近一次成功备份的时间”并列的指标。

反模式二:所有数据一个策略。 统一每天全量、保留七天,看起来整齐,实际是给支付数据太长的 RPO、给日志太短的保留期。分级不是为了文档好看,是为了把预算花在后果最重的地方。

反模式三:把副本当备份。 只读副本能加速读请求,但它和主库共享同一套复制链路、同一个故障域、同一批误操作。副本上的“备份”在主库逻辑损坏时会一起损坏。备份必须是独立写入路径、独立保留、独立权限。

小结

备份的对象按丢失后果分级:账号支付与玩家资产不可缺席,世界状态看品类,日志按合规。有效 RPO 由最短的恢复路径决定:有日志看落盘间隔,有增量看增量间隔,都没有才看全量。备份要用清单、校验和与确定性抽样自证;恢复演练要在隔离环境里定义结构、内容、业务、时间四类验收项。计划评审与恢复验收都应该写成可重复运行的检查,而不是一次性的文档。

保留期不是纯成本项,它决定了最坏情况下能回到多久以前;按“发现延迟”而不是“故障时间”设计保留期,才不会出现“备份齐全但救不了两周前的误删”。演练报告的价值在于记录阻塞点与未闭环改进项,而不只是写一句成功。

下一节讨论更激进的形态:主备、双活与故障切换。备份回答“数据还在不在”,切换回答“服务什么时候回来”,两者的边界与代价并不相同。

阅读导航:上一节:7.3 合服、迁移与玩家可见性 · 下一节:8.2 主备、双活与故障切换 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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