07 · .ndv 二进制格式与统一加载器:让数据真正可交换
阅读时长 :约 35 分钟
前置知识 :会读 JSON、知道追踪事件长什么样(前几篇)。本篇偏"格式设计与互操作",是进阶内容,但我会把二进制概念讲得非常接地气。
本篇目标 :讲清楚三件事------①怎么手工设计一个紧凑二进制格式(
.ndv);②为什么字符串驻留能省一半体积;③怎么让三种来源(NodeVerdict JSON / OTel JSON / .ndv)一个入口通吃。
目录
- 问题:JSON 对追踪数据并不友好
- 核心思路:字符串驻留(intern),先打个比方
- 设计
.ndv的整体布局(逐字节拆解) - flags 位掩码:让每条记录"减肥"
- 编码器:怎么把事件写成二进制
- 解码器:怎么读回来(带校验)
- 统一加载器:三种格式一个入口
- OTel 适配:一个 span 变两个事件
- 总结 + 下篇预告
1. 问题:JSON 对追踪数据并不友好
1.1 两个"浪费"
追踪数据有两个特点,让 JSON 很吃亏:
- 字符串重复 :几千条事件共享同一批频道名(
mysql2:query)、operationId。JSON 里每条都要重复写一遍完整字符串。 - 结构冗长 :
{"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.ts 用 detectTraceFormat 尝试识别:
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,而是靠文本解析和数学统计。
本篇附赠:动手练习
- 在跟踪查看器上传任意追踪,点"导出 .ndv(二进制)",再用文本编辑器打开,观察开头的
NDV魔数。 - 重新导入该
.ndv,确认数据无损往返。 - 拖入
examples/otel-distributed-trace.json,观察加载器是否自动识别为 OTel 格式。