中央 TimescaleDB 运维手册¶
适用于管理平面 VM 上的
tfrs_timescaledb容器(TFRM-120)。 该实例与业务 PG(tfrs_postgres)物理隔离,承担 SaaS 计费链路的 of record 存储(详见 Epic TFRM-114 与 otlp-receiver-contract.md)。
架构概述¶
otlp-receiver(:4318) ── 写 ──► tfrs_timescaledb(5432, tfrs_billing)
│
user-service ConsumptionScheduler ◄── 读 outbox ──┘
│
└── WAL ──► /var/lib/postgresql/wal-archive
(docker volume:tfrs_ts_wal_archive)
- 容器:
tfrs_timescaledb(镜像timescale/timescaledb:2.17.2-pg16) - 宿主端口:
127.0.0.1:5433(容器内 5432);docker network DNS 名为timescaledb - 数据卷:
tfrs_tsdata(PGDATA) +tfrs_ts_wal_archive(WAL 归档) - Schema migration:otlp-receiver 启动时
OTLP_TS_AUTO_MIGRATE=true自动幂等执行(详见internal/shared/timescale/migrations/)
备份策略¶
| 维度 | 策略 |
|---|---|
| 全量备份 | 每周日 03:00 由 systemd timer 触发 scripts/timescale-backup.sh weekly → pg_basebackup |
| 增量 | WAL 归档(archive_mode=on),由 PG 自动写入独立 volume |
| 本机热备份保留期 | 默认 90 天(恢复演练窗口;TS_BACKUP_LOCAL_RETENTION_DAYS 控制)。这是热备份窗口,不是合规保留期 |
| 远端冷归档保留期 | ≥ 2 年(财务审计 of record 硬要求;BACKUP_REMOTE_RETENTION_DAYS=740 仅记录用)。由 do_upload 上传 + bucket 侧 lifecycle rule 兑现 |
| 远端备份 | do_upload 已实现(aws s3 cp → 腾讯云 COS,S3 兼容)。配齐 BACKUP_REMOTE_* 即随 weekly 自动生效;见下方 §远端冷归档 SOP |
| 备份产物路径 | /opt/tfrs/backups/timescale/full/YYYY-MM-DD/ |
| 备份日志 | /opt/tfrs/backups/timescale/backup.log |
⚠️ 保留期两段制:本机只保留 90 天热备份(避免周全量 × 2 年堆出 TB 级磁盘);财务审计要求的 2 年保留由远端冷归档承载(
do_upload上传 + bucket lifecycle rule,见 §远端冷归档 SOP)。本机保留期 ≠ 合规保留期 —— 调整TS_BACKUP_LOCAL_RETENTION_DAYS不影响 2 年审计窗口。与业务 PG(
tfrs_postgres)的备份独立运行,不共享 schedule 也不共享存储桶。
部署 systemd Timer¶
首次部署时一次性操作(运维执行):
# 1. 复制 systemd unit
sudo cp /opt/tfrs/scripts/timescale-backup.service /etc/systemd/system/
sudo cp /opt/tfrs/scripts/timescale-backup.timer /etc/systemd/system/
# 2. 启用并启动
sudo systemctl daemon-reload
sudo systemctl enable --now timescale-backup.timer
# 3. 验证
systemctl list-timers timescale-backup.timer
# 预期下次触发:Sun 03:00:00
首次部署验收(必做 — 备份链路 smoke test)¶
备份脚本无 CI 自动验证,首次 enable timer 后必须手动触发一次并核对产物,把它当成部署验收节点(bootstrap-env Skill Step 7.5 同步要求)。这是唯一能保证 pg_basebackup → docker cp → tar.gz 链路在生产镜像上真正跑通的环节:
# 1. 手动触发一次全量(基线备份)
sudo systemctl start timescale-backup.service
journalctl -u timescale-backup.service -f
# 等待退出码 0(Type=oneshot)
# 2. 核对产物(缺任一项即视为验收不通过)
ls -lh /opt/tfrs/backups/timescale/full/$(date +%Y-%m-%d)/
cat /opt/tfrs/backups/timescale/full/$(date +%Y-%m-%d)/manifest.txt
du -sh /opt/tfrs/backups/timescale/full/$(date +%Y-%m-%d)/
# 预期:base.tar.gz + pg_wal.tar.gz + manifest.txt 均存在,大小非 0
# 3. 备份日志无 ERROR
grep -i error /opt/tfrs/backups/timescale/backup.log || echo "OK 无错误"
验收不通过时不要 enable timer 走人 —— 否则等到首个周日才发现备份从未成功,财务审计窗口已丢数据。
中期增强建议(非本工单交付)¶
staging 配置每月一次 cron-driven mini drill:取最新 full 在 5434 端口起临时实例,count(*) 比对生产。把恢复演练从纯文档承诺变成有自动回归的流程(见下方 §恢复演练 SOP)。
备份操作 SOP¶
主动触发全量¶
# 一次性全量
sudo -u deploy bash /opt/tfrs/scripts/timescale-backup.sh full
# 周任务(full + prune + upload)
sudo -u deploy bash /opt/tfrs/scripts/timescale-backup.sh weekly
检查备份产物¶
# 列出全量备份
ls -lh /opt/tfrs/backups/timescale/full/
# 单次备份产物
ls -lh /opt/tfrs/backups/timescale/full/$(date +%Y-%m-%d)/
cat /opt/tfrs/backups/timescale/full/$(date +%Y-%m-%d)/manifest.txt
# WAL 归档数量
docker exec tfrs_timescaledb sh -c 'ls /var/lib/postgresql/wal-archive | wc -l'
# 备份日志
tail -50 /opt/tfrs/backups/timescale/backup.log
清理超期备份(手动)¶
prune 只清理本机热备份(TS_BACKUP_LOCAL_RETENTION_DAYS 默认 90 天),不影响远端冷归档。如需调整窗口,在 .env.prod 中覆盖此值,不要直接改脚本。远端归档保留期由 bucket 侧 lifecycle rule 兑现(BACKUP_REMOTE_RETENTION_DAYS=740 仅作记录用;脚本从不删远端对象,见 §远端冷归档 SOP)。
远端冷归档 SOP¶
财务审计要求 of-record 数据 ≥ 2 年保留。本机只留 90 天热备份,2 年合规保留由远端冷归档承载。
do_upload用aws s3 cp把当日全量目录推到腾讯云 COS(S3 兼容)。
宿主机依赖(首次必备)¶
远端上传需要宿主机安装 awscli(一次性,随 VM 初始化 / bootstrap-env 完成):
# Ubuntu
sudo apt-get update && sudo apt-get install -y awscli
aws --version # 任意 v1/v2 均可,仅用 s3 cp + --endpoint-url
BACKUP_REMOTE_BUCKET已配但awscli缺失时,do_upload会 ERROR 退出码 1(合规缺口必须响,不静默跳过),weekly 任务随之 systemd failed。
环境变量(.env.prod,走 Infisical + CNB 密钥仓库)¶
| 变量 | 说明 |
|---|---|
BACKUP_REMOTE_BUCKET |
远端 bucket 全名(腾讯云 COS 含 appid,如 tfrs-ts-archive-1300000000)。留空 = 跳过上传(向后兼容) |
BACKUP_REMOTE_SECRET_ID |
访问密钥 ID,与 COS_* 同源(同腾讯云账号,建议 bucket 级最小权限子账号) |
BACKUP_REMOTE_SECRET_KEY |
访问密钥 Key |
COS_REGION |
复用现有变量作为端点区域(如 ap-beijing),不新增区域变量 |
BACKUP_REMOTE_ENDPOINT |
高级手动覆盖,非常态部署链路(不经 CNB / Infisical 自动注入)。缺省 https://cos.${COS_REGION}.myqcloud.com 已覆盖腾讯云 COS 同区域标准路径;仅当归档 bucket 跨区域/跨厂商时,运维在宿主机 export BACKUP_REMOTE_ENDPOINT=... 后手动跑 upload(手动演练用) |
前 4 项(
BUCKET/SECRET_ID/SECRET_KEY走白名单 + Infisical;COS_REGION复用现有白名单项)由流水线注入.env.prod;BACKUP_REMOTE_ENDPOINT不入白名单(工单约束白名单仅加 3 项),仅作宿主机手动覆盖,配进 Infisical 不会生效。
对象键格式:s3://${BACKUP_REMOTE_BUCKET}/timescale/full/YYYY-MM-DD/{base.tar.gz,pg_wal.tar.gz,manifest.txt}。
同一周重跑覆盖同 key(幂等,不堆叠)。
bucket 侧 lifecycle rule(运营一次性配置,非脚本)¶
脚本绝不删远端对象(避免脚本 bug 误删合规数据)。≥ 2 年过期由 bucket 生命周期策略兑现,运营在腾讯云 COS 控制台 / API 配置一次:
- 规则前缀:
timescale/full/ - 过期天数:≥ 740 天(≈ 2 年 + 10 天缓冲,对齐
BACKUP_REMOTE_RETENTION_DAYS) - 验证(程序化断言,纳入季度演练,不止一次性肉眼):
AWS_ACCESS_KEY_ID=$BACKUP_REMOTE_SECRET_ID AWS_SECRET_ACCESS_KEY=$BACKUP_REMOTE_SECRET_KEY \
AWS_DEFAULT_REGION=$COS_REGION AWS_EC2_METADATA_DISABLED=true \
aws s3api get-bucket-lifecycle-configuration \
--bucket "${BACKUP_REMOTE_BUCKET}" \
--endpoint-url "https://cos.${COS_REGION}.myqcloud.com"
# 断言(任一不满足即合规告警):
# 存在 Rule.Filter.Prefix == "timescale/full/"
# 且 Rule.Expiration.Days >= 740
# 且 Rule.Status == "Enabled"
调小于 740 天会违反财务审计 2 年要求,与 hypertable retention policy 的下限约束一致。 该断言把「漏配 / 配错前缀 / 天数过短 / 误配提前删除」从「2 年后数据消失才暴露」降级为季度可发现。
手动触发与验证¶
# 当日已有 full → 仅上传(standalone)
sudo -u deploy bash /opt/tfrs/scripts/timescale-backup.sh upload
# 或整条周任务(full + prune + upload)
sudo -u deploy bash /opt/tfrs/scripts/timescale-backup.sh weekly
# 验证远端对象(落档到验收记录)
AWS_ACCESS_KEY_ID=$BACKUP_REMOTE_SECRET_ID AWS_SECRET_ACCESS_KEY=$BACKUP_REMOTE_SECRET_KEY \
AWS_DEFAULT_REGION=$COS_REGION AWS_EC2_METADATA_DISABLED=true \
aws s3 ls "s3://${BACKUP_REMOTE_BUCKET}/timescale/full/$(date +%Y-%m-%d)/" \
--endpoint-url "https://cos.${COS_REGION}.myqcloud.com"
# 预期:base.tar.gz + pg_wal.tar.gz + manifest.txt 三个对象,Size 非 0
上传失败时本机 90 天热备份原样保留(
do_upload从不删本机),脚本 exit 1 经set -e传播 →do_weekly非零 → systemd 标记 failed,不会静默成功。排查凭证 / 网络 / bucket 权限后重跑upload即可(幂等)。
恢复演练 SOP(季度执行)¶
目标 RPO:≤ 1 小时(WAL 归档间隔约 16MB 写一段,活跃时段每分钟有归档) 目标 RTO:≤ 4 小时(全量恢复 + WAL 重放)
步骤¶
- 准备临时实例(在备用机器或同 VM 另启容器):
# 在演练机上创建临时数据目录
sudo mkdir -p /tmp/ts-restore/pgdata
sudo chown -R 999:999 /tmp/ts-restore # postgres 镜像内 uid
- 解压全量备份:
BACKUP_DATE=2026-05-10 # 选择目标全量备份日期
cd /tmp/ts-restore/pgdata
sudo tar -xzf /opt/tfrs/backups/timescale/full/${BACKUP_DATE}/base.tar.gz
sudo tar -xzf /opt/tfrs/backups/timescale/full/${BACKUP_DATE}/pg_wal.tar.gz -C pg_wal/
- 配置 recovery(PITR 重放到目标时刻):
# 在解压目录内创建 standby.signal(PG12+ recovery 接口)
sudo touch /tmp/ts-restore/pgdata/recovery.signal
# 编辑 postgresql.auto.conf 加 restore_command + recovery_target_time
cat <<EOF | sudo tee -a /tmp/ts-restore/pgdata/postgresql.auto.conf
restore_command = 'cp /opt/tfrs/wal-archive-restore/%f %p'
recovery_target_time = '2026-05-11 10:00:00 Asia/Shanghai'
recovery_target_action = 'promote'
EOF
# 把 WAL 归档复制到 restore 目录
sudo mkdir -p /opt/tfrs/wal-archive-restore
sudo docker cp tfrs_timescaledb:/var/lib/postgresql/wal-archive/. \
/opt/tfrs/wal-archive-restore/
- 启动临时 PG 实例:
docker run -d --name tfrs_ts_restore \
-v /tmp/ts-restore/pgdata:/var/lib/postgresql/data \
-v /opt/tfrs/wal-archive-restore:/opt/tfrs/wal-archive-restore:ro \
-p 127.0.0.1:5434:5432 \
timescale/timescaledb:2.17.2-pg16
docker logs -f tfrs_ts_restore
# 等待出现:archive recovery complete + database system is ready
- 校验数据一致性:
# 临时实例的事件总数
docker exec tfrs_ts_restore psql -U tfrs -d tfrs_billing -c \
"SELECT count(*), max(occurred_at), min(occurred_at) FROM llm_billing_events;"
# 与生产实例对比(取截至 recovery_target_time 的同窗口)
docker exec tfrs_timescaledb psql -U tfrs -d tfrs_billing -c \
"SELECT count(*) FROM llm_billing_events WHERE occurred_at <= '2026-05-11 02:00:00Z';"
# 两个数字应该一致(如有偏差需根因分析)
- 清理演练实例:
季度演练记录模板¶
把每次演练结果归档到 docs/audit/ts-recovery-drill-YYYY-Qx.md(手动创建,不进 mkdocs):
# 中央 TS 恢复演练 - 2026 Q2
- 演练日期:2026-05-15
- 执行人:{姓名}
- 目标时刻(PITR):2026-05-11 10:00 +08:00
- 实测 RTO:xx 分钟
- 实测 RPO:xx 分钟
- 数据一致性:是否通过对账
- 远端归档恢复:是否通过(`aws s3` 拉回 → 临时实例 → checksum 对账)
- 远端 lifecycle 断言:是否通过(prefix=`timescale/full/` 且 Days≥740 且 Enabled,命令见 §远端冷归档 SOP)
- 异常 / 改进点:
从远端冷归档恢复演练 SOP(季度执行)¶
验证财务审计 of-record 远端链路真实可恢复 —— 不只是本机磁盘有备份,而是 从远端 bucket 拉回也能恢复出一致数据。证据须落档(不依赖 staging 持久)。
与上方「恢复演练 SOP」唯一区别:全量产物来自远端 bucket 而非本机目录,其余(解压 / recovery 配置 / 启动临时实例 / checksum 对账 / 清理)完全复用上方步骤 2–6。
季度演练必做的两件事(缺一即合规风险):① 远端可恢复(下方步骤 0–4);② lifecycle 策略断言 —— 跑 §远端冷归档 SOP 的 aws s3api get-bucket-lifecycle-configuration 命令,断言 prefix=timescale/full/、Days≥740、Enabled,结果写入演练记录。仅验「能拉回」不够(刚上传的对象当然能拉回),过期策略才是 2 年保留的真实兑现点。
# 0. 选定目标备份日期
BACKUP_DATE=2026-05-11
# 1. 从远端冷归档拉回到临时目录(区别于本机 /opt/tfrs/backups/...)
mkdir -p /tmp/ts-remote-restore
AWS_ACCESS_KEY_ID=$BACKUP_REMOTE_SECRET_ID AWS_SECRET_ACCESS_KEY=$BACKUP_REMOTE_SECRET_KEY \
AWS_DEFAULT_REGION=$COS_REGION AWS_EC2_METADATA_DISABLED=true \
aws s3 cp "s3://${BACKUP_REMOTE_BUCKET}/timescale/full/${BACKUP_DATE}/" \
/tmp/ts-remote-restore/ --recursive \
--endpoint-url "https://cos.${COS_REGION}.myqcloud.com"
# 2. 校验产物完整 + 与本机同日备份 checksum 一致(若本机仍在 90 天窗口内)
ls -lh /tmp/ts-remote-restore/ # base.tar.gz + pg_wal.tar.gz + manifest.txt 非 0
sha256sum /tmp/ts-remote-restore/base.tar.gz
# 若本机仍有同日备份:与 /opt/tfrs/backups/timescale/full/${BACKUP_DATE}/base.tar.gz 的 sha256 对比应一致
-
之后复用上方「恢复演练 SOP」步骤 2–6:把
/tmp/ts-remote-restore/当作解压源, 解压 → 配recovery.signal+recovery_target_time→ 起 5434 临时实例 →SELECT count(*)与生产对账 → 清理临时实例。 -
证据落档(不依赖 staging 持久)到
docs/audit/ts-recovery-drill-YYYY-Qx.md: 远端aws s3 ls输出 +base.tar.gzsha256 + 临时实例与生产的count(*)对账截图/文本。
# 清理(含远端拉回的临时目录)
docker rm -f tfrs_ts_restore 2>/dev/null || true
sudo rm -rf /tmp/ts-restore /tmp/ts-remote-restore /opt/tfrs/wal-archive-restore
Retention Policy 维护¶
中央 TS hypertable 已通过 add_retention_policy('llm_billing_events', INTERVAL '2 years') 配置(TFRM-115 migration 0001_init.up.sql)。
-- 查看当前 retention policy
SELECT * FROM timescaledb_information.jobs
WHERE proc_name = 'policy_retention';
-- 手动调整保留期(示例:从 2 年改 3 年)
SELECT remove_retention_policy('llm_billing_events');
SELECT add_retention_policy('llm_billing_events', INTERVAL '3 years');
-- 手动 drop 指定 chunk
SELECT drop_chunks('llm_billing_events', older_than => INTERVAL '2 years 1 month');
⚠️ 不要为了释放磁盘把保留期降低到 2 年以下,会违反财务审计要求。
常见故障¶
内存不足 / OOM-kill¶
容器 deploy.resources.limits.memory=2G。OTLP 高频小事务 + WAL 同步刷盘 + hypertable chunk 切换 + ConsumptionScheduler 大窗口聚合都吃内存。
# 实时内存占用
docker stats --no-stream tfrs_timescaledb
# 是否被 OOM-kill
docker inspect tfrs_timescaledb --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
# 输出 true 137 即为 OOM
mem% 长期 > 85% → 调大 timescaledb 的 memory limit(如 2G → 4G),或下调 shared_buffers。云环境 TS 由 GitOps/k8s 管理(TFRM-254),改 k8s StatefulSet 的 resources.limits 后滚动重建(数据卷不受影响);本地 UAT 用 docker-compose.timescale.yml。
连接数耗尽¶
-- 查看活跃连接
SELECT datname, usename, application_name, state, count(*)
FROM pg_stat_activity
WHERE datname = 'tfrs_billing'
GROUP BY 1,2,3,4;
max_connections=100 当前足够;若耗尽多半是 user-service / otlp-receiver 连接池泄漏,先在应用侧排查。
磁盘占用告警¶
阈值建议: - 80% → 告警(运维人工介入) - 90% → 紧急(立即扩容或临时关闭 WAL 归档)
# 查看 PGDATA / WAL 归档占用
docker exec tfrs_timescaledb du -sh /var/lib/postgresql/data /var/lib/postgresql/wal-archive
# 查看每张表 / 每个 chunk 大小
docker exec tfrs_timescaledb psql -U tfrs -d tfrs_billing -c \
"SELECT hypertable_name, pg_size_pretty(hypertable_size(format('%I.%I', hypertable_schema, hypertable_name)::regclass)) FROM timescaledb_information.hypertables;"
WAL 堆积¶
archive_command 失败时 WAL 不会被清理。检查:
docker exec tfrs_timescaledb psql -U tfrs -d tfrs_billing -c "SELECT * FROM pg_stat_archiver;"
# 关注 failed_count > 0 + last_failed_time
通常是 wal-archive volume 满或权限异常,扩容卷或重启容器即可恢复。
关联文档¶
- 部署:docs/deploy/03-infrastructure.md
- 环境变量:docs/deploy/04-backend-deploy.md
- OTLP Receiver 契约(含 sink / SSL 边界):docs/specs/otlp-receiver-contract.md
- Epic:TFRM-114
- 存储层来源:TFRM-115
- 本机备份骨架:TFRM-120
- 远端冷归档(do_upload / 2 年合规收尾):TFRM-123