备份的成功标准是“能回来”
《泡泡工坊》上线第二个月,运营在一次数据修复中误执行了删除条件,支付订单表少了当天的一批记录。备份任务显示每小时成功,但真到恢复时,DBA 发现最近三份全量备份都来自同一个只读副本,而那个副本的主从同步在一周前就断了。备份文件存在、大小正常、校验和也能对上,唯独内容是旧的。
这件事的教训不是“要多做备份”,而是:备份任务的“成功”只证明文件写出来了,不证明数据能回来。 能回来的判据是恢复演练:在隔离环境里真的恢复一次,用业务口径校验,并记录耗时。
《群岛纪元》的第二次事故更接近游戏特有的问题。赛季结算后,世界地图状态需要回档到结算前一刻,但备份里只有每天凌晨的全量快照,事件日志又因为分区切换丢了两个小时。结果是玩家的城市等级回来了,联盟占领关系却对不上。对 SLG 来说,世界状态不是“可选备份”,它的恢复粒度直接决定回档是否可解释。
这一节讨论:备份对象怎么分级,RPO 由什么决定,恢复演练恢复到哪里、比什么,以及如何用可运行的夹具把“计划评审”和“恢复验收”变成可重复的检查。
按后果分级,而不是按数据量
备份计划最常见的错误是按库的大小排序:最大的库最先做,最小的库顺手做。正确的排序依据是丢失后的后果,而不是存储成本。
| 分级 | 内容 | 丢失后果 | 建议目标 RPO(示例) | 是否可缺席 |
|---|---|---|---|---|
| 账号与支付 | 账号、登录票据、订单、渠道流水 | 无法登录、资损、合规问题 | 1 分钟以内 | 不可 |
| 玩家资产 | 背包、货币、抽卡记录、邮件附件 | 回档即资损与舆情 | 5–15 分钟 | 不可 |
| 世界状态 | 房间、地图、联盟、赛季进度 | 回档后关系错乱 | 5–60 分钟,看品类 | 视品类 |
| 日志 | 对局日志、审计日志、风控事件 | 无法追溯、合规缺口 | 按合规要求 | 按合规 |
分级的直接作用是决定预算。账号与支付值得为秒级日志投递付费;玩家资产可以用分钟级增量;世界状态要看品类——《裂隙战场》的房间只活十几分钟,房间状态丢了重开一局即可;《群岛纪元》的世界状态活几个月,丢了没法“重开一局”。
日志容易被当成“反正都存着”的附属品。但日志的恢复目标不是“还原业务”,而是“还原事实”。如果日志和对局数据在同一套备份里、同一个故障域里,那么一次机房级故障会同时带走业务和证据。日志至少要有独立的保留路径与保留期;涉及跨境的日志还要先确认数据驻留要求,参见数据驻留与跨境 。
RPO 由最短的恢复路径决定
RPO 是“最多能接受丢多久的数据”,它不由“备份做了多少次”决定,而由从最后一次可用副本到故障时刻之间,还有没有别的恢复路径决定。
| 机制 | 能覆盖的窗口 | 代价 | 典型用途 |
|---|---|---|---|
| 全量备份 | 上一次全量至今 | 存储与备份窗口最大 | 兜底基线 |
| 增量备份 | 上一次增量至今 | 恢复要回放链条 | 玩家资产 |
| 日志/变更流 | 最后一次落盘至今 | 持续写入与保留成本 | 账号、支付、世界状态 |
所以一个对象的有效 RPO 是三者中最短的那个:有日志投递时看日志落盘间隔,没有日志时看增量间隔,连增量都没有才看全量间隔。把这条规则写进评审工具,比写在文档里可靠——文档会过期,代码不会。
RTO 是另一个维度:RPO 决定“丢多少”,RTO 决定“停多久”。恢复一份 2 TB 的全量备份可能需要几小时,而业务只允许停 30 分钟,这时真正的答案不是“买更快的磁盘”,而是“把恢复拆成两层”:先恢复登录与资产让玩家进来,再在后台恢复世界状态。恢复顺序是架构决策,第 8.3 节会展开。
还有一个常被忽略的约束:备份窗口会和生产争抢 I/O。全量备份放在凌晨并不等于没有影响,SLG 的离线推进和定时结算也常在凌晨跑。备份任务要能被限速,也要能因为生产压力让路;一个把数据库拖垮的备份任务,本身就是故障源。
备份必须自证:清单、校验和、抽样
要让“备份成功”变成可审计的事实,至少需要三样东西:
- 清单(manifest):这次备份包含哪些对象、各自的起始与结束位点、行数、大小、创建时间。
- 校验和:文件级校验和只能证明“没被篡改或截断”,不能证明“内容正确”;它必须和清单里的行数、位点一起看。
- 抽样键:从业务主键里确定性地抽一批记录,恢复后逐条比对。抽样必须可复现,否则下次演练抽到另一批,问题就被掩盖了。
抽样不要用 ORDER BY random()。它每次结果不同,无法回答“上次那批数据这次还对吗”。用“主键 + 固定盐”的哈希排序取前 N 条,盐写进演练报告;同样的数据、同样的盐,永远得到同一批样本。
抽样比例要与数据分级匹配:支付订单可以全量比对金额与状态;玩家资产抽千分之一到百分之一;世界状态抽关键实体(联盟、首都、赛季排名)而不是随机行。抽样的目标不是覆盖率,而是用最小代价证伪。
恢复演练:恢复到哪里、比什么
恢复演练最常见的失败不是恢复不出来,而是“恢复出来了,但没人敢说它对”。所以演练要先定义验收项:
| 验收项 | 比什么 | 不通过说明 |
|---|---|---|
| 结构 | 表数量、行数、索引 | 备份不完整或恢复脚本漏表 |
| 内容 | 校验和、关键字段聚合 | 文件损坏或编码/时区问题 |
| 业务 | 抽样记录逐条比对 | 备份位点与业务预期不一致 |
| 时间 | 从开始恢复到可服务 | RTO 不达标,或恢复步骤不可并行 |
| 隔离 | 恢复环境不连生产 | 演练本身可能污染生产 |
恢复目标环境必须是隔离的。把备份恢复到生产库的另一个 schema、再让业务连过去,是事故复发的常见路径:连接串、缓存、消息队列都可能把“演练”变成“二次故障”。
演练频率也要分级:账号与支付每季度至少一次全链路恢复;玩家资产每半年一次;世界状态按赛季开始前做一次。频率不是拍脑袋,它对应“如果现在坏了,我们上一次验证这套流程是什么时候”。
演练报告要能回答三个问题:恢复到哪个备份、丢了多长时间的数据、用了多久恢复。只写“演练成功”的报告没有价值,因为它无法在下次事故时被引用。
保留期决定你能回到多久以前
保留期经常被当成纯成本项:多留一天就多一天存储费,于是能砍就砍。但它同时决定了“最坏情况下你能回到多久以前”。如果一次逻辑损坏在两周后才被发现,而备份只保留七天,那么备份再完整也救不了。
保留期要按“发现时间”而不是“故障时间”设计:
| 数据 | 典型发现延迟 | 建议保留(示例) | 依据 |
|---|---|---|---|
| 支付订单 | 小时到天 | 180 天以上 | 渠道退款期与审计要求 |
| 玩家资产 | 天到周 | 30–90 天 | 客诉追溯与回档窗口 |
| 世界状态 | 赛季内 | 覆盖整个赛季 | 赛季结算需要回看 |
| 对局日志 | 周到月 | 按合规与反作弊要求 | 证据链完整性 |
保留期还要和“删除”对齐。很多团队只定义了保留多久,没定义到期怎么删、谁能删、删之前要不要归档。结果是要么不敢删、存储无限增长,要么误删了还在审计期内的数据。删除策略应该和备份策略写在同一个文档里,并由同一套权限控制。
另一个成本项是恢复环境。恢复演练需要一套隔离的数据库与对象存储,规模可以比生产小,但不能“借用生产备机”。把演练环境做成按需创建、用完销毁,比长期养一套闲置集群更划算;前提是创建过程本身被演练过,而不是事故当天第一次执行。
复盘:演练报告要留下什么
一次演练的价值,取决于它留下了多少可复用的信息。报告至少包含:
- 备份标识与清单摘要:从哪个位点恢复,包含哪些对象。
- 恢复目标与耗时:每个阶段的开始与结束时间,瓶颈在哪一步。
- 校验结果:结构、内容、业务抽样分别通过还是失败,失败项的具体记录。
- 数据损失:恢复点与故障点之间的时间差,以及已知丢失的业务范围。
- 阻塞点:哪些步骤需要人工、哪些脚本报错、哪些权限缺失。
- 改进项与负责人:每条改进要有截止时间,否则下次演练还会遇到同一个阻塞。
报告里最有用的是第 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 主备、双活与故障切换 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。