《MMO 服务器开发》8.1 备份与恢复

备份不是把数据复制一份,而是保证任何一次误删、损坏或机房故障之后都能把数据恢复到某个一致状态。本节讲清全量与增量的分工、备份周期与保留策略、异地副本的必要性,并在本机 Postgres 上真跑一遍「写数据、备份、破坏、恢复、校验和一致」的完整闭环。

前七章把服务端能量、能看清、能安全变更都补齐了,但它们都有一个共同前提:数据还在。这一章换个方向,假设最坏的情况已经发生——磁盘写坏、误删了一张表、机房整层断电、或者某次上线把玩家背包刷成了负数。

备份这件事最常见的误解,是把它当成「运维的定时任务」。定时任务只解决了「有一份副本」,没解决「这份副本能不能用」。真正要回答的是三个问题:恢复点目标(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,不留残留。

本节小结

  1. 备份的目标是「能恢复到一致状态」,不是「有一份副本」。 RPO 决定备份周期,RTO 决定恢复手段,一致性只有真跑过才知道。
  2. 保留策略必须保住基线链。 全量是基线、增量叠在其上,删掉某份全量会让它之后的增量失去回放能力;SelectRetained 从后往前数够 N 份全量再回带增量,就是为了这条。
  3. 恢复要能自证。 逻辑导出配内容校验和、恢复走单事务、备份读回复算哈希——这三件事把「恢复成功」从一个感觉变成一个可以断言的数字。

下一节把视角从「单点数据」抬到「整个集群」:主节点宕机之后,怎么在几十秒内选出新主、把流量切过去,并且挡住醒来的旧主继续写坏数据。

阅读导航:上一节:目录 · 下一节:8.2 多活与故障切换 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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