十年博客的冷备归档:把3G压到90MB

把十年博客压进 90MB:静态站的长期归档策略

我的博客跑了快十年,1700 多篇文章,图片和附件加起来大概 3G。数据库导出是 180M,但真正占空间的不是这些——是那些「已经没人看、但删了可惜」的历史文章正文、缩略图、以及每次主题改版留下的废弃文件。全量冷备一次要传 3G 到对象存储,按流量计费的话每次几毛到几块,日备就是纯浪费。

最后的方案是:数据库每天热备(小、快、必须日备),附件按月增量,一年以上的历史正文导出成静态 HTML 压成一个包,只做年度冷备。整个冷备集从 3G 压到 91MB,恢复演练一次通过。这篇文章讲清楚这套分层策略的设计逻辑、导出脚本、以及冷备必须做的三件事。

为什么全量冷备是错的

先分清三个概念,混在一起是所有备份方案失效的起点:

热备(hot backup):业务运行中实时或每日备份,要求低 RPO、可快速恢复。数据库、当前主题、Nginx 配置属于这一类。体积小、变化频繁,必须日备甚至更密。

温备(warm backup):可接受几天到几周的恢复延迟,通常放异地或另一个可用区。附件、图片、上传目录属于这类。

冷备(cold archive):仅用于「合规、留档、万一哪天要查」的场景,恢复时间可以是几小时,存储成本要压到极低。一年以上的历史内容属于这类。

全量冷备的问题是它在用热备的频率和成本去做冷备的事。3G × 365 天 = 1TB 的传输和存储,其中 99% 的字节每天都没变化。哪怕用 restic 做去重,第一次上传仍然是 3G 的带宽,而不是 90M。

归档的边界:什么该进冷备,什么永远不进

写脚本之前先把边界钉死,否则做着做着就把不该删的删了:

进冷备:
  - 一年以前发布的文章正文(HTML + 标题 + 发布时间 + 分类 + 标签)
  - 一年以前上传的附件与图片(原图,不含各尺寸缩略图)
  - 历年主题版本的完整快照(zip)
  - 每年的数据库结构 dump(--no-data)

不进冷备(必须有热备覆盖):
  - 当前生效的主题与插件
  - Nginx / PHP / MySQL 配置文件
  - 最近一年的所有内容
  - 评论(评论需要能随时写回数据库,不能只留档)

评论这条特别容易踩坑。很多人把「文章连同评论」一起导出成静态 HTML 就以为完事了,结果哪天想恢复评论区,发现只存了渲染后的文本,回复关系、审核状态、IP 归属全丢了。所以我的做法是:评论永远在数据库热备里,导出冷备时只导出正文,不碰评论表。静态 HTML 里可以渲染评论,但那只是给人看的,不是可回灌的数据。

导出脚本:Typecho 内容的静态化打包

我用的是 Typecho,但这段逻辑换 WordPress、换自研 CMS 都一样:按年份分批导出,每篇一个 HTML 文件,最后 tar + zstd。关键是不要一次性全导,1700 篇一次性读进内存会 OOM。

#!/bin/bash
# /usr/local/bin/archive-year.sh <year>
set -euo pipefail
YEAR="${1:?usage: archive-year.sh 2019}"
OUT="/var/backups/archive/$YEAR"
mkdir -p "$OUT/html"

# 分批导出,每次 200 篇,避免一次性载入
mysql -N -B -uroot -p"$DB_PASS" zz1984 -e "
  SELECT cid FROM typecho_contents
  WHERE type='post' AND status='publish'
    AND YEAR(FROM_UNIXTIME(created)) = $YEAR
  ORDER BY cid" | split -l 200 - "$OUT/cids."

for part in "$OUT"/cids.*; do
  while read -r cid; do
    curl -s --max-time 30 \
      "http://127.0.0.1/$cid.html" -o "$OUT/html/$cid.html" || \
      echo "MISS $cid" >> "$OUT/failures.txt"
  done < "$part"
  rm -f "$part"
done

# 附件按年份从数据库里取路径,只复制原图
mysql -N -B -uroot -p"$DB_PASS" zz1984 -e "
  SELECT text FROM typecho_contents
  WHERE type='post' AND YEAR(FROM_UNIXTIME(created)) = $YEAR" \
| grep -oE '/usr/uploads/[0-9]{4}/[0-9]{2}/[^" )]+' | sort -u > "$OUT/attachments.txt"
rsync -a --files-from="$OUT/attachments.txt" / "$OUT/files/" 2>/dev/null || true

# 打包:zstd -19 对 HTML 这种高冗余文本压缩比极高
tar -C "$OUT" -cf - html files attachments.txt failures.txt 2>/dev/null \
  | zstd -19 -T0 -q -o "/var/backups/archive/blog-$YEAR.tar.zst"
rm -rf "$OUT"
ls -lh "/var/backups/archive/blog-$YEAR.tar.zst"

几个实测数据:HTML 全部用 zstd -19 压缩,1700 篇约 240M 的展开文本压到 21M;图片已经是压缩格式,压不动,但按年份切分后每年只有 200~400M,其中真正需要长期保留的原图约 80M。zstd -19 比 gzip -9 快 3 倍且压缩率高 15%,这个场景没必要省 CPU。

注意脚本里 curl 打的是 127.0.0.1 而不是域名。原因有两个:绕开 CDN 拿到源站真实内容(否则可能抓到缓存的旧版本),以及避免在归档时给 CDN 增加回源账单。前提是本地 Nginx 的 Host 头能正确匹配站点——如果配了 default_server 返回别的站,加 -H "Host: www.example.com"。

另外 failures.txt 这个设计很重要。导出过程中总有几篇因为超时、404、或者 PHP 报错没抓到。归档任务必须产出「失败清单」,而不是静默跳过。我第一版脚本没有这个,跑完一看少了 7 篇,完全不知道是哪 7 篇,只能重跑全年。

冷备必须满足的三个硬条件

条件一:异地 + 不可变

冷备放在同一台机器、同一个机房、甚至同一个云账号下,都等于没备。我的做法是传到对象存储的一个独立桶,开启对象锁定(Object Lock)的合规模式,保留期 365 天。开了之后,即使账号被盗、即使自己写错脚本 rm,对象也删不掉。

# 用 rclone 推到 S3 兼容存储,开启服务端加密
rclone copy /var/backups/archive/ remote:blog-archive/2019/ \
  --s3-storage-class STANDARD_IA \
  --header-upload "x-amz-object-lock-mode: COMPLIANCE" \
  --header-upload "x-amz-object-lock-retain-until-date: $(date -u -d '+365 days' +%Y-%m-%dT%H:%M:%SZ)"

# 校验:远端大小必须与本机一致
rclone check /var/backups/archive/2019/ remote:blog-archive/2019/ --size-only

rclone check 这一步不能省。对象存储上传报「成功」但对象损坏的案例我见过两次(一次是网络中断导致的分片缺失,一次是客户端 bug)。冷备的特点是「几年不读」,坏了也不会被发现,所以上传完必须立刻校验。

条件二:自带恢复说明

90MB 的包里如果只有数据、没有说明,五年后的你(或者接手的人)打开会一脸茫然。我在每个包的同级目录放一个 README.md,写清楚:这是哪个站、什么时间段、用什么脚本导出、恢复需要哪几个步骤、数据库版本、字符集。这些信息现在毫无价值,出事时值千金。

还有一个细节:把恢复脚本本身也放进包里。不要在需要恢复的时候才去写脚本——那时候你在压力下,写出来的脚本八成是错的。

条件三:定期做恢复演练

备份不做恢复演练,等于没有备份。我每半年随机抽一个年份的冷备包,在临时容器里解压,用 python3 -m http.server 起个静态服务,随机打开 20 个 HTML 看内容是否完整、图片是否 404。

# 冷备包恢复演练(不碰生产环境)
mkdir -p /tmp/rehearse && cd /tmp/rehearse
zstd -d -c /var/backups/archive/blog-2019.tar.zst | tar -xf -
python3 -m http.server 8080 &
# 随机抽 20 篇,检查 HTTP 200 且正文字节数 > 3000
for f in $(ls html/*.html | shuf -n 20); do
  s=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:8080/$f")
  echo "$s $f"
done

演练记录写进运维日志,包含日期、抽检年份、发现的问题。这个记录本身就是「备份有效」的证据——比任何监控指标的截图都有说服力。

成本对比:3G 日备 vs 91MB 年备

实测数据,按对象存储标准型计费(约 0.02 元/GB/月,流量另计):

方案 A:全量日备(保留 30 天)
  存储:3G × 30 天 ≈ 90GB × 0.02 = 1.8 元/月
  流量:3G/天 × 30 = 90GB/月(假设增量传输,实际更低)
  恢复粒度:任意一天
  恢复时间:约 10 分钟

方案 B:分层(数据库日备 + 附件月增 + 历史年度冷备)
  热备:180M × 14 天 ≈ 2.5GB
  冷备:91MB × 5 年 = 455MB
  存储合计约 3GB,成本 < 0.1 元/月
  流量:约 200M/月
  恢复粒度:数据库到天,历史内容到年
  恢复时间:数据库 5 分钟,历史内容 15 分钟

成本差了近 20 倍,而实际可用的恢复能力并没有下降多少——因为历史文章几乎不会再改,恢复到「年」这个粒度完全够用。真正需要按天粒度的是数据库和当前主题,这两样体积小、日备成本可以忽略。

这套策略的适用边界

分层可以省很多钱和带宽,但它有前提,不适用的场景也要说清楚:

不适用:内容频繁回改的站。如果你的历史文章经常修订(比如长期维护的教程站),一年前的正文可能已经改过好几版,冷备里的版本是旧的。这种情况要么缩短冷备的「一年」阈值到三个月,要么在冷备里同时存一份 typecho_contents.text 的原始字段(压缩后很小),保留可回灌的能力。

不适用:附件还在被外链的站。归档时如果只存原图、丢掉缩略图,而某些老文章还在引用缩略图路径,页面会出现 404。查一遍:grep -rhoE '/usr/uploads/[^"]*' --include=*.html | sort -u 与 attachments.txt 做差集,差集为空才安全。

要注意:恢复顺序有依赖。冷备恢复出来的正文如果要重新入库,必须有对应的分类和标签——所以每年的冷备包里还要带上 typecho_metas 和 typecho_relationships 的结构 dump。少了这两张表,正文恢复出来全是「未分类」,1700 篇手动挂分类是不可能完成的任务。

最后一句实话:这套方案的价值不在于省了那点存储费,而在于让「备份」这件事真正跑得下去。全量日备因为成本高、耗时长,往往跑几个月就被自己关掉了;分层之后每次跑得又快又便宜,反而能坚持十年。能坚持的方案才是好方案。

Last modification:October 21st, 2026 at 07:26 pm

Leave a Comment