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 失败 (唯一红为隐私扫描的存量远端引用,本地历史已脱敏干净,待推送后消除)。
150 lines
6.9 KiB
JavaScript
150 lines
6.9 KiB
JavaScript
/**
|
||
* 墨观 指标字段清单 —— 全链路单一真相源(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,
|
||
};
|