重写企业级 PostgreSQL 备份:pgSafe 如何把复杂性放回正确的位置

2026-09-10 28 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:18 分钟

企业级 PostgreSQL 备份真正难的地方,从来不是“把文件复制到另一个目录”,而是保证复制过程中的顺序、WAL 持久性、恢复边界和故障行为都正确。PGDay UK 2026 的分享《100,000 Lines of C Later: Re-architecting Enterprise Postgres Backups in Go》以 pgSafe 为例,讨论了如何从头用 Go 重建一套 PostgreSQL 备份工具,而不是把旧实现机械地移植到新语言。

pgSafe 目前仍处于 alpha 阶段,不能直接承担生产备份。它的价值更像是一场架构实验:哪些代码是备份逻辑,哪些只是早期 C 项目不得不自己实现的基础设施?当语言标准库、云 SDK 和 PostgreSQL 原生能力都成熟之后,重写是否可能比维护旧代码更容易?

备份工具的核心不是复制,而是 WAL 括号

一个 PostgreSQL 物理备份大致要完成以下动作:

  1. 告诉 PostgreSQL 备份即将开始。
  2. 复制数据目录中的文件。
  3. 告诉 PostgreSQL 备份结束。
  4. 确认这段时间需要的 WAL 已经安全归档。
  5. 记录文件清单和校验和。
  6. 在未来某个时间点把数据恢复出来。

看起来只是几个步骤,但顺序决定了备份是否有效。备份期间,PostgreSQL 仍会修改数据文件,因此单独复制出来的文件并不一定处于一致状态。真正修复这些中间状态的是从备份开始 LSN 到结束 LSN 之间的 WAL 重放。

可以把它抽象成一个必须完整闭合的括号:

pg_backup_start()
    复制数据文件
pg_backup_stop()
    等待所需 WAL 完成归档
    原子写入 manifest

如果缺少这段 WAL,备份目录即使包含了全部数据文件,也可能无法恢复。换句话说,复制只是数据搬运,WAL 才是把不一致文件修复成可用数据库的机制。

pgSafe 设计了三种 WAL 获取方式:

  • archive:通过 archive_command 归档 WAL,默认可以获得完整的时间点恢复能力。
  • stream:类似 pg_basebackup --wal-method=fetch,把所需 WAL 随备份一起带走。
  • walgrab:备份结束后由 worker 从 $PGDATA/pg_wal 读取相关 WAL。

恢复时,工具可以优先使用备份内部的 pg_wal;如果备份来自归档模式,则先放置括号期间的 WAL,再通过 restore_command 按需拉取后续 WAL,并处理 timeline 切换。

把不变量写成发布门槛

pgSafe 的一个重要做法,是不把关键规则藏在实现细节里,而是为每条规则命名,并为它配套测试。以下规则尤其值得任何备份系统借鉴:

manifest 必须最后写入

文件需要先写入临时位置,完成 fsync,再发布文件名。备份结束后,要先调用 pg_backup_stop(),等待所有必需 WAL 进入归档,最后再原子写入 manifest。

manifest 的存在代表“这个备份已经完成且有效”。它不能在复制尚未结束时提前出现。

从校验和恢复,而不是从时间戳恢复

文件的内容可能在同一个 mtime 时间粒度内发生变化。只比较修改时间,可能让恢复过程错误地复用一个内容已经变化的文件,最终得到静默损坏的备份。

恢复未完成的备份时,应该重新计算已上传对象的校验和。相同内容可以复用,校验和不同则删除并重新上传。

不能删除正在依赖的备份

增量备份依赖父备份。如果一个正在运行的备份还需要某个父备份,retention 清理任务不能把它删除。清理逻辑需要理解备份链,而不是只按目录时间排序。

WAL 重复上传必须校验内容

崩溃恢复期间,PostgreSQL 可能重新生成同一个 WAL 段。再次上传同名对象时:

  • 字节完全相同:视为幂等操作。
  • 字节不同:直接报错,不能静默覆盖。

对象存储没有 POSIX 文件系统的 rename 语义,因此需要使用条件写入或“不覆盖已有对象”的机制模拟原子发布。

备库备份必须确认它仍在追主

一台已经断开连接的 standby 仍然可能响应查询,但它提供的备份内容可能远比调用者认为的时间点旧。备份前应检查恢复状态、接收位置和重放状态,不能只因为连接成功就认为备库适合备份。

多存储后端的完成条件必须明确

当一个备份同时写入多个后端时,可以把“至少一个后端成功提交”定义为完成,也可以要求所有后端都成功。无论采用哪种策略,都必须把语义写进文档、状态文件和测试,不能让调用者猜测。

故障注入:不要相信退出码

持久化问题往往无法通过正常路径测试出来。pgSafe 为写临时文件、文件 fsync、重命名、对象发布等步骤设置命名注入点,然后在每个步骤强制失败或模拟进程崩溃,检查磁盘和存储上最终留下了什么。

这种测试关注的不是程序是否返回了非零退出码,而是:

  • 是否留下了一个看起来完成、实际不完整的 manifest?
  • 重启后能否安全恢复或清理?
  • 是否会复用校验和不一致的对象?
  • 是否会删除另一个运行中备份依赖的父备份?

一个可以改造到现有备份流程中的最小检查示例如下。它不是 pgSafe 的完整命令,而是展示如何在恢复前把“manifest 存在”和“PostgreSQL 原生校验”设为硬门槛:

#!/usr/bin/env bash
set -Eeuo pipefail

BACKUP_DIR="${1:?usage: $0 /path/to/backup}"

if [[ ! -f "$BACKUP_DIR/backup_manifest" ]]; then
  echo "ERROR: backup_manifest is missing; backup is not considered complete" >&2
  exit 1
fi

# pg_verifybackup 由 PostgreSQL 提供。请使用与备份兼容的客户端版本。
pg_verifybackup "$BACKUP_DIR"

echo "Backup passed manifest and checksum verification: $BACKUP_DIR"

运行前只需要把备份目录作为参数传入,并确认 pg_verifybackup 已安装:

chmod +x verify-backup.sh
./verify-backup.sh /srv/postgres/backups/base-2026-04-20

这个检查不能替代真正的恢复演练。一个从未成功启动过的备份,只能称为“尚未验证的备份”。

三个架构选择:隔离、复用、最小权限

PostgreSQL 不应该知道备份工具的存在

pgSafe 的设计目标是不使用 PostgreSQL 扩展、共享库或后端 hook。备份工具通过标准 SQL 函数获取集群身份和检查点信息,而不是直接解析内部 pg_control

WAL 归档使用 archive_command,而不是运行在数据库后端进程内的 archive_library。这样做的代价是每个 16 MB WAL 段增加大约 1 到 2 毫秒的开销,但换来了一个重要边界:备份工具崩溃或阻塞时,不应该把数据库本身拖垮。

标准能力优先

Go 标准库已经提供 HTTP、TLS、JSON、加密原语、并发协调和跨平台构建能力。云存储则尽量使用供应商 SDK,而不是在备份工具中重新实现 S3 签名、Azure Shared Key 或 GCS JWT 流程。

PostgreSQL 本身也提供了不少可复用能力:

  • backup_manifest 作为原生文件清单格式。
  • pg_verifybackup 作为备份校验工具。
  • PostgreSQL 17 的 WAL summarizer 支持增量备份所需的信息。
  • pg_combinebackup 用于恢复增量备份链。

少写一层代码,就少维护一层协议、错误处理和安全边界。

不要把长期凭据放在数据库主机

数据库主机通常是高价值、也更容易被重点攻击的机器。pgSafe 的思路是由调用者为每次备份申请短时、窄权限凭据,再通过内存中的 worker 通道传递,不写入磁盘:

  • S3:限制到单个前缀、只允许写对象的临时角色。
  • Azure Blob:仅允许写入和创建、强制 HTTPS 的 SAS。
  • GCS:有效期约一小时的 impersonated token。

archive_command 是一个现实中的例外,因为它运行在数据库主机上,仍然需要读取归档凭据。这个边界需要在部署审计中单独处理。

三种部署形态,不靠 --mode 猜测

pgSafe 把运行模式视为部署拓扑,而不是每次命令行调用时手工指定的模式:

  • simple:通过一个复制连接获取 tar 流。
  • remote-parallel:通过多个连接调用 pg_read_binary_file(),并行读取文件;读取过程还可以在客户端验证页面校验和。
  • pgSafe worker:数据库主机上的 worker 直接读取 $PGDATA,多个 goroutine 并行读取后直接写入存储,数据不会经由调用者或 libpq 再转发一次。

如果配置了可通过 SSH 访问的 pg.host,就可以采用 worker 模式;如果没有数据库主机但配置了多个 worker,则可以采用远程并行模式;其他情况回退到 simple 模式。

这种设计的好处是调用者不需要记住一组互相冲突的 --mode 参数。每次运行输出解析后的拓扑,用自然语言说明数据经过哪些进程、连接和存储后端,便于排查性能和权限问题。

Go 减少的是语言负担,不是业务难度

旧的 C 实现中,有相当一部分代码并不直接处理 PostgreSQL 备份,而是在补齐当时语言和生态缺少的基础设施,例如 HTTP、TLS、云存储签名、JSON、容器类型、配置代码生成、内存管理和自定义进程协议。

Go 的标准库、垃圾回收器、泛型、errgroup、竞态检测器和静态编译能力,可以让这些部分显著缩小。并行文件复制甚至可以保持非常小的结构:

package copywork

import (
    "context"

    "golang.org/x/sync/errgroup"
)

type File struct {
    Path string
}

func CopyAll(ctx context.Context, files []File, workers int) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(workers)

    for _, file := range files {
        file := file // 为 goroutine 固定当前循环变量
        g.Go(func() error {
            return copyOne(ctx, file)
        })
    }
    return g.Wait()
}

func copyOne(ctx context.Context, file File) error {
    // 实际项目中应在这里实现:读取、校验和、加密、上传、重试和取消检查。
    return nil
}

这段代码本身并没有解决备份问题。它只解决了并发任务协调。真正不可压缩的部分仍然存在:WAL 括号、时间线切换、恢复协议、凭据权限、跨存储后端的原子发布和恢复测试。语言可以减少基础设施代码,却不能消除外部系统之间的契约。

重写前,先回答四个问题

pgSafe 目前大约有 18,000 行 Go 代码和 15,000 行测试,而旧工具规模更大。这个数字并不意味着 Go 自动让备份工具变简单,因为 pgSafe 更年轻、功能更少,也没有经历十年生产环境边界案例的考验。

考虑重写类似基础设施前,可以先问:

  1. 旧代码中有多少只是早期语言和平台缺失造成的基础设施?
  2. 操作系统、标准库和云 SDK 是否已经吸收了这些复杂性?
  3. 能否在不阅读每一行实现的情况下写出系统不变量?
  4. 测试套件是否比具体代码更有价值,并且能被迁移为新实现的约束?

如果这些问题大多能得到肯定答案,重写可能比想象中小。但如果连系统规则都无法描述,就很容易在“重写”过程中重新制造十年的旧 bug。

AI 编程助手可以减少输入代码的时间,却不能替架构师定义不变量。每一条生成的代码都必须接受设计约束、代码审查以及单元、集成和端到端测试的检验。责任仍然属于做决定的人。

采用建议:先把恢复链跑通

pgSafe 当前明确不适合生产使用,仍缺少一些功能,包括异步和并行 WAL 归档、delta restore、文件 bundling,以及部分增量备份能力。WAL 归档的静态加密和压缩也仍是需要优先解决的事项。

如果你正在设计或评估备份工具,可以先执行这份检查清单:

  • [ ] 能否清楚画出 pg_backup_start()pg_backup_stop() 的 WAL 边界?
  • [ ] manifest 是否最后写入,并且具备原子发布语义?
  • [ ] 恢复是否基于校验和,而不是 mtime?
  • [ ] WAL 重复上传时是否区分“相同字节”和“内容冲突”?
  • [ ] retention 是否理解正在运行的备份和父备份依赖?
  • [ ] 是否测试过 standby 断连、timeline 切换和归档延迟?
  • [ ] 云凭据是否短时、窄权限,并尽量不落在数据库主机?
  • [ ] 是否用故障注入测试了 fsync、rename 和对象发布失败?
  • [ ] 是否在 PostgreSQL 13 到 18 等目标版本上做集成测试?
  • [ ] 是否定期执行真实恢复,而不只是检查命令退出码?

备份软件的可维护性很重要,但可维护性不能以牺牲恢复正确性为代价。pgSafe 最值得借鉴的地方,不是“用 Go 重写 C”,而是把顺序、持久性、权限和恢复行为写成明确的不变量,再让实现和测试共同服从这些规则。


相关推荐