前七章把服务端能量、能看清、能安全变更都补齐了,但它们都有一个共同前提:数据还在。这一章换个方向,假设最坏的情况已经发生——磁盘写坏、误删了一张表、机房整层断电、或者某次上线把玩家背包刷成了负数。
备份这件事最常见的误解,是把它当成「运维的定时任务」。定时任务只解决了「有一份副本」,没解决「这份副本能不能用」。真正要回答的是三个问题:恢复点目标(RPO)——最多能接受丢多久的数据;恢复时间目标(RTO)——从故障到恢复要多久;以及恢复出来的数据是不是一致的。前两个是策略问题,第三个只有真跑过才知道。
本节先给备份策略的骨架,再用本机的 Postgres 17 真跑一遍「写数据 → 备份 → 破坏 → 恢复 → 校验和一致」的闭环。所有代码都在 internal/dr/ 包下,go test 可以直接复现。
8.1.1 分工:本节与站内既有文章
容灾这条线在站内既有专题里已有三篇文章打底,本节不重复它们,只做「策略 + 可跑实现」的补位。分工如下:
| 既有文章 | 它讲什么 | 本节的分工 |
|---|---|---|
/game-server-disaster-recovery-drill-for-live-games/ | 面向线上游戏的容灾演练怎么组织 | 本节只做数据层,演练组织留到 8.3 |
/game-server-active-active-read-model-architecture/ | 多活下的读模型与数据同步架构 | 本节只讲单点备份恢复,多活切换留到 8.2 |
/game-server-post-incident-review-for-live-games/ | 事故复盘的方法与流程 | 本节不碰复盘,8.3 再展开时间线与改进项 |
一句话:既有文章给的是「为什么」,本节给的是「怎么在 Postgres 上把恢复真跑一遍」。
8.1.2 备份策略:全量 + 增量、周期、保留、异地
先给结论,再解释。一份能兜住事故的备份方案,由四个正交的维度组成:
| 维度 | 选项 | 《晨曦大陆》的选择 |
|---|---|---|
| 备份形式 | 全量 / 增量 | 每日全量 + 每小时增量 |
| 备份周期 | 定时 / 触发 | 全量定时,重大上线前手动触发一次 |
| 保留策略 | 时间窗 / 份数 | 保留 30 天,且至少 7 份全量 |
| 异地 | 同城 / 跨地域 | 每日全量复制到异地对象存储 |
全量是基线,增量叠在其上。 恢复永远是「最近一次全量 + 它之后的所有增量」回放。这意味着保留策略不能只按时间删——如果把某份全量删了,它后面的增量就失去了基线,再也回放不出来。所以保留策略必须保证基线链完整:从后往前数够 N 份全量,保留最早那份全量之后的所有增量。
下面这段是 L1,go test 可以直接跑,它把上面这条规则写成纯函数:
// snippet: internal/dr/policy.go —— 保留链必须保住增量可以回放的基线
func SelectRetained(backups []BackupMeta, now time.Time, p BackupPolicy) []BackupMeta {
// 1. 先按保留窗口过滤掉太老的。
// 2. 从后往前保留 KeepFull 份全量。
// 3. 保留这些全量里最早那份之后的所有增量,保证基线链完整。
fullSeen, cutIdx := 0, 0
for i := len(within) - 1; i >= 0; i-- {
if within[i].Kind == Full {
fullSeen++
if fullSeen == p.KeepFull {
cutIdx = i
break
}
}
}
if fullSeen < p.KeepFull {
cutIdx = 0 // 全量不够 KeepFull 份,全部保留。
}
return within[cutIdx:]
}
异地为什么必须。 同城备份挡得住误删和单机故障,挡不住「整个机房没了」。异地的成本主要在带宽和存储,所以通常只复制全量,增量在同城恢复时补齐。异地副本还要定期验证可读——一个从没被读出来过的异地备份,和没有备份是一回事。
周期怎么定。 备份周期直接决定 RPO。每小时增量意味着最坏丢一小时数据。对《晨曦大陆》这种实时性强的 MMO,一小时是能接受的量级;但如果要做「跨服竞技场积分」这种不可丢的数据,就得缩短增量周期,或者对关键表额外做一次高频逻辑导出。周期不是越短越好——增量越密,恢复时要回放的文件越多,RTO 反而变长。
把四个维度合起来看,不同数据的 RPO / RTO 目标其实差别很大,不该用同一套参数:
| 数据类型 | RPO 目标 | RTO 目标 | 对应手段 |
|---|---|---|---|
| 角色基础数据(等级、背包) | ≤ 1 小时 | ≤ 2 小时 | 每小时增量 + 每日全量 |
| 付费与订单 | ≤ 5 分钟 | ≤ 30 分钟 | 逻辑复制 + 高频导出 |
| 跨服竞技场积分 | 接近 0 | ≤ 15 分钟 | 同步落库 + 单独备份通道 |
| 埋点与分析日志 | ≤ 1 天 | 不敏感 | 每日全量,可降级丢弃 |
介质与加密。 备份不能只落在一块盘上:本地盘负责快速恢复,对象存储负责长期留存和异地。落盘前做静态加密,密钥与备份分离保管——备份里往往含全量玩家数据,泄露的代价比丢数据还高。保留策略的删除动作也要留审计,避免「备份被误删」变成新的故障源。
8.1.3 恢复演练:写 → 备 → 破 → 复 → 验(L2 真连 Postgres)
策略再漂亮,没恢复过就等于没有。下面这段是 L2,真连本机 Postgres 17(127.0.0.1:25432,独立 schema aegis_test_ch08),go test 可以直接跑。它把整条闭环串起来:写 5000 行确定性数据 → 逻辑导出并算校验和 → 故意破坏(删一半、改一批、塞脏数据)→ 从备份恢复 → 重算校验和比对。
// file: internal/dr/backup.go
// tier: L2
// verify: go test ./internal/dr/ -run TestPostgresBackupRestore -v
package dr
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"sort"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// WalletRow 是玩家钱包表的一行,用来演示「逻辑导出 → 恢复 → 一致性校验」。
type WalletRow struct {
PlayerID int64 `json:"player_id"`
Gold int64 `json:"gold"`
Version int64 `json:"version"`
UpdatedAt time.Time `json:"updated_at"`
}
// Snapshot 是一张表的逻辑快照,附带内容校验和。
type Snapshot struct {
Schema string `json:"schema"`
Table string `json:"table"`
Rows []WalletRow `json:"rows"`
Checksum string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
// ChecksumRows 对行内容做稳定校验和(按主键排序后逐行哈希)。
// 恢复后只要重算一遍、与备份时的值相等,就证明「恢复到一致状态」。
func ChecksumRows(rows []WalletRow) string {
sorted := append([]WalletRow(nil), rows...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].PlayerID < sorted[j].PlayerID })
h := sha256.New()
for _, r := range sorted {
fmt.Fprintf(h, "%d|%d|%d|%d\n", r.PlayerID, r.Gold, r.Version, r.UpdatedAt.UnixNano())
}
return hex.EncodeToString(h.Sum(nil))
}
// ExportTable 做一次逻辑导出:把整张表读进内存并算校验和。
func ExportTable(ctx context.Context, pool *pgxpool.Pool, schema, table string) (*Snapshot, error) {
q := fmt.Sprintf("SELECT player_id, gold, version, updated_at FROM %s.%s ORDER BY player_id", schema, table)
rows, err := pool.Query(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []WalletRow
for rows.Next() {
var r WalletRow
if err := rows.Scan(&r.PlayerID, &r.Gold, &r.Version, &r.UpdatedAt); err != nil {
return nil, err
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
return &Snapshot{Schema: schema, Table: table, Rows: out,
Checksum: ChecksumRows(out), CreatedAt: time.Now()}, nil
}
// WriteBackup 把快照落成一份备份文件。
func WriteBackup(path string, snap *Snapshot) error {
b, err := json.Marshal(snap)
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
// ReadBackup 读回备份并复算校验和,用于在恢复前发现备份本身被损坏。
func ReadBackup(path string) (*Snapshot, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var snap Snapshot
if err := json.Unmarshal(b, &snap); err != nil {
return nil, err
}
snap.Checksum = ChecksumRows(snap.Rows)
return &snap, nil
}
// RestoreTable 从快照恢复:先清空,再在一个事务里批量写入。
// 返回恢复后重新导出的校验和,调用方拿它和快照校验和比对。
func RestoreTable(ctx context.Context, pool *pgxpool.Pool, snap *Snapshot) (string, error) {
tx, err := pool.Begin(ctx)
if err != nil {
return "", err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, fmt.Sprintf("TRUNCATE %s.%s", snap.Schema, snap.Table)); err != nil {
return "", err
}
_, err = tx.CopyFrom(ctx,
pgx.Identifier{snap.Schema, snap.Table},
[]string{"player_id", "gold", "version", "updated_at"},
pgx.CopyFromSlice(len(snap.Rows), func(i int) ([]any, error) {
r := snap.Rows[i]
return []any{r.PlayerID, r.Gold, r.Version, r.UpdatedAt}, nil
}),
)
if err != nil {
return "", err
}
if err := tx.Commit(ctx); err != nil {
return "", err
}
after, err := ExportTable(ctx, pool, snap.Schema, snap.Table)
if err != nil {
return "", err
}
return after.Checksum, nil
}
两个设计点值得单独说。第一,恢复走事务:TRUNCATE 和批量写入在同一个事务里,要么全成功要么全回滚,不会出现「表被清空了但数据没写进去」的半成品状态。第二,备份文件读回时复算校验和:ReadBackup 不是无脑信任文件,它重算一遍内容哈希,如果备份文件本身在传输或落盘时损坏了,恢复前就能发现,而不是恢复完才发现数据是错的。
为什么这里用逻辑导出而不是直接 pg_dump?两者各有位置。pg_dump 是物理层工具,快、保真,但它导出的是整个数据库对象,跨大版本、跨部署形态时容易卡在版本兼容上;逻辑导出(SELECT 出行、自己序列化)慢一些,但它可读、可裁剪、可断言——能只导一张表、能在导出时顺手算校验和、能把恢复结果直接和备份比对。演练要的正是「可断言」,所以这里选逻辑导出。生产上通常是 pg_dump 做全量、逻辑导出做关键表的校验与抽样。
真跑的断言结果:
=== RUN TestPostgresBackupRestore
backup_test.go:133: 备份行数=5000 备份校验和=71a8ba0282d0 恢复校验和=71a8ba0282d0 一致
--- PASS: TestPostgresBackupRestore (0.20s)
=== RUN TestSelectRetained
backup_test.go:156: 共 9 份备份,保留 6 份,基线=2026-10-10T20:00:00Z
--- PASS: TestSelectRetained (0.00s)
PASS
ok aegis/internal/dr 0.866s
第一行是恢复演练的核心证据:破坏前和恢复后的校验和逐字相等,说明数据确实回到了一致状态。用例收尾会 DROP SCHEMA aegis_test_ch08 CASCADE,不留残留。
本节小结
- 备份的目标是「能恢复到一致状态」,不是「有一份副本」。 RPO 决定备份周期,RTO 决定恢复手段,一致性只有真跑过才知道。
- 保留策略必须保住基线链。 全量是基线、增量叠在其上,删掉某份全量会让它之后的增量失去回放能力;
SelectRetained从后往前数够 N 份全量再回带增量,就是为了这条。 - 恢复要能自证。 逻辑导出配内容校验和、恢复走单事务、备份读回复算哈希——这三件事把「恢复成功」从一个感觉变成一个可以断言的数字。
下一节把视角从「单点数据」抬到「整个集群」:主节点宕机之后,怎么在几十秒内选出新主、把流量切过去,并且挡住醒来的旧主继续写坏数据。
阅读导航:上一节:目录 · 下一节:8.2 多活与故障切换 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。