跳转至

中央 TimescaleDB 运维手册

适用于管理平面 VM 上的 tfrs_timescaledb 容器(TFRM-120)。 该实例与业务 PG(tfrs_postgres)物理隔离,承担 SaaS 计费链路的 of record 存储(详见 Epic TFRM-114otlp-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 weeklypg_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

清理超期备份(手动)

sudo -u deploy bash /opt/tfrs/scripts/timescale-backup.sh prune

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_uploadaws 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_uploadERROR 退出码 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.prodBACKUP_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 重放)

步骤

  1. 准备临时实例(在备用机器或同 VM 另启容器):
# 在演练机上创建临时数据目录
sudo mkdir -p /tmp/ts-restore/pgdata
sudo chown -R 999:999 /tmp/ts-restore  # postgres 镜像内 uid
  1. 解压全量备份
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/
  1. 配置 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/
  1. 启动临时 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
  1. 校验数据一致性
# 临时实例的事件总数
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';"

# 两个数字应该一致(如有偏差需根因分析)
  1. 清理演练实例
docker rm -f tfrs_ts_restore
sudo rm -rf /tmp/ts-restore /opt/tfrs/wal-archive-restore

季度演练记录模板

把每次演练结果归档到 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 策略断言 —— 跑 §远端冷归档 SOPaws s3api get-bucket-lifecycle-configuration 命令,断言 prefix=timescale/full/Days≥740Enabled,结果写入演练记录。仅验「能拉回」不够(刚上传的对象当然能拉回),过期策略才是 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 对比应一致
  1. 之后复用上方「恢复演练 SOP」步骤 2–6:把 /tmp/ts-remote-restore/ 当作解压源, 解压 → 配 recovery.signal + recovery_target_time → 起 5434 临时实例 → SELECT count(*) 与生产对账 → 清理临时实例。

  2. 证据落档(不依赖 staging 持久)到 docs/audit/ts-recovery-drill-YYYY-Qx.md: 远端 aws s3 ls 输出 + base.tar.gz sha256 + 临时实例与生产的 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 满或权限异常,扩容卷或重启容器即可恢复。

关联文档