企业级 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 物理备份大致要完成以下动作:
- 告诉 PostgreSQL 备份即将开始。
- 复制数据目录中的文件。
- 告诉 PostgreSQL 备份结束。
- 确认这段时间需要的 WAL 已经安全归档。
- 记录文件清单和校验和。
- 在未来某个时间点把数据恢复出来。
看起来只是几个步骤,但顺序决定了备份是否有效。备份期间,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 更年轻、功能更少,也没有经历十年生产环境边界案例的考验。
考虑重写类似基础设施前,可以先问:
- 旧代码中有多少只是早期语言和平台缺失造成的基础设施?
- 操作系统、标准库和云 SDK 是否已经吸收了这些复杂性?
- 能否在不阅读每一行实现的情况下写出系统不变量?
- 测试套件是否比具体代码更有价值,并且能被迁移为新实现的约束?
如果这些问题大多能得到肯定答案,重写可能比想象中小。但如果连系统规则都无法描述,就很容易在“重写”过程中重新制造十年的旧 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”,而是把顺序、持久性、权限和恢复行为写成明确的不变量,再让实现和测试共同服从这些规则。