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 失败 (唯一红为隐私扫描的存量远端引用,本地历史已脱敏干净,待推送后消除)。
95 lines
6.3 KiB
SQL
95 lines
6.3 KiB
SQL
-- ==================== 幂等增量迁移(**每次启动都跑**)====================
|
||
--
|
||
-- ⚠️ 为什么要单独一个文件(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;
|