2026-08-29 14:51:20 +08:00
|
|
|
|
# pgdb — TimescaleDB (PG18, Docker)
|
|
|
|
|
|
|
|
|
|
|
|
## Role and access
|
|
|
|
|
|
|
|
|
|
|
|
| Item | Value |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| Role | TimescaleDB PostgreSQL 18 (Docker) — Home Assistant recorder 后端(`hass`/`scribe` 库) |
|
|
|
|
|
|
| IPv4 | `192.168.55.15` (LAN55) |
|
|
|
|
|
|
| DNS | (none) |
|
|
|
|
|
|
| SSH | `ssh -4 windy@192.168.55.15`(key auth 已验证可用 2026-08-29;agent 沙箱用 `ssh -F /dev/null -o BatchMode=yes`;password auth 亦可) |
|
2026-09-13 19:12:32 +08:00
|
|
|
|
| Host | PVE 管理的 QEMU VM(i440FX,**VMID 100**),Debian 13 (trixie),内核 6.12.105;宿主机 **pve2 `192.168.55.25`**(Proxmox 9.2.2,SSH `root@192.168.55.25`,`onboot: 1`,QEMU guest agent 已装;2026-08-31 补记) |
|
|
|
|
|
|
| Resources | 3 GB RAM(08-30 13:58 由 2G 上调、删除 balloon/ksm/shares 后重启生效)/ 30 GB disk(26 G 空闲) |
|
2026-08-29 14:51:20 +08:00
|
|
|
|
| Docker | 29.7.2;容器 `timescaledb` = `timescale/timescaledb:latest-pg18`(PG **18.6** + TimescaleDB **2.29.2**,Apache-2.0 版) |
|
|
|
|
|
|
| Ports | `192.168.55.15:5432`(PG,IPv4 only);`192.168.55.15:8081`(pgweb GUI,basic auth) |
|
|
|
|
|
|
|
|
|
|
|
|
## Databases
|
|
|
|
|
|
|
|
|
|
|
|
| DB | Owner | Size | 用途 |
|
|
|
|
|
|
|---|---|---|---|
|
2026-09-22 08:45:45 +08:00
|
|
|
|
| `hass` | hass | ~603 MB(2026-09-21) | HA recorder(states/events/statistics),客户端 HAOS `192.168.55.11` |
|
|
|
|
|
|
| `scribe` | postgres | ~2851 MB(2026-09-21) | HA scribe 集成(entities/areas/devices 注册表同步 + `states_raw`/`events` hypertable + `csg_history` 长期归档表);体积由 `sensor_minute` 图表管道主导(2.46 GB 未压缩 chunk),见 Known issues |
|
2026-08-29 14:51:20 +08:00
|
|
|
|
| `postgres` | postgres | ~9 MB | 默认库 |
|
|
|
|
|
|
|
|
|
|
|
|
## Ops notes
|
|
|
|
|
|
|
|
|
|
|
|
- **Docker compose 管理**(2026-08-29 改造):`/opt/database/docker-compose.yml`(源码在仓库 `compose/pgdb/`)+ `/opt/database/.env`(0600,密钥)+ `/opt/database/pgweb-bookmarks/`(0600,bookmark 含 DB 密码)。三个服务:
|
|
|
|
|
|
| 服务 | 镜像 | 端口 | 说明 |
|
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
| `timescaledb` | `timescale/timescaledb:latest-pg18` | `192.168.55.15:5432`(IPv4 only) | PG 18.6 + TS 2.29.2;healthcheck pg_isready;`restart: unless-stopped` |
|
|
|
|
|
|
| `pgweb` | `sosedoff/pgweb:latest`(v0.17.0) | `192.168.55.15:8081` | Web GUI:http://192.168.55.15:8081;basic auth(用户名/密码见 .env `PGWEB_AUTH_USER/PASS`);`--readonly --sessions --bookmarks-only --bookmarks-dir /bookmarks`(v0.17.0 不读 PGWEB_BOOKMARKS_DIR env,必须用 flag);bookmarks = hass/scribe |
|
|
|
|
|
|
| `pg-backup` | `prodrigestivill/postgres-backup-local:latest`(=PG18 客户端) | — | 每日 02:00(`TZ=Asia/Shanghai`,本地时区)`pg_dump -Fc` 三库 → `/opt/database/backups/{daily,weekly,monthly}`;保留 7 天/4 周/6 月;`BACKUP_ON_START` |
|
|
|
|
|
|
- **数据盘**:`/dev/sdb1`(32G ext4,label `pgdata`)挂载 `/srv/pgdata`,fstab 按 `UUID=c9e12e79-1f66-404c-ab7f-b8809be81d86`(defaults,noatime)持久化(2026-08-29 迁移)。容器 bind mount `/srv/pgdata:/var/lib/postgresql`。
|
|
|
|
|
|
- 容器内 postgres 用户 uid/gid = **70**(Debian 系,非 999);迁移数据后需 `chown -R 70:70`。
|
|
|
|
|
|
- **密码**:postgres 超级用户已换强密码(hex,存 `/opt/database/.env` 0600,2026-08-29)。HA 用 `hass` 角色不受影响。
|
|
|
|
|
|
- **备份**:由 `pg-backup` 容器接管(2026-08-29),宿主机 cron 与 `/opt/database/pg-backup.sh` 已退役。恢复用 `pg_restore`(custom format)——2026-08-29 已实测还原 hass 库 dump(states 10014 行)成功。
|
|
|
|
|
|
- **认证**:外部连接 scram-sha-256(密码必填,改密码有效);容器内 loopback 为 trust(官方镜像默认)。
|
|
|
|
|
|
- **回滚**:旧启动命令保留在 `/opt/database/run`(容器无状态,数据在 /srv/pgdata);旧匿名卷 `9375195843b950f4e04c34872409ca095e1136520dd019a8e86e2794be06c236`(根盘 ~82M)保留作兜底,确认稳定后可 `docker volume rm`。
|
2026-08-30 13:19:05 +08:00
|
|
|
|
- **开机自愈**(2026-08-30):新增 systemd oneshot `pgdb-compose.service`(enabled,源码在仓库 `compose/pgdb/pgdb-compose.service`):`After=network-online.target docker.service`,开机后幂等执行 `docker compose up -d`,重试直到 `192.168.55.15:5432` 监听,重试耗尽 `--force-recreate` 兜底(数据在 bind mount,无损)。原因:2026-08-30 开机竞态——docker 恢复容器时 VM IP 尚未可绑(EADDRNOTAVAIL),timescaledb/pgweb 启动失败且 docker 不重试。手动重跑:`sudo systemctl restart pgdb-compose.service`。
|
2026-08-29 14:51:20 +08:00
|
|
|
|
- 本机无防火墙(ufw/nft/iptables 均未装)——待办:如要彻底隔离可加 ufw 白名单 192.168.55.11。
|
|
|
|
|
|
- `/opt/database/backups/` 根下残留 `*-2026-08-29_1359.dump`(compose 化之前旧备份机制产物)与 `backup.log`——健康检查只看 `daily/`,残留可清理。
|
2026-09-22 08:49:40 +08:00
|
|
|
|
- **Runbooks**:[pgdb-health](../runbooks/pgdb-health.md)(只读健康检查,**9 项**;第 9 项为 CSG 归档新鲜度,2026-09-22 新增)、[pgdb-restore](../runbooks/pgdb-restore.md)(pg_restore 还原)、[pgdb-update](../runbooks/pgdb-update.md)(镜像/compose 升级)。
|
2026-09-22 08:45:45 +08:00
|
|
|
|
- **CSG 长期归档(2026-08-29, W1N-243)**:`csg_history` 表(`period date / kind('day'|'month') / usage_kwh / cost / ladder / balance / updated_at`,PK(period,kind),`GRANT SELECT TO hass`)保存南方电网有价值数据:day = 逐日(昨日用电/费用/阶梯/余额,2026-07-01 起),month = 当月累计(用电/费用,2025-01 起)。由 TimescaleDB 每日任务 **1010**(2026-09-21 起;原 1008)`csg_daily_snapshot()`(**14:30 UTC = 22:30 Asia/Shanghai**;**TS job 非 pg_cron**,本库未装 pg_cron)upsert 维护;计费用 `csg_ladder_cost(kwh, month)` 助手(归档侧唯一计价来源)。回填来源:集成 attributes `history_data`(59 天)+ `by_month`(19 月)——08-29 前唯一残存历史。回滚:`DROP TABLE csg_history` + 删任务(另有迁移前快照表 `csg_history_bak_20260921`)。详见下方 Known issues 2026-09-21。**2026-09-22 v5 补两件事**:① **断档自愈** job **1011** `csg_backfill_missing_days()`(**15:10 UTC = 23:10 Asia/Shanghai**,排在 1010 之后)——只 `INSERT` 缺失日期、`ON CONFLICT DO NOTHING`,**绝不覆盖既有行**,数据源是 `states_raw` 里 `latest_day_kwh` 的 (`latest_day_date`, `value`) 观测对;② day 行费用改为 `csg_ladder_cost_raw` 的**累积边际差分 + 单次 `round(...,2)`**(与 v3 回填同口径),快照表 `csg_history_bak_20260922`,脚本 `compose/pgdb/csg-snapshot-v5.sql`。详见下方 Known issues 2026-09-22。
|
2026-08-29 14:51:20 +08:00
|
|
|
|
|
|
|
|
|
|
## Known issues
|
|
|
|
|
|
|
2026-09-22 08:45:45 +08:00
|
|
|
|
- 2026-09-22:**CSG day 费用口径统一 + 断档自愈(v5,脚本 `compose/pgdb/csg-snapshot-v5.sql`)**。只读复核发现 `csg_daily_snapshot()` 写 day 费用用的是「昨日用电 × 当前档费率」**单一费率且未舍入**,而 v3 的回填用「当月累积边际差分 + `round(...,2)`」——两套口径混在同一列:当时只差精度(3 行:`2026-08-28` 4.89474 / `09-19` 6.94431 / `09-20` 6.31408,总差 -0.01),但**一旦某个跨档日由夜间任务写出就会破坏「day 费用之和 ≈ 阶梯月费用」**;时限是 2026-09-29 前后跨二档(当时 `csg_current_ladder_remaining_kwh` = 80.6、日均 8.97)。**改动**:① 新增 `csg_ladder_cost_raw(kwh, month)`(无舍入版;直接用 `csg_ladder_cost(c1) − csg_ladder_cost(c0)` 会**二次舍入**,7 月实测 1 天差 0.01);② `csg_daily_snapshot()` v5 —— day 费用改为 `round(raw(c1) − raw(c0), 2)`,`p_month` 计算**上移到计费之前**并加**跨月守卫**(每月 1 日的 `d` 落在上月,此时 `m_usage` 是新月份累计,不可作起点,否则为负);③ 数据日期与当日用量改为取**同一实体同一行** `latest_day_kwh`(v3 用它的 `latest_day_date` 定日期、却用 `yesterday_kwh` 取值,两实体可能错配;且 `latest_day_kwh` 归档更全,含 09-06 = 9.29 那条 `yesterday_kwh` 没有的观测);④ 新增 job **1011** `csg_backfill_missing_days()`(**断档自愈**,insert-only)。**验证**:归一 `UPDATE 3`(与 dry-run 预测一致);`cost <> round(cost,2)` 的行数 0;`CALL run_job(1011)` 与调度器实跑均 `Success`(1/1/0、`job_errors` 0);函数签名为 `(job_id integer, config jsonb)`(即 job 1008 失败的根因形态,已显式排除);两函数手动调用幂等(总行数 103、`max(day period)` 仍 09-20);各月 `|sum(day cost) − month cost|` = 0.00 / -0.03 / -0.03(**逐日舍入累积,非缺陷**;月行由 `csg_ladder_cost` 单次舍入,是权威值)。回滚:`delete_job(1011)` + `DROP FUNCTION csg_backfill_missing_days/csg_ladder_cost_raw` + 用 v3 脚本重建主函数 + 从 `csg_history_bak_20260922` 恢复行。健康检查新增第 9 项(`runbooks/pgdb-health.md`),专查「最新 day 行日期」——**上游停更时 job 会反复 upsert 同一行、`last_run_status` 仍 `Success`、日期上也无缺口,只有该断言能发现**。
|
|
|
|
|
|
- 2026-09-21:**CSG 快照任务 1008 自创建起从未成功 + 已修复(迁移脚本 `compose/pgdb/csg-snapshot-v3.sql`)**。只读复核发现 `job_stats` 1008 = **294 次运行 / 0 成功 / 294 失败**,`job_errors` 与容器日志一致:`function or procedure "public.csg_daily_snapshot(integer, jsonb)" does not exist. Custom job actions must accept (integer, jsonb) arguments.` 根因:函数实际签名是零参数 `csg_daily_snapshot()`,而 TimescaleDB 自定义 job 动作按名字调用 `schema.proc(job_id integer, config jsonb)`;08-29 那批数据是**手工写入**的,任务本身没写过一行。后果:`csg_history` 的 day 行停在 08-28、month 行停在 08-01,且 2026-08 月行是 08-29 当时的「本月至今」**302.47 kWh / 180.28 元**,而非月终值 **331.22 / 198.65**(账单 198.64)。**修复**:① 不能直接 `DROP FUNCTION`(job 持有依赖,报 `cannot drop ... because background job 1008 depends on it`)→ `delete_job(1008)` → 换 `(job_id integer DEFAULT NULL, config jsonb DEFAULT NULL)` 签名重建 → `add_job` 重挂为 **1010**,排程从 22:00 UTC(=06:00 CST,那时集成还没发布前一日数据)对齐到 **14:30 UTC = 22:30 CST**;② 新增 `csg_ladder_cost(kwh, month)` 助手(夏季 5-10 月 260/600、非夏季 200/400;0.589/0.639/0.889),month 行费用一律由它重算;③ month 行改为**按数据日期 `d` 所属月份归属**——当月用集成本月累计,上月由该月 day 行汇总(不读集成 `last_month_*`,避开翻月瞬态:08-31 16:32 实测读到过 323.49 这种误值),并加「汇总值小于已记录值则不动」的不降级保护;④ 回填 08-29→09-19 共 22 个 day 行(08-29..08-31 取 `states_raw` 里 `yesterday_kwh` 观测值减一天;09-01..09-19 取集成 `this_month_by_day`,其中 09-06 = 9.29 是 scribe 漏采的观测),日费用按「当日用电 × 当日边际档位」计,使 day 费用之和 == 阶梯月费用。**验证**:job 1010 `last_run_status=Success`(1/1/0),`next_start = 2026-09-21 14:30 UTC`;day 行 81 条(07-01..09-19)、month 行 21 条(2025-01..2026-09);2026-08 = 331.22/198.65、2026-09 = 168.68/99.35;9 月 day 合计 168.68 kWh / 99.33 元(与阶梯 99.35 差 0.02 为逐日四舍五入)。迁移前快照表 `csg_history_bak_20260921`(79 行)保留;旧任务 1008 的 294 条 `job_errors` 作为历史保留(Timescale 的 error retention 任务会自行清理)。差异核对:day 新增 22 条、补 cost 80 条(仅填 NULL,未覆盖既有非空值),month 改 2 条。
|
|
|
|
|
|
- 2026-09-13:**`sensor_minute` 体积构成与压缩窗口(只读诊断,暂不处理)**。`scribe` 库 2.6 GB = `sensor_minute` **2.26 GB**(850 万行 / 16 天,约 56–59 万行/天 = 331 实体 × 1440 分钟 LOCF)+ `states_raw` 290 MB + `events` 1.5 MB;`hass` 库另 406 MB。2.26 GB 中 1.50 GB 是 chunk `[09-03,09-10]`、0.76 GB 是 `[09-10,09-17]`,**都还没到压缩窗口**——TimescaleDB 的 `compress_after` 按 **chunk 结束时间**判断(09-10 结束 + 7 天 = **09-17** 才合格),所以「7 天 chunk + 7 天 compress_after」的设计下限就是盘上常驻近 14 天原始数据;已压缩的 `[08-27,09-03]` 从 1.04 GB → **1.5 MB**(LOCF 重复度极高,~700:1)。任务 1005 健康(30 成功 / 0 失败)。**2026-09-21 复查已通过**:`_hyper_4_6_chunk`(09-03→09-10)`is_compressed=t`(32 kB),`_hyper_4_10_chunk`(09-10→09-17,1557 MB)按 09-24 合格待压,稳态平台期估算不变(≈4–5 GB);pgdata 实测 3.6 G / 32 G(13%),**无需处理**。可选调优:chunk 间隔 7 天 → 1 天 + `compress_after` → 2 天(`set_chunk_time_interval` 只对新 chunk 生效)。**注意:这是 pgdb 侧对象,HA/scribe 的 `retention_states` 管不到它;HA 侧唯一杠杆是少记/少画(等于砍图)。**
|
2026-08-29 14:51:20 +08:00
|
|
|
|
- 2026-08-29:HA 侧 HACS 集成 `custom_components.scribe`(YAML `scribe: db_url:`,连 `scribe` 库)建表被拒(`permission denied for schema public`,hass 无 CREATE 权限),之后持续报 `relation "entities" does not exist`。**已解决**:① `GRANT CREATE ON SCHEMA public TO hass;`(scribe 库)② 重启 HA Core 触发重跑建表。重启后自动创建 `entities`(1591 行)/`users`/`areas`/`devices`/`integrations`/`states_raw` 表并启用 TimescaleDB 时间序列能力。报错已停止(最后一条 06:06 UTC),`states_raw` 持续写入。2026-08-29 复查:scribe 现有**两个** hypertable——`states_raw`(segmentby `metadata_id`、orderby `time`)与 `events`(segmentby `event_type`、orderby `time`),均 1 维 `time`;压缩已配置(`timescaledb_information.compression_settings` 可见对应行;2.29.x 该视图无 `compression_enabled` 列)。
|
2026-08-29 15:54:40 +08:00
|
|
|
|
- 2026-08-29:**timescale reader 图表对象**(配套 hass 的 `timescale_database_reader` 集成 + `timescale-plotly-card`,上游 SQL `remmob/timescale_database_reader` `SQL/scribe/01+02` @ `bb8776a`,以 postgres 执行):`sensor_minute_aggregate` 连续聚合(1 分钟桶,last(state)/last(value),实时聚合开启)+ `sensor_minute_aggregate_entity` 视图(join `entities`)+ `sensor_minute` hypertable(`minute`/`entity_id`/`state`/`value`,LOCF 前向填充)。任务:1005 `sensor_minute` 压缩(7 天)、1006 `sensor_minute` 保留(10 年)、1007 `every_minute_refresh` 每分钟增量刷新(含 5 分钟回溯窗口修正)。授权:`GRANT SELECT ON sensor_minute_aggregate, sensor_minute_aggregate_entity, sensor_minute, entities TO hass`。种子 19529 行(331 实体,自首个数据点起)。**刻意跳过**了上游脚本对 `states_raw` 的 3 个月保留 + 压缩策略语句——与"`states_raw` 永久归档"定位冲突,如需磁盘回收属用户决策(scribe 自己的压缩任务 1000/1001 未动)。
|
|
|
|
|
|
- 2026-08-29:**`sensor_minute_refresh` 本地补丁(类比 tianqi 补丁,重跑上游 02 SQL 后需重打)**:值 CASE 的 `ELSE 0` → `ELSE NULL`。原因:scribe 对 unavailable 分钟 value 为 NULL,上游刷新过程兜底写 0;对差分模式的用电图,0→计数器回升会把插座的**生命周期累计值**(最高 1588 kWh)算进掉线那一小时。同日一次性清理既有脏 0:头部占位行 DELETE 505 行(各实体首次非零分钟之前的 value=0);`sensor.%_energy` 与温湿度实体的 value=0 → NULL(10+16 行,物理上不可能的真 0,图表渲染为断点)。功率实体的中途 0 是真实待机读数,保留。
|
2026-08-29 14:51:20 +08:00
|
|
|
|
- `hass` 库的 recorder 表仍为普通表(无 hypertable);`scribe` 集成负责时间序列历史(`states_raw` + `events` hypertable)。
|
|
|
|
|
|
|
|
|
|
|
|
## Verification history
|
|
|
|
|
|
|
2026-09-13 19:12:32 +08:00
|
|
|
|
- 2026-08-31:**13:58 重启根因确认,非停电**(W1N-263):pve2(`192.168.55.25`)任务日志显示 08-30 **13:58:00 `root@pam` 在 PVE Web UI 修改 VM 100 配置**(`-delete allow-ksm,balloon,shares -memory 3072`),**13:58:06 点 Reboot**(`qmreboot` → 客机 13:58:08 干净 ACPI 关机 → 13:58:13 自动重启)。宿主机全程在线(08-30 09:00 开机至今连续运行 1d12h+),`.66.26` PVE 及各 VM 均无重启——排除停电。HA recorder 在窗口(13:58:46–47)报 2 次 `Connection refused`,DB 恢复后自动重连,**无数据丢失**(`hass.states`/`scribe.states_raw` 13:55–14:02 逐分钟无缺口,recorder 内存队列吸收回写)。13:58:47 三容器已起,13:58:56 自愈单元 `pgdb-compose.service` 执行成功——本次自愈按设计工作。同日下午 12:54–12:55 另有一次**客机内自重启**(无 PVE 任务,工作站 SSH 会话相邻)。08-29 22:19→08-30 09:00 宿主机停机 10h41m 为**干净关机**(systemd 有序关闭,非停电)。
|
2026-08-30 13:19:05 +08:00
|
|
|
|
- 2026-08-30:**开机竞态故障 + 修复**(W1N-260):09:01 开机后 docker 恢复容器时绑定 `192.168.55.15:5432/8081` 失败(EADDRNOTAVAIL)→ timescaledb/pgweb 停摆至 12:16,pg-backup 开机备份失败(解析不到 timescaledb)→ unhealthy。12:22 `docker compose up -d --force-recreate` 修复(三容器回 `database_default`、端口发布、今日备份、pgweb 恢复);用户重启 HA Core 后写入管道恢复。12:43 新增开机自愈 unit `pgdb-compose.service`(enabled,已实测幂等 reconcile)。pgdb-health 8 项全绿。
|
2026-08-29 14:51:20 +08:00
|
|
|
|
- 2026-08-29:首次检查(只读)+ 修复 scribe 权限 + 安装夜间备份。见 Linear vps 项目登记。
|
|
|
|
|
|
- 2026-08-29:**compose 改造完成**(W1N-227,用户已验收):裸 `docker run` → `/opt/database/docker-compose.yml` 三服务(timescaledb + pgweb + pg-backup);superuser 换强密码;端口收紧 IPv4;备份容器化(TZ=Asia/Shanghai,cron 02:00 本地);`pg_restore` 还原实测通过;pgweb UI 用户确认可查 hass/scribe 数据。源码在仓库 `compose/pgdb/`。
|
|
|
|
|
|
- 2026-08-29:**运维 runbook 落地**(W1N-228,已验收):新增 `runbooks/pgdb-health.md`(只读,8 项诊断全绿)、`pgdb-restore.md`(流程式,temp-DB 安全还原 + 审批门)、`pgdb-update.md`(门控命令式,回滚=/opt/database/run + 旧卷);README 索引与 validate-repo.sh 分类同步更新;runbook 命令已对活主机逐条实测(含 `pg_restore -l` 校验当日 dump)。同日修正:SSH key auth 可用(facts 原记"密钥未安装"已过时);scribe 新增 `events` hypertable。
|
2026-08-30 13:19:05 +08:00
|
|
|
|
- 2026-08-29:**CSG 长期归档 + recorder 365d**(W1N-243):建 `csg_history` 表 + attributes 回填(逐日 59 + 逐月 19)+ 每日任务 1008(函数 v2:最新有值行读取、日费用阶梯回退);hass `purge_keep_days` 30→365(备份 `configuration.yaml.bak-20260829-purge365`)。见 Linear vps W1N-243。
|