Files
inkwatch/deploy/migrate.sql
T
PMon Dev 03b30c1534 fix(dedup): session_id 贯通写入链路,并实测推翻"DB 唯一索引"方案
E1(网关重启后补传帧重复入库)的第一段:把 session_id 落到库。

## 做了什么
- device_metrics 加 session_id 列(init.sql 建表 + migrate.sql 迁移,两条路径同源)。
- gateway 入队指标帧时带上 session.sessionId —— 此前 806 行已经在用这个值判重,
  但没往下传,所以 DB 侧永远拿不到它。
- repository 的 INSERT 列清单加 session_id,并补 ON CONFLICT DO NOTHING。
- 固定头列清单(ts/device_id/seq/session_id)收到 metrics-schema.js 的
  METRIC_TABLE_HEADER_COLUMNS,仓储写入与一致性测试都引用它。

## 关键发现:原定的"DB 唯一索引"方案在本项目行不通
架构评审给的方案是建 (device_id, session_id, seq) 唯一索引。我在真实部署形态
(本机 Docker 的 TimescaleDB,device_metrics 有 262 万行)上实测,直接报错:

  ERROR: cannot create a unique index without the column "ts" (used in partitioning)

原因是 device_metrics 是 **TimescaleDB hypertable** —— pg_inherits 里挂着 6 个
_timescaledb_internal._hyper_1_*_chunk,hypertable 按 ts 分区,而 PostgreSQL
硬性要求分区表上的唯一索引必须包含分区键。把 ts 放进唯一键同样不行:
ts 由服务端改写,补传同一帧时 ts 不同,唯一键就失去幂等语义 ——
骨架期那条"不加唯一约束因为 ts 会被改写"的注释想说的正是这点,
它错在把结论下成了"所以不能建",而正确结论是"唯一键不该含 ts,改由应用层承担"。

顺带纠正一个认知:pg_relation_size 在本机报 0 bytes 是 Docker Desktop
VirtioFS 的统计怪象,262 万行是真实的(count(*) 45ms,全在 page cache)。
排查时不要被那个 0 带偏。

## 因此 ON CONFLICT DO NOTHING 是"安全网"而非本层去重手段
现在是 no-op(没有唯一约束)。留着是因为 PG 的 INSERT 是语句级原子:
一批 5000 帧里只要有 1 行撞约束,整条语句抛错、_flushStorage 只 catch 掉
打一行日志 → 整批(含合法帧)全丢。将来若在普通 postgres 分支补上约束,
没有这一句会立刻变成"一条脏数据毁掉整批"。

## 尚未完成(如实标注)
真正的幂等防线要挪到**网关侧持久化去重窗口**(原进程内 Map 重启即清空)。
方案已定:seq 窗口按 (deviceId, sessionId) 落 Redis,用 ZADD NX 的返回值
做原子判重,并并入 pushFrameAndFanout 那个已有的 multi —— 不新增往返
(本项目历史教训就是每帧多次 Redis 往返把帧率上限按死了)。
但这次没做完:_isDuplicateSeq 目前是同步函数、被 seq-session-dedup-test.js 的
28 项直接测,改成异步会波及那批测试,需要单独一轮谨慎处理。

## 验证
本机 TimescaleDB 实测:ALTER TABLE ADD COLUMN 3.67ms(PG16 元数据操作,不重写表);
自带的去重 CTE 在有 262 万行的表上耗时 2.85s(seq scan),这也是为什么
"部分索引只覆盖新数据"的思路值得 —— 但 hypertable 不支持,最终没用上。
schema-consistency 107/0;seq-session-dedup 28/0;run-all.sh 28 套件 27 通过/1 失败
(唯一红为隐私扫描的存量远端引用,本地历史已脱敏干净,待推送后消除)。
2026-10-26 21:18:48 +08:00

95 lines
6.3 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ==================== 幂等增量迁移(**每次启动都跑**)====================
--
-- ⚠️ 为什么要单独一个文件(2026-09-15 修的运维坑)────────────────────────
-- 迁移段原先只写在 `init.sql` 末尾,而 `init.sql` 挂在
-- `/docker-entrypoint-initdb.d/` —— **那个目录只在数据目录为空时才执行**。
-- 也就是说:**已有库上,这段代码从来没跑过**,全靠人手敲 SQL。
--
-- 后果不是"少个字段",而是**静默的链路中断**:新增 `devices.hardware_id` 后,
-- `registerDevice` 会写一个不存在的列 → SQL 报错 → 网关侧 catch 掉只记日志
-- (`[gateway] 设备落库失败`)→ **设备不再落库,而服务健康检查全绿**。
-- 与 `init.sql` 里那段"首次部署才走、结果云上全废"是同一个病:
-- 凡是"只在某种时序下执行"的代码,都必须有一个不受时序影响的执行点。
--
-- 故:本文件是**所有 schema 变更的唯一落点**,
-- · 全新库:`init.sql` 末尾 `\i /migrate.sql` 走一遍;
-- · 已有库:compose 里的 `migrate` 一次性服务在 postgres healthy 后跑一遍。
-- 两条路径共用同一份定义(单一真相源),且全部语句幂等(可反复执行)。
--
-- ⚠️ 纪律:**新增任何列/索引/表,都加到这里**(而不是只改上面的 CREATE TABLE,
-- 那只对全新库有效)。改完跑 `bash tests/run-all.sh` ——
-- `schema-consistency-test.js` 会校验建表顺序与清单一致性。
-- ---- 指标列(骨架期只有 7 列,权威清单有 17 个字段,缺列的指标只能落在 payload)----
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS mem_used_mb DOUBLE PRECISION;
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS mem_total_mb DOUBLE PRECISION;
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS disk_free_gb DOUBLE PRECISION;
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS disk_total_gb DOUBLE PRECISION;
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS disk_write_mbps DOUBLE PRECISION;
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS battery_temp DOUBLE PRECISION;
-- ---- 账号体系的凭据列(新表走 CREATE TABLE IF NOT EXISTS,已存在的库只需补列)----
ALTER TABLE users ADD COLUMN IF NOT EXISTS secret_hash TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS secret_salt TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS secret_updated_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ;
-- ---- 设备硬件指纹(2026-09-15)----
-- 用途见 `server/shared/device-identity.js`:让服务端能认出"这还是同一台机",
-- 从而把同一台设备因 device_id 漂移产生的多条记录归并掉。
--
-- ⚠️ 存量行留 NULL,**不要**拿 device_id 回填:device_id 是**派生的、会变的值**,
-- 拿它当"稳定指纹"等于把 bug 固化进数据。NULL 只是让它走"后缀兜底"判据。
ALTER TABLE devices ADD COLUMN IF NOT EXISTS hardware_id TEXT;
CREATE INDEX IF NOT EXISTS idx_devices_hardware ON devices(owner_id, hardware_id);
-- ── 日志与可观测性(2026-10-17)────────────────────────────────────
-- log_line_prefix:单容器里 5 个进程日志共用一个流,PG 默认格式没有标识,
-- 排查时要靠猜。加 [postgres] 前缀后与 [gateway]/[business]/[supervisor] 对齐。
-- log_checkpoints=off:每 5 分钟两条 checkpoint 日志是纯噪音(默认 off,
-- 某些镜像会开),关掉后 docker logs 里业务日志一眼可见。
-- log_min_duration_statement:**慢查询落日志**。不依赖 pg_stat_statements
-- (那要装扩展+重启),超过 200ms 的语句直接出现在日志里,是排查
-- "为什么这次请求慢"最省事的抓手。
ALTER SYSTEM SET log_line_prefix = '[postgres] %m [%p] ';
ALTER SYSTEM SET log_checkpoints = off;
ALTER SYSTEM SET log_min_duration_statement = 200;
SELECT pg_reload_conf();
-- ── 断线补传幂等:session_id 列(2026-10-25)────────────────────────
--
-- 背景见 init.sql 里 idx_metrics_device_seq 的注释:网关进程内的 seq 去重窗口
-- **重启即清空**,而客户端 session_id 不变、会重发最多 120 帧补传
-- → 全部被判成新 seq → 重复入库 → 历史回溯的均值被慢慢拉偏,且不报任何错。
--
-- ⚠️ 为什么**不在 DB 层建唯一索引**(2026-10-25 在生产形态上实测,勿再尝试)
--
-- `device_metrics` 在 TimescaleDB 分支上是 **hypertable**(实测 pg_inherits 里挂着
-- 多个 `_timescaledb_internal._hyper_1_*_chunk`)。hypertable 按 `ts` 分区,
-- 而 PostgreSQL 硬性要求:**分区表上的唯一索引必须包含分区键**。实测报错——
-- ERROR: cannot create a unique index without the column "ts"
-- (used in partitioning)
--
-- 把 ts 加进唯一键同样不行:ts 由服务端改写,补传同一帧时 ts 不同,
-- 唯一键就失去幂等语义 —— 骨架期那条注释「不加唯一约束因为 ts 会被改写」
-- 想说的正是这一点,只是它把结论下成了「所以不能建」,
-- 而正确结论是「所以唯一键不该含 ts,改由应用层承担」。
--
-- 另注意:唯一索引这条路在**普通 postgres 镜像**上本来可行,
-- 但两种镜像必须共用同一份 schema 定义(单一真相源),
-- 不能一边建索引一边不建 —— 否则两套部署的幂等语义就分叉了。
--
-- → 幂等改由**网关侧持久化去重窗口**承担(见 gateway.js 的 `_seqWindow`),
-- `session_id` 是那个窗口 key 的组成部分。
--
-- 本列仍然有价值,不只是给去重当 key:
-- ① 区分「同一会话内重连补传」与「换了进程后的新流」—— 两者去重语义不同
-- (前者要判重,后者要重置窗口,否则新进程的 seq 从 0 开始会被全判成重复);
-- ② 诊断时能看出一次 agent 进程生命周期的边界。
--
-- 两条执行路径(init.sql / 本文件)共用同一份列定义,语句幂等。
ALTER TABLE device_metrics ADD COLUMN IF NOT EXISTS session_id TEXT;