Files
inkwatch/shared/metrics-schema.js
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

150 lines
6.9 KiB
JavaScript
Raw Permalink 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.
/**
* 墨观 指标字段清单 —— 全链路单一真相源(Single Source of Truth)
*
* ⚠️ 为什么需要这个文件(真实缺陷记录,务必读完再改字段)
*
* 骨架期三处各写了一套指标命名,且互不相通:
* - Rust 采集层 `cpu_total` / `mem_used_pct` / `net_tx_bps` / `disk_free_gb`
* - 协议白名单 `cpu_usage` / `mem_usage` / `net_tx_mbps`
* - 时序表列 `cpu_usage` / `mem_usage` / `net_tx_mbps`
* 三者交集只有 `cpu_cores` / `mem_used_mb` / `mem_total_mb` 三个字段。
*
* 后果:真实采集帧进入 `sanitizePayload()` 后,cpu / 内存占用 / 磁盘 / 网络
* 全部被判为"未声明字段"**静默丢弃**,而 device_metrics 里 cpu_usage 等列
* 恒为 NULL —— 端到端链路看似有数据,实则只剩三个字段。
* 这正是 protocol.js 早期注释里警告过的"静默丢数据",且不会报任何错。
*
* 单位也在漂移:Rust 给的是字节/秒,DB 列口径是 Mbps,缺一次换算。
*
* 自本源起,三处一律由本清单派生,禁止再各自硬编码:
* - 协议白名单 ← 本文件 METRIC_WHITELIST(shared/protocol.js 直接引用)
* - Rust 采集字段 ← rust-core/src/collector.rs 的 MetricsFrame 字段名
* - 时序表列 ← deploy/init.sql 中 dbColumn 非空的同名列
* - 仓储写入 ← server/shared/repository.js 的 INSERT 列清单
*
* 一致性由 `tests/schema-consistency-test.js` 交叉校验并锁定:
* 任一处漂移即测试失败,不允许再出现"改了一处忘了另三处"。
*/
'use strict';
/** 指标值形态:数值 / 同质数组(数组整体量化,见 codec.js) */
const METRIC_KIND = Object.freeze({
NUMBER: 'number',
ARRAY: 'array',
});
/**
* 权威指标清单。
*
* @property key 权威字段名 —— 协议 payload 键,同时必须是 Rust MetricsFrame 字段名
* @property kind 数值形态,供编解码与查询层判断
* @property unit 单位口径(文档/UI 展示用;写入前必须按此单位换算)
* @property dbColumn 时序表列名;null 表示不单独建列,仅随 payload JSONB 落库
* @property group 指标分组(CPU / 内存 / GPU / 磁盘 / 网络 / 温度 / 其它)
*/
const METRICS_SCHEMA = Object.freeze([
// ---- CPU ----
{ key: 'cpu_usage', kind: METRIC_KIND.NUMBER, unit: '%', dbColumn: 'cpu_usage', group: 'cpu' },
{ key: 'cpu_cores', kind: METRIC_KIND.ARRAY, unit: '%', dbColumn: null, group: 'cpu' },
{ key: 'cpu_temp', kind: METRIC_KIND.NUMBER, unit: '℃', dbColumn: 'cpu_temp', group: 'cpu' },
// ---- 内存 ----
{ key: 'mem_usage', kind: METRIC_KIND.NUMBER, unit: '%', dbColumn: 'mem_usage', group: 'mem' },
{ key: 'mem_used_mb', kind: METRIC_KIND.NUMBER, unit: 'MB', dbColumn: 'mem_used_mb', group: 'mem' },
{ key: 'mem_total_mb', kind: METRIC_KIND.NUMBER, unit: 'MB', dbColumn: 'mem_total_mb', group: 'mem' },
// ---- GPU(V1.0 未接入采集,字段先占位以冻结协议) ----
{ key: 'gpu_usage', kind: METRIC_KIND.NUMBER, unit: '%', dbColumn: 'gpu_usage', group: 'gpu' },
{ key: 'gpu_temp', kind: METRIC_KIND.NUMBER, unit: '℃', dbColumn: null, group: 'gpu' },
{ key: 'gpu_mem_usage', kind: METRIC_KIND.NUMBER, unit: '%', dbColumn: null, group: 'gpu' },
// ---- 磁盘 ----
{ key: 'disk_free_gb', kind: METRIC_KIND.NUMBER, unit: 'GB', dbColumn: 'disk_free_gb', group: 'disk' },
{ key: 'disk_total_gb', kind: METRIC_KIND.NUMBER, unit: 'GB', dbColumn: 'disk_total_gb', group: 'disk' },
{ key: 'disk_read_mbps', kind: METRIC_KIND.NUMBER, unit: 'MB/s', dbColumn: 'disk_read_mbps', group: 'disk' },
{ key: 'disk_write_mbps', kind: METRIC_KIND.NUMBER, unit: 'MB/s', dbColumn: 'disk_write_mbps', group: 'disk' },
// ---- 网络(注意单位是 Mbps,不是字节/秒) ----
{ key: 'net_rx_mbps', kind: METRIC_KIND.NUMBER, unit: 'Mbps', dbColumn: 'net_rx_mbps', group: 'net' },
{ key: 'net_tx_mbps', kind: METRIC_KIND.NUMBER, unit: 'Mbps', dbColumn: 'net_tx_mbps', group: 'net' },
// ---- 温度(增强项,见方案 7.2:按平台能力置 null,不虚假上报) ----
{ key: 'battery_temp', kind: METRIC_KIND.NUMBER, unit: '℃', dbColumn: 'battery_temp', group: 'temp' },
// ---- 其它 ----
{ key: 'uptime_s', kind: METRIC_KIND.NUMBER, unit: 's', dbColumn: null, group: 'other' },
]);
/** 全部权威字段名(保持清单顺序,便于与 Rust 结构体对照) */
const METRIC_KEYS = Object.freeze(METRICS_SCHEMA.map((m) => m.key));
/** 协议白名单集合 —— protocol.sanitizePayload 直接使用 */
const METRIC_WHITELIST = new Set(METRIC_KEYS);
/** key → 单位,供换算与展示 */
const METRIC_UNITS = Object.freeze(
Object.fromEntries(METRICS_SCHEMA.map((m) => [m.key, m.unit]))
);
/** key → 时序表列名(仅含单独建列的指标) */
const METRIC_DB_COLUMNS = Object.freeze(
Object.fromEntries(
METRICS_SCHEMA.filter((m) => m.dbColumn).map((m) => [m.key, m.dbColumn])
)
);
/** 时序表中属于"指标列"的列名集合 —— 用于与 init.sql 交叉校验 */
const METRIC_DB_COLUMN_SET = new Set(Object.values(METRIC_DB_COLUMNS));
/**
* `device_metrics` 的**固定头列**(顺序即 INSERT 参数顺序)。
*
* 它们不是"采到的指标",所以不在 [METRICS_SCHEMA] 里;但仓储写入
* (`repository.js` 的 INSERT 列清单)、建表(`init.sql`)、迁移
* (`migrate.sql`)和一致性校验(`schema-consistency-test.js`)
* 必须共用**同一份**,否则又是一次"同一清单写多处"。
*
* 历史:2026-10-25 加 `session_id`(断线补传幂等判据)时,测试里另写了一份
* `['ts','device_id','seq','payload']`,于是新列被判成"表独有"。
* 那次修改把清单收到了这里,测试改为引用。
*/
const METRIC_TABLE_HEADER_COLUMNS = ['ts', 'device_id', 'seq', 'session_id'];
/**
* 非指标列(固定头 + payload 原文)。
* 一致性校验用它把表列里的"基础设施列"排除掉,只看指标列。
*/
const METRIC_TABLE_NON_METRIC_COLUMNS = [
...METRIC_TABLE_HEADER_COLUMNS,
'payload',
];
/** 是否为已声明的指标字段 */
function isMetricKey(key) {
return METRIC_WHITELIST.has(key);
}
/**
* 字节/秒 → Mbps(1 Mbps = 10^6 bit/s)
* 网络速率必须按此换算后再上报,否则 DB 列口径与采集值差 8 个数量级。
*/
function bytesPerSecToMbps(bytesPerSec) {
if (typeof bytesPerSec !== 'number' || !Number.isFinite(bytesPerSec)) return null;
return Math.round((bytesPerSec * 8) / 1e6 * 100) / 100;
}
module.exports = {
METRIC_KIND,
METRICS_SCHEMA,
METRIC_KEYS,
METRIC_WHITELIST,
METRIC_UNITS,
METRIC_DB_COLUMNS,
METRIC_DB_COLUMN_SET,
METRIC_TABLE_HEADER_COLUMNS,
METRIC_TABLE_NON_METRIC_COLUMNS,
isMetricKey,
bytesPerSecToMbps,
};