【WebSocket+MQTT】day59-binary-protocol

作者:mario 发布时间: 2026-09-03 阅读量:4 评论数:0

Day 59 · 二进制协议 — TypedArray 视角下的传感器数据编码

传感器一秒推一次 JSON 没什么问题;但振动传感器是 1kHz 起步、一个网关下有几百个通道——JSON 的体积和解析成本立刻爆表。今天用第 2 月 Week 6 打下的 ImageData/TypedArray 地基,设计一条高频传感器数据的二进制通道:帧怎么设计、字节怎么编解码、和 JSON 的体积/性能差距实测多少。学完今天,“二进制"从"知道有这个东西"变成"能自己设计协议”。


目录


一、为什么 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 早就把"保证送达"做成了协议内建能力,理解它之后,周五的补传设计就有了参照系。

评论