在 PostgreSQL 备份工具里,“全量 + 一串增量”几乎是默认想象:上一次备份是下一次备份的父节点,恢复时沿着链条一路重放。pg_hardstorage 选择了另一条路:它没有增量链,而且这是设计目标,不是缺失功能。核心取舍很直接:用更多源端读取 I/O,换一个不容易在凌晨恢复时突然断掉的仓库格式。
增量链的问题不在日常,而在恢复现场
链式增量格式看起来很省:
full A <- incr B <- incr C <- incr D <- diff E
只要每个节点都在,恢复路径清晰,存储成本也低。但它有一个危险性质:链上任何一个备份坏掉、丢失、被过早清理,都会影响它后面的所有备份。
真实事故通常不是工具当场报错,而是静悄悄埋雷:
- S3 lifecycle policy 误删了
incr B,incr C、incr D、diff E都失去意义。 - 后端存储出现 bit flip,某个差异备份损坏,只能恢复到它之前。
- 运维执行
backup expire时保留策略过激,基础全量被删,整条链悬空。 - 基础全量的 manifest schema 在版本升级中变化,迁移脚本又有边界 bug。
这些问题单独看都能处理,真正麻烦的是:很多团队直到恢复时才发现链断了。备份系统最怕的不是“平时看起来不够漂亮”,而是“恢复时才暴露依赖关系”。
pg_hardstorage 用内容寻址替代父子引用
pg_hardstorage 的 manifest 不引用“上一个 manifest”,而是引用内容块的 SHA-256。形式大致像这样:
{
"id": "prod-2026-05-02-0334",
"chunks": [
"7e1f2a...ab",
"9c4d3b...12",
"2faabc...77"
],
"chunks_added": 1288490188
}
每个 chunk 的文件名就是它的 hash。两个备份共享相同数据时,它们共享的是存储池里的同一个 chunk,而不是通过“父备份指针”继承数据。
这带来一个非常实际的删除模型:删除较老备份时,较新备份需要的 chunk 不会因为父节点被删而消失。只有当垃圾回收确认某个 chunk 没有任何 manifest 引用时,它才应该被清理。
这个思路并不新,restic、kopia、borgbackup 都采用类似的内容寻址与去重模型。但在 PostgreSQL 生态里,链式增量太常见,所以 pg_hardstorage 的选择值得单独说明:它把“备份之间的顺序依赖”降到了 chunk 引用集合,而不是一条易碎的链。
存储省了,源端 I/O 还要付账
链式增量的两个优势通常是:
- 数据没变时,不重复写相同字节。
- 数据大部分没变时,不必每次完整读取数据目录。
内容寻址天然解决第一个问题:相同内容的 chunk hash 一样,只写一次。pg_hardstorage manifest 里的 chunks_added 可以直接告诉你这次备份实际向 chunk pool 写入了多少数据。
第二个问题更难。根据来源摘要,pg_hardstorage 当前每次备份都会读取完整 PostgreSQL data dir,然后再和 chunk pool 做去重。对 100 TB 级别、底层存储较慢的数据库来说,这不是免费午餐。项目正在做 snapshot-aware fast path,希望在可用时利用文件系统级 COW snapshot 降低读取成本,但该能力尚未发布。
所以这里的取舍要说清楚:pg_hardstorage 不是魔法般同时消除链依赖和源端扫描成本。它当前更偏向恢复可靠性、仓库可检查性和删除语义清晰度。
来源摘要里给了一组生产级 fleet 的数据:487 GiB 数据规模,按小时备份一周,结果是:
chunks_total: 1,247,883
chunks_unique: 156,423 (12.5%)
dedup_ratio: 87.5%
total_storage: 12.4 TiB (vs raw 89.2 TiB)
chunks_added: 1,200 ± 400 per backup
这说明在该样本里,内容寻址模型的存储效率可以接近链式增量,同时移除了链式依赖。
可以这样实践:用 jq 检查 manifest 的引用和新增量
下面是一个最小可运行示例,用来模拟 pg_hardstorage 这类 manifest 的可检查性。它不调用真实 pg_hardstorage,只演示“manifest 引用 chunk,而不是引用父备份”的仓库思路。
运行前需要本机安装 jq。
mkdir -p /tmp/pg-hardstorage-demo/manifests /tmp/pg-hardstorage-demo/chunks
cd /tmp/pg-hardstorage-demo
cat > manifests/backup-001.json <<'JSON'
{
"id": "backup-001",
"chunks": ["aaa111", "bbb222", "ccc333"],
"chunks_added": 300
}
JSON
cat > manifests/backup-002.json <<'JSON'
{
"id": "backup-002",
"chunks": ["bbb222", "ccc333", "ddd444"],
"chunks_added": 100
}
JSON
touch chunks/aaa111 chunks/bbb222 chunks/ccc333 chunks/ddd444
printf "Backup IDs and bytes added:\n"
jq -r '"\(.id) added=\(.chunks_added)"' manifests/*.json
printf "\nChunk reference counts:\n"
jq -r '.chunks[]' manifests/*.json | sort | uniq -c | sort -nr
printf "\nChunks needed by backup-002:\n"
jq -r '.chunks[]' manifests/backup-002.json
你会看到 backup-002 需要 bbb222、ccc333、ddd444,它并不需要知道 backup-001 是否存在。即使删除 backup-001.json,只要这些 chunk 还在,backup-002 的引用关系依然完整。
可以继续模拟一次安全删除检查:
cd /tmp/pg-hardstorage-demo
rm manifests/backup-001.json
printf "Remaining manifest references:\n"
jq -r '.chunks[]' manifests/*.json | sort | uniq -c
printf "\nUnreferenced chunks that GC may remove:\n"
comm -23 \
<(find chunks -type f -printf '%f\n' | sort) \
<(jq -r '.chunks[]' manifests/*.json | sort -u)
输出里的 aaa111 就是没有任何 manifest 引用的 chunk,垃圾回收可以考虑清理它;而 bbb222、ccc333 仍被 backup-002 使用,不能因为旧备份被删就删除。
采用前的判断清单
pg_hardstorage 的设计适合重视恢复确定性、希望仓库格式可审计、并且不想让中间某次备份成为单点风险的团队。manifest 能用 jq 这类普通工具查看,也让排障更接近“检查数据结构”,而不是猜一条隐含链是否还完整。
但它也有边界:当前每次备份读取完整 PG data dir,对超大库、慢存储、备份窗口紧张的系统,需要认真压测源端 I/O。等 snapshot-aware fast path 发布后,这个取舍可能会变化,但在那之前,不能只看去重率,还要看备份扫描对生产负载的影响。
一个务实的落地方式是:先在非核心库或只读副本上跑一周,记录 chunks_added、总存储增长、备份耗时、源端 I/O 峰值和恢复演练结果。备份格式的好坏不该只看写入时有多省,更要看删除、升级、损坏和凌晨恢复时是否还能讲得清楚。