NodeVerdict | .ndv 二进制格式:为 WASM 解码器设计的紧凑布局

07 · .ndv 二进制格式与统一加载器:让数据真正可交换

阅读时长 :约 35 分钟

前置知识 :会读 JSON、知道追踪事件长什么样(前几篇)。本篇偏"格式设计与互操作",是进阶内容,但我会把二进制概念讲得非常接地气。

本篇目标 :讲清楚三件事------①怎么手工设计一个紧凑二进制格式(.ndv);②为什么字符串驻留能省一半体积;③怎么让三种来源(NodeVerdict JSON / OTel JSON / .ndv)一个入口通吃。


目录

  1. 问题:JSON 对追踪数据并不友好
  2. 核心思路:字符串驻留(intern),先打个比方
  3. 设计 .ndv 的整体布局(逐字节拆解)
  4. flags 位掩码:让每条记录"减肥"
  5. 编码器:怎么把事件写成二进制
  6. 解码器:怎么读回来(带校验)
  7. 统一加载器:三种格式一个入口
  8. OTel 适配:一个 span 变两个事件
  9. 总结 + 下篇预告

1. 问题:JSON 对追踪数据并不友好

1.1 两个"浪费"

追踪数据有两个特点,让 JSON 很吃亏:

  1. 字符串重复 :几千条事件共享同一批频道名(mysql2:query)、operationId。JSON 里每条都要重复写一遍完整字符串
  2. 结构冗长{"channel": "mysql2:query", "eventType": "start", ...},每条都要带一遍键名。

打个比方:你要打印 100 张快递单,每张都手写一遍"收件人:张三,地址:北京......"(重复字符串)。是不是很蠢?如果印了一本"地址册",每张单据只写"编号 7",省多少纸?

1.2 实测数字

NodeVerdict 的 .ndv 大约能把同数据压到 JSON 体积的 45%------省超过一半。

核心思路一句话:字符串驻留(intern) ------重复字符串只存一份,事件里用整数索引引用。


2. 核心思路:字符串驻留,先打个比方

2.1 什么是"驻留"(intern)

打个比方:全班同学的名字很长,你不想每次都喊全名。你发了一张"学号表":

text 复制代码
学号 1 → mysql2:query
学号 2 → ioredis:command
学号 3 → SELECT * FROM users

从此以后,大家喊"1 号上课了"就行,不用喊"mysql2:query 上课了"。一个整数(学号)代表一个长字符串。

2.2 在代码里

用一个"名字池"(pool)+ 一张"名字→学号"的查表:

typescript 复制代码
const pool = { list: [], index: new Map() };

function intern(pool, value) {
  if (!value) return 0;
  const existing = pool.index.get(value);   // 查学号表
  if (existing !== undefined) return existing;  // 已经有了,直接用
  const idx = pool.list.length;             // 新名字,分配新学号
  pool.list.push(value);
  pool.index.set(value, idx);
  return idx;
}

之后写事件时,频道名不写字符串,写 intern(pool, 'mysql2:query') 得到的学号


3. 设计 .ndv 的整体布局(逐字节拆解)

3.1 先说什么叫"二进制紧凑格式"

打个比方 :JSON 像"文字版合同"(每个字段都有名字,人类可读);.ndv 像"表格版合同"(固定列宽、只写数字,机器高效)。二进制格式节省了"列名"和"字符串重复"两部分空间。

3.2 整体结构(小端序)

.ndv 是"段式布局"------分成几大块依次排布:

text 复制代码
Header (16 字节)         ── 文件信息区
String table             ── 字符串驻留区(学号表)
Events                   ── 事件记录区(定长记录)

3.3 Header 16 字节逐个拆

text 复制代码
偏移    大小  内容              说明
0      1    'N'              魔数(magic)字符 N
1      1    'D'              魔数 D
2      1    'V'              魔数 V
3      1    version=1        格式版本号
4      1    flags             标志位(bit0=是否有 context)
5      1    保留              保留字节
6-7    2    保留             保留
8-11   4    stringCount      字符串表有多少条
12-15  4    eventCount       有多少条事件

为什么要有"魔数"(NDV)? 让解码器一眼认出"这文件是 .ndv,不是别的二进制"。就像文件开头的"签名"。

为什么要有版本号? 格式将来会变,解码器读版本号就知道该按哪种规则解析,避免新老格式混乱。

3.4 String table(学号表)

重复若干条这样的记录:

text 复制代码
u32 byteLength        这个字符串的字节长度
utf8 bytes            字符串本身的字节

3.5 Events(事件记录区)

每条事件是"定长 + 可选"混合记录:

text 复制代码
u8   eventType        事件类型(0=start 1=end 2=asyncStart 3=asyncEnd 4=error)
u32  channelIdx       频道名在字符串表里的学号
f64  timestamp        时间戳(毫秒)
u8   flags             标志位:后面还有哪些可选字段
[可选] 根据 flags 追加     duration / operationIdIdx / errorIdx / contextIdx

关键channelIdx一个 u32 整数,而不是完整字符串。这就是省一半体积的核心。


4. flags 位掩码:让每条记录"减肥"

4.1 问题:不是每条事件都有所有字段

  • 有的事件有 duration,有的没有
  • 有的有 operationId,有的没有
  • 有的有 error,有的没有
  • 有的有 context,有的没有

如果每条事件都完整写一遍,就是浪费。

4.2 解法:一个字节的"位开关"

用一条 u8 flags 的 4 个 bit 标记"后面跟着哪些字段":

bit 掩码 含义 如果置位,追加
0x01 hasDuration f64 duration
0x02 hasOperationId u32 operationIdIdx
0x04 hasError u32 errorIdx
0x08 hasContext u32 contextIdx

打个比方:外卖单有个"勾选"区------你勾哪几样厨师就做哪几样,没勾的就不做(不占篇幅)。

4.3 代码里怎么写 flags

typescript 复制代码
let flags = 0;
if (e.duration !== undefined) flags |= 0x01;   // 有耗时:把 bit0 置 1
if (op !== undefined)          flags |= 0x02;   // 有 operationId:bit1
if (err !== undefined)         flags |= 0x04;   // 有 error:bit2
if (ctx !== undefined)         flags |= 0x08;   // 有 context:bit3
view.setUint8(offset, flags);

4.4 位运算小入门(给不熟的朋友)

  • flags |= 0x01 = 把 flags 的第 0 位改成 1(原来不管 0 还是 1)
  • 按位或(|)来"开某个开关"
  • 解码时用按位与 (&)来"查某个开关是否打开":if (flags & 0x01) { /* 读 duration */ }

这一整套"开关 + 按需追加字段",让记录在保留完整信息的同时,为常见的简短事件省掉大量字节


5. 编码器:怎么把事件写成二进制

5.1 核心:预计算总长度,一次性分配

写数据前,先算出总共要多少字节,然后一次性申请一块内存------避免反复扩大(像一次买够纸而不是边写边拆页):

typescript 复制代码
const total = HEADER_SIZE + strBytes + recordSizes.reduce((a, b) => a + b, 0);
const buf = new ArrayBuffer(total);
const view = new DataView(buf);

5.2 写 Header

typescript 复制代码
view.setUint8(0, 0x4e);   // 'N'
view.setUint8(1, 0x44);   // 'D'
view.setUint8(2, 0x56);   // 'V'
view.setUint8(3, VERSION);          // 版本 1
view.setUint32(8, pool.list.length, true);   // stringCount
view.setUint32(12, events.length, true);     // eventCount

注意 , true 表示小端序(little-endian)。小端序是大多数现代 CPU 的原生字节序,读起来快。

5.3 第一个 pass:先驻留所有字符串

写事件之前先把所有字符串都驻留(收集到 pool 里),这样才知道字符串表有多大、学号是多少:

typescript 复制代码
for (const e of events) {
  intern(pool, e.channel);
  intern(pool, e.operationId);
  if (Object.keys(e.context).length) intern(pool, JSON.stringify(e.context));
  if (e.error) intern(pool, `${e.error.name}: ${e.error.message}`);
}

5.4 第二个 pass:写事件记录

typescript 复制代码
view.setUint8(offset, eventTypeToNum(e.eventType));        // 类型
offset += 1;
view.setUint32(offset, pool.index.get(e.channel), true);    // 频道学号
offset += 4;
view.setFloat64(offset, e.timestamp, true);                 // 时间戳
offset += 8;
// ... 根据 flags 写入 duration / operationId / error / context ...

6. 解码器:怎么读回来(带校验)

读回来时,每一步都要做越界/正确性校验------因为二进制坏一个字节,后面全乱。

6.1 校验魔数和版本

typescript 复制代码
if (view.byteLength < HEADER_SIZE) throw new NdvError('Truncated .ndv header');
if (!(bytes[0]===0x4e && bytes[1]===0x44 && bytes[2]===0x56))
  throw new NdvError('Not a .ndv file (bad magic)');
const version = bytes[3];
if (version !== VERSION) throw new NdvError(`Unsupported version ${version}`);

打个比方:你是海关检验员,先看货物外包装(magic),再查验是不是旧版本货,发现不对立即拒收------而不是硬着头皮往里读导致崩溃。

6.2 读字符串表

typescript 复制代码
for (let i = 0; i < stringCount; i++) {
  if (offset + 4 > view.byteLength) throw new NdvError('Truncated string table');
  const len = view.getUint32(offset, true);   // 读长度
  offset += 4;
  if (offset + len > view.byteLength) throw new NdvError('Truncated string data');
  strings.push(decoder.decode(bytes.subarray(offset, offset + len))); // 读字节转字符串
  offset += len;
}

6.3 读事件

typescript 复制代码
const typeNum = view.getUint8(offset);
const channelIdx = view.getUint32(offset+1, true);
const timestamp = view.getFloat64(offset+5, true);
const flags = view.getUint8(offset+13);
offset += 14;

let operationId;
if (flags & 0x02) {   // hasOperationId
  operationId = strings[view.getUint32(offset, true)];  // 用学号查表还原字符串
  offset += 4;
}
// ... 同理读 error / context ...

关键还原动作strings[学号] ------把整数索引还原成真正的字符串。这就是编码时 intern 的逆过程。


7. 统一加载器:三种格式一个入口

7.1 问题

用户手上可能有三种来源的追踪数据。写三个入口,每个页面都要判断?太麻烦。一个入口通吃最好。

7.2 格式嗅探

data-loader.tsdetectTraceFormat 尝试识别:

typescript 复制代码
export function detectTraceFormat(content) {
  const parsed = JSON.parse(stripBom(content));   // stripBom:去掉 UTF-8 BOM
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
    if (parsed.format === 'ndv') return 'ndv';             // 显式标记 ndv
    if (isOtelExport(parsed)) return 'otel';               // 有 resourceSpans / spans / jaeger data
  }
  return 'nodeverdict';    // 否则就当最普通的 TracingEvent[] 数组
}

打个比方:这是"猜箱子的内容物"。看到里面有"马车形状"就说是 OTel,看到写着 NDV 就说是二进制,都不是就当成最普通的追踪数组。

7.3 统一入口

typescript 复制代码
export function loadTracingData(content) {
  const format = detectTraceFormat(stripBom(content));
  if (format === 'ndv') throw new Error('需要二进制解码,请用 .ndv 上传选项');
  const parsed = JSON.parse(clean);
  if (format === 'otel') return convertOtelToTracingEvents(parsed);
  if (!isEventArray(parsed)) throw new Error('无法识别的格式');
  return parsed;
}

之后所有页面(事件查看器、瀑布图、报告、门禁、AI 根因)都能吃这三种格式,不用各自写解析。


8. OTel 适配:一个 span 变两个事件

8.1 OTel 是什么形态

OTel 的追踪是一个"跨度的树"(span tree)。一个 span 对应一次操作,有 start 和 end。

8.2 转换逻辑

NodeVerdict 把一个 span 转成 start + end/error 两个事件(otel-adapter.ts):

typescript 复制代码
function spanToEvents(span, serviceName) {
  const channel = attr(span, 'nodeverdict.channel') || span.name || 'otel.span';
  const operationId = span.spanId || span.name;
  let start = timeToMs(span.startTimeUnixNano);   // OTel 用纳秒
  let end = timeToMs(span.endTimeUnixNano);
  const duration = end > start ? end - start : 0;
  return [
    { channel, eventType: 'start', timestamp: start, duration, operationId, context },
    isError
      ? { channel, eventType: 'error', ... error: {...} }
      : { channel, eventType: 'end', ... },
  ];
}

8.3 时间单位自动识别(量级启发式)

OTel 不同导出器的时间单位可能是纳秒1.7e18)、**微秒**(1.7e15)或毫秒 (~1.7e12)。怎么辨别?用数量级

typescript 复制代码
function timeToMs(n) {
  if (n >= 1e16) return n / 1e6;   // 纳秒
  if (n >= 1e13) return n / 1e3;   // 微秒
  return n;                          // 毫秒
}

打个比方:三种单位差两个数量级(10 的几百次方)。就像身高单位:一个数 170 → 厘米理解,一个数 1700000 → 微米理解。靠"数位多少"就能判断。

注:1970--2287 年之间的时间戳,纳/微/毫三者数量级区分得非常干净,这个启发式成立。

8.4 支持的三种 OTel 形态

形态 结构
标准 OTLP/JSON resourceSpans[].scopeSpans[].spans[]
扁平数组 { spans: [...] }
Jaeger 风格 { data: [{ traceID, spans }] }

9. 总结 + 下篇预告

9.1 本篇干货清单

设计 解决的问题 打个比方
字符串驻留 重复字符串只存一份 发学号表代替喊全名
flags 位掩码 缺省字段不占字节 外卖单勾选
魔数 + 版本 快速判别 + 兼容控制 海关验货
统一加载器 三种来源归一 猜箱子内容物
量级启发式 OTel 不同时间单位 数位判断身高单位

9.2 配餐数据

  • examples/otel-distributed-trace.json --- OTel 跨服务导出,可直接拖入测试自动识别

9.3 下篇预告

回到"数据分析"向:--trace-gc 日志变成 GC 泄漏报告,以及内存时间线的线性回归检测。 两种数据都不靠结构化 API,而是靠文本解析和数学统计。


本篇附赠:动手练习

  1. 在跟踪查看器上传任意追踪,点"导出 .ndv(二进制)",再用文本编辑器打开,观察开头的 NDV 魔数。
  2. 重新导入该 .ndv,确认数据无损往返。
  3. 拖入 examples/otel-distributed-trace.json,观察加载器是否自动识别为 OTel 格式。
相关推荐
恒拓高科WorkPlus1 小时前
BeeWorks 即时通讯私有化解决方案介绍
安全
冬奇Lab2 小时前
开源项目第180期:Omnigent — Databricks 出品的 AI Agent 元编排层,让 Claude Code、Codex、Cursor 统一管控
人工智能·开源·agent
石逸凡2 小时前
AI驱动的金融IT架构转型升级
人工智能·金融·架构
梁辰兴2 小时前
软件工程:模块独立性
软件工程·设计原则·梁辰兴·模块独立性定义·度量标准·提高方法·设计目标
opensnn2 小时前
当闭源AI遇上开源反击:未来竞争的核心不是模型,而是生态
人工智能·开源
aGdF8E3gQ2 小时前
谈一下关于CQRS架构如何实现高性能
架构
CoordClaw3 小时前
主流多智能体架构为什么大多失败——它们输在结构,不在模型
人工智能·架构
dyxal3 小时前
SSH本地端口转发完全解析:像“挖掘隧道”一样安全访问远程数据库
数据库·安全·ssh
zlinear数据采集卡3 小时前
ZLinear DABM-D223 通信协议与软件开发全解析:从 USB CDC 到 DDS 波形输出
arm开发·嵌入式硬件·fpga开发·开源