Day 59 · 二进制协议 — TypedArray 视角下的传感器数据编码
传感器一秒推一次 JSON 没什么问题;但振动传感器是 1kHz 起步、一个网关下有几百个通道——JSON 的体积和解析成本立刻爆表。今天用第 2 月 Week 6 打下的 ImageData/TypedArray 地基,设计一条高频传感器数据的二进制通道:帧怎么设计、字节怎么编解码、和 JSON 的体积/性能差距实测多少。学完今天,“二进制"从"知道有这个东西"变成"能自己设计协议”。
目录
- 一、为什么 JSON 撑不住高频数据
- 二、二进制基础回顾:ArrayBuffer 家族
- 三、设计一个传感器帧协议
- 四、编码器实现(客户端/服务器通用)
- 五、解码器实现(含边界校验)
- 六、JSON vs 二进制:实测对比
- 七、常见坑点
- 八、自测挑战
- 九、总结
一、为什么 JSON 撑不住高频数据
1.1 一笔体积账(1000 点/秒的振动通道)
// JSON 版:每点一个对象
{"ts":1756684800123,"ch":"vib-01","v":3.14159,"q":1}
// ≈ 48 字节(UTF-8)
// 二进制版:每点 8 字节
// float64 时间戳(4字节截断) + float32 数值 + 通道/质量位打包
单通道每秒差 40KB,200 通道的网关每秒差 8MB——内网也扛不住,还全是要 GC 的字符串垃圾。
1.2 解析成本的差距
| 环节 | JSON | 二进制 |
|---|---|---|
| 接收 | 字符串 → JSON.parse → 每个点都是新对象(GC 压力) |
ArrayBuffer → 视图直接读,零拷贝 |
| 输出 | 1000 点 = 1000 个对象 | 1000 点 = 1 个 Float32Array |
JSON.parse 出来的对象树在 10k 点/秒的量级下会制造可观的 GC 停顿(掉帧的隐形元凶——Day 54 图表优化救不了数据层的 GC)。
1.3 什么时候仍然用 JSON
| 场景 | 推荐 |
|---|---|
| 1Hz 的面板快照、控制指令、低频告警 | JSON(可读、可调试、成本可忽略) |
| 高频通道数据(≥50Hz)、批量历史数据 | 二进制 |
| 混合(BOSS 战方案) | 一帧内 JSON 头 + 二进制体的复合协议 |
二、二进制基础回顾:ArrayBuffer 家族
Week 6 做像素操作时的知识,今天换个战场用:
/**
* 二进制数据的三层结构
*/
const buf = new ArrayBuffer(16); // ① 缓冲区:16 字节的裸内存(不能直接读写)
const f32 = new Float32Array(buf); // ② 类型化视图:按 float32 解释这段内存
// f32.length === 4(16 字节 / 4 字节每元素)
const dv = new DataView(buf); // ③ 任意视图:按字节偏移 + 指定类型读写(混排协议用它)
dv.setFloat64(0, 3.14159); // 第 0 字节起写 float64
dv.setUint16(8, 7); // 第 8 字节起写 uint16
混合字段必须用 DataView
TypedArray 视图只能"整段同类型";协议里时间戳(float64) + 通道号(uint16) + 数值(float32) 混排时,DataView 是唯一选择。
字节序(Endianness)
// 网络协议惯例:大端(高位字节在前)
// x86/ARM CPU:小端
// TypedArray 跟随本机字节序(几乎都是小端)
// DataView 可显式指定:
dv.setFloat32(0, 3.14, false); // false = 大端(网络序)
dv.getFloat32(0, false); // 读写必须一致!
🎯 同源约定可偷懒,跨端协议必须显式:浏览器和 Node 都是 x86 小端时 TypedArray 直传没事;但协议文档里必须写死字节序——将来接嵌入式网关(可能是大端 MCU)时这就是救命的一行字。
三、设计一个传感器帧协议
3.1 需求
一个二进制帧携带:帧类型 + 时间戳 + 通道 ID + N 个采样点(时间均匀,只存首时间戳 + 采样率,点内只存值——压缩的核心思想)。
3.2 帧结构(协议即文档)
偏移 长度 类型 字段
─────────────────────────────────────────────
0 2 uint16(大端) magic = 0xFA01(帧识别:防止脏数据当帧解析)
2 1 uint8 frameType:1=通道数据 2=补传数据(Day 61 用)
3 1 uint8 保留
4 8 float64(大端) startTs:首采样点时间戳(毫秒)
12 4 uint32(大端) sampleRate:采样率(Hz)
16 2 uint16(大端) channelId
18 2 uint16(大端) pointCount:本帧点数 N
20 4×N float32(大端) values:N 个采样值
─────────────────────────────────────────────
帧总长 = 20 + 4 × N 字节
3.3 体积对比(100 点/帧)
| 方案 | 体积 |
|---|---|
| JSON(100 个对象数组) | ≈ 1800 字节 |
| 本协议(20 头 + 400 体) | 420 字节(4.3 倍压缩) |
| 且:点数越多压缩比越高(头是固定成本) | 1000 点/帧 → 9 倍 |
四、编码器实现(客户端/服务器通用)
/**
* 传感器帧编码器:把通道数据打包成二进制帧
* 服务器(Node)与浏览器共用此模块——协议代码单源,防两端不一致
*/
export const MAGIC = 0xfa01;
export function encodeChannelFrame(
frameType: number, // 1=实时 2=补传
startTs: number,
sampleRate: number,
channelId: number,
values: Float32Array
): ArrayBuffer {
const byteLen = 20 + values.length * 4;
const buf = new ArrayBuffer(byteLen);
const dv = new DataView(buf);
dv.setUint16(0, MAGIC, false); // 大端:false
dv.setUint8(2, frameType);
dv.setUint8(3, 0); // 保留位
dv.setFloat64(4, startTs, false);
dv.setUint32(12, sampleRate, false);
dv.setUint16(16, channelId, false);
dv.setUint16(18, values.length, false);
// 逐点写入 float32(大端)
for (let i = 0; i < values.length; i++) {
dv.setFloat32(20 + i * 4, values[i], false);
}
return buf;
}
五、解码器实现(含边界校验)
5.1 核心实现
/**
* 传感器帧解码结果
*/
export interface ChannelFrame {
frameType: number;
startTs: number;
sampleRate: number;
channelId: number;
values: Float32Array; // 解码出的采样值(视图零拷贝引用)
}
/**
* 解码:从 ArrayBuffer 解析出一帧
* @returns null 表示不是合法帧(magic 不匹配/长度不足)
*/
export function decodeChannelFrame(buf: ArrayBuffer): ChannelFrame | null {
// 边界校验:头都不够,直接判废
if (buf.byteLength < 20) return null;
const dv = new DataView(buf);
if (dv.getUint16(0, false) !== MAGIC) return null; // 脏数据防线
const pointCount = dv.getUint16(18, false);
const expectedLen = 20 + pointCount * 4;
// 边界校验:声明长度与实际长度不符 → 截断帧(网络层理论不截,但防御性编程)
const n = Math.min(pointCount, Math.floor((buf.byteLength - 20) / 4));
// 零拷贝:直接在原 buffer 上开视图(不 new 新数组,避免高频 GC)
const values = new Float32Array(buf, 20, n);
return {
frameType: dv.getUint8(2),
startTs: dv.getFloat64(4, false),
sampleRate: dv.getUint32(12, false),
channelId: dv.getUint16(16, false),
values,
};
}
5.2 解码后如何还原每点时间戳
协议里只存了 startTs + sampleRate,第 i 个点的时间是算出来的:
// 第 i 个采样点的时间戳(毫秒)
const tOf = (i: number): number => startTs + (i / sampleRate) * 1000;
// 转成 ECharts 数据点:
const points: [number, number][] = [];
for (let i = 0; i < values.length; i++) {
points.push([tOf(i), values[i]]);
}
5.3 接入昨天的 ReconnectingWebSocket
// Day 57 埋的伏笔今天兑现:binaryType = 'arraybuffer' 就是为今天准备的
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
// 文本通道:心跳、订阅、低频快照(JSON)
} else {
// 二进制通道:高频传感器帧
const frame = decodeChannelFrame(ev.data);
if (frame) chartAppend(frame);
}
};
六、JSON vs 二进制:实测对比
6.1 实验代码(Node 里跑,浏览器同理)
/**
* 压测:编码 10 万点的体积与耗时对比
*/
function benchmark(): void {
const N = 100_000;
const values = new Float32Array(N);
for (let i = 0; i < N; i++) values[i] = Math.sin(i / 100) + (Math.random() - 0.5);
// --- JSON 路线 ---
const t0 = performance.now();
const json = JSON.stringify(
Array.from(values, (v, i) => [Date.now() + i, Number(v.toFixed(5))])
);
const t1 = performance.now();
const parsed = JSON.parse(json);
const t2 = performance.now();
// --- 二进制路线 ---
const t3 = performance.now();
const bin = encodeChannelFrame(1, Date.now(), 1000, 1, values);
const t4 = performance.now();
const decoded = decodeChannelFrame(bin);
const t5 = performance.now();
console.log(`JSON: ${(json.length / 1024).toFixed(0)}KB 编码${(t1 - t0).toFixed(0)}ms 解析${(t2 - t1).toFixed(0)}ms`);
console.log(`二进制: ${(bin.byteLength / 1024).toFixed(0)}KB 编码${(t4 - t3).toFixed(0)}ms 解析${(t5 - t4).toFixed(0)}ms`);
}
6.2 参考结果(i5-12400P,Node 20)
| 指标 | JSON | 二进制 | 差距 |
|---|---|---|---|
| 10 万点体积 | ~1900KB | 400KB | 4.7× |
| 编码耗时 | ~120ms | ~2ms | 60× |
| 解析耗时 | ~180ms + 10 万对象 GC | ~1ms 零拷贝 | 100×+ |
把你的实测数据填进这张表,截图进博客——这是面试"你做过什么性能优化"的顶级弹药。
七、常见坑点
坑 1:编码用大端、解码用小端
setFloat32(0, v, false) 写、getFloat32(0) 读(默认小端)——值全变成乱码。防御:把字节序封装进 encode/decode 模块(本文做法),且两端共用一个文件。
坑 2:Float32Array 视图未对齐
new Float32Array(buf, offset, len) 的 offset 必须是 4 的倍数,否则抛 RangeError。协议设计时头部长度保持 4 字节对齐(20 = 4×5 ✅)。
坑 3:float32 精度丢失
温度 85.123456789 存 float32 变成 85.123458……对监控无影响(显示只到 0.1),但对金额/累计量必须 float64 或定点整数(分为单位)。协议设计第一问:这个字段要多少精度?
坑 4:时间戳用 float32
float32 只有 24 位有效数字——毫秒时间戳(13 位十进制)直接溢出成垃圾值。时间戳一律 float64 或 uint32(存"相对起点秒数")。
坑 5:二进制帧和 JSON 消息混发时分不清
服务器 ws.send(buf) 和 ws.send(json) 可以交替发,客户端靠 typeof ev.data 区分——但必须记得 Day 57 坑 2:binaryType 提前设为 arraybuffer,否则二进制帧到的是 Blob。
八、自测挑战
T1 · 编解码器 + 双端跑通(40 分钟)
实现 encode/decode 模块,扩展昨天服务器:订阅后推二进制帧(1kHz 振动模拟),客户端解码后 console 出首尾 5 个点。今天核心作业。
T2 · 压测报告(20 分钟)
跑第六节 benchmark,填实测数据表,放进博客。
T3 · 多通道帧(30 分钟)
扩展协议:一帧携带多个通道(channelId + pointCount 改成"通道表 + 拼接数据区")。思考头部长度怎么算?解码时如何用一次遍历切出各通道的视图?
T4 · 定点数编码(进阶,30 分钟)
温度范围 0~200℃、精度 0.01 → 用 uint16 存 Math.round(t * 100)(2 字节替代 4 字节 float32,再省一半)。实现编解码 + 边界钳位(超范围截断到极值)。
九、总结
| 概念 | 要点 |
|---|---|
| 何时用二进制 | ≥50Hz 通道数据、批量历史;低频控制流仍用 JSON |
| 协议设计 | magic + 类型 + 定长头 + 变长体;4 字节对齐;字节序写死 |
| 零拷贝 | 解码用视图引用,不复制数组 |
| 时间压缩 | 只存 startTs + sampleRate,点时间靠算 |
| 实测 | 10 万点:体积 4.7×、解析 100× 的优势 |
今天解决了"传得快",但链路一断高频数据照样丢。明天先横向认识 MQTT——设备侧的世界里 QoS 1/2 早就把"保证送达"做成了协议内建能力,理解它之后,周五的补传设计就有了参照系。