如果你用 Node 的 ws 库写过 WebSocket 二进制通信,大概率也踩过这个坑:从message事件里拿到的数据,有时候是 Buffer,有时候是 ArrayBuffer,前端明明是 ArrayBuffer,后端却拿到一个带toString()的 Buffer,两边一对接,代码里到处是Buffer.from、new Uint8Array、data.buffer,性能没上去,内存倒是先翻倍了。
我前阵子做一个跨端设备数据采集 Demo,后端要往浏览器实时推传感器采样帧,前后端各写一套二进制解析逻辑,结果接收端一会儿报“没有byteOffset”,一会儿报“instanceof ArrayBuffer判断失败”,最后才发现根源在于 ws 的type选项没统一。折腾完这一轮,把 Buffer、ArrayBuffer、TypedArray 之间的关系彻底摸了一遍,也沉淀出 5 个实战里真正管用的高效技巧,覆盖接收类型统一、发送零拷贝、分片拼接、内存保护和性能调参。
这篇文章就是围绕这 5 个技巧展开的。我会先从根因说起,再逐个给配置、给代码、给踩坑记录,适合所有用 ws 做实时二进制传输(传感器数据、音频帧、图像、文件分片)的同学参考。看完你至少能把“到底该传 Buffer 还是 ArrayBuffer”“为什么不能随便Buffer.from(view)”“大文件怎么安全地分片”这几个问题彻底搞明白。
1. 先搞清根因:ArrayBuffer和Buffer是“表亲”不是“同一个人”
1.1 两者的真实关系
很多人把 ArrayBuffer 和 Buffer 混着用,因为它们在二进制数据上长得确实像:都有byteLength,都能塞进 WebSocket 发送,Uint8Array甚至可以直接当一个通用的二进制容器。但它们的定位完全不同。
ArrayBuffer 是 ECMAScript 标准里的底层缓冲区对象,它只代表一段连续内存,本身不提供任何读写方法。你想操作它,必须再建一个视图,比如Uint8Array、DataView。打个比方,ArrayBuffer 就像一块画好了边界的地皮,Uint8Array 是地皮上划好格子、标注了每个格子位置的平面图,你拿着平面图才能知道哪个位置能写值。
Node 的 Buffer 则是运行在 V8 之上、由 Node 额外实现的一个Uint8Array子类。它天然拥有整套 Node 生态的二进制操作 API:toString()、writeInt32BE()、concat()、slice()等等,而且这些 API 是服务端场景里高频使用的。Buffer 的底层存储本质上就是一个 ArrayBuffer,或者说 Buffer 是“一块已经带好了装修工具的地皮”。
这里最容易出错的是类型判断。Buffer.isBuffer(view)为 true 时,ArrayBuffer.isView(view)一定也是 true,因为 Buffer 本身就是一个视图。于是很多代码会出现这种误判:
if (data instanceof ArrayBuffer) { // 处理二进制 }这句话在浏览器端收到的ArrayBuffer上没问题,但 Node 端默认收到的 Buffer 根本不会走进这个分支。反过来,如果你用data.buffer去拿底层 ArrayBuffer,拿到的是整块底层内存,而 Buffer 视图可能只对应其中一段,你还需要同时带上byteOffset和byteLength才能还原真实数据。
1.2 ws在不同端到底给你什么类型
ws 库在不同端、不同配置下,message事件携带的数据类型是不同的,这是所有混乱的源头。
| 运行环境 | 默认类型 | 可配置项 | 配置后类型 |
|---|---|---|---|
| Node 服务端(ws Server) | Buffer | type: 'arraybuffer' | ArrayBuffer |
| Node 客户端(ws WebSocket) | Buffer | type: 'arraybuffer' | ArrayBuffer |
| 浏览器客户端(原生 WebSocket) | Blob | binaryType = 'arraybuffer' | ArrayBuffer |
我最初就是没注意这个差异:前端binaryType = 'arraybuffer',后端却用默认 Buffer,两边都对“二进制数据”有各自的假设。你以为是前后端协议没对齐,其实只是 ws 的默认行为导致的类型错位。
1.3 常见的类型误判案例
有一次排查问题,我在服务端打印收到的数据:
Buffer(1024) [0x01, 0x02, ...]然后在浏览器端打印同样的消息:
ArrayBuffer(1024) {}两个端明明传输的是同一份二进制协议,打印出的结构完全不一样。后来查到原因是服务端代码里用了Buffer.from(data)去“处理”ArrayBuffer,而这段代码在 Node 端会对已经是 Buffer 的数据做一次额外拷贝;浏览器端拿到的是 ArrayBuffer,Buffer.from(arrayBuffer)又能正常生成视图。两边逻辑看似都能跑,内存和性能表现却完全不同。
所以处理二进制通信的第一步,不是写业务逻辑,而是先把“这端到底会收到什么类型”这件事定死。
2. 技巧一:接收端统一为ArrayBuffer,让前后端共用一套解码
2.1 服务端和客户端的配置姿势
最直接的办法是让服务端显式返回 ArrayBuffer,和浏览器端保持一致。服务端创建时加一个type选项即可:
const { WebSocketServer } = require('ws'); const wss = new WebSocketServer({ port: 8080, type: 'arraybuffer', // 让 message 事件返回 ArrayBuffer maxPayload: 64 * 1024 * 1024 // 64MB 上限 });Node 端的 ws 客户端也可以在连接时指定同样的选项:
const WebSocket = require('ws'); const ws = new WebSocket('ws://localhost:8080', { type: 'arraybuffer' });浏览器端原生 WebSocket 的配置方式不一样,不是type,而是binaryType:
const ws = new WebSocket('ws://localhost:8080'); ws.binaryType = 'arraybuffer'; ws.onmessage = (event) => { // event.data 是 ArrayBuffer };这样前后端最终拿到的都是 ArrayBuffer,后续解码函数可以完全复用。我自己习惯在项目里维护一个decodeBinary(data)函数,入口先做一次类型归一,即使某天某个中间层把配置丢了,也不会瞬间崩掉。
2.2 为什么我推荐统一成ArrayBuffer而不是Buffer
这里有人会问:既然 Node 端默认 Buffer 性能最好,为什么不统一成 Buffer?
我的理由是:浏览器端没有 Buffer,想让浏览器端模拟 Buffer,通常得引入 polyfill,或者传输完再手动Buffer.from(arrayBuffer)。前者增加依赖,后者多一次拷贝。而 ArrayBuffer 是两端天然共通的标准类型,统一成它以后,发送端、接收端、协议文档可以写成完全一样的三件套:ArrayBuffer → DataView/Uint8Array → 业务结构体。
Node 端的 Buffer 也不是没用。Node 内部、文件系统、流处理都依赖 Buffer,但那是服务端内部的事。在“跨端 WebSocket 消息”这个边界上,ArrayBuffer 才是更合适的通用语言。把内部处理和网络传输隔离开,代码会干净很多。
2.3 配合自定义协议识别文本和二进制
统一成 ArrayBuffer 之后,还需要解决一个常见问题:一条连接上既传 JSON 文本,又传二进制块,接收端怎么区分?
我采用的方式是在协议层加一个消息分类标志位。二进制消息头部固定一个 magic byte,比如0xAB;文本消息则以{开头。接收端先判类型,再决定走哪条解析链路:
ws.onmessage = (event) => { const data = event.data; if (typeof data === 'string') { handleText(data); return; } const bytes = new Uint8Array(data); if (bytes[0] === 0xAB) { handleBinary(bytes.subarray(1)); } else { handleText(new TextDecoder().decode(bytes)); } };这套逻辑在浏览器端和服务端可以一字不差地共用,前提是两边拿到的都是 ArrayBuffer。这也是技巧一真正的价值:它不只是省一个Buffer.from,而是让跨端二进制解码逻辑可以只写一遍。
3. 技巧二:发送端零拷贝识别,别再做无谓的数据搬运
3.1 一个靠谱的类型归一函数
发送端最容易出的问题,是把数据传来传去时反复拷贝。比如收到了一个Uint8Array,为了“保险”先Buffer.from(view)再发送,这就是一次无谓拷贝。
先给一个我常用的发送前归一函数,它只做逻辑判断,不做数据拷贝:
function toBinaryPayload(input) { if (input instanceof ArrayBuffer) { return input; } if (ArrayBuffer.isView(input)) { // Uint8Array、DataView、Buffer 都属于视图 // 统一返回视图本身,交给 ws 内部处理 return input; } throw new Error('unsupported binary type'); }这里有一个关键认知:ws 的send()本身支持ArrayBuffer、TypedArray、DataView、Buffer,不需要你手动把它们互相转换。真正需要转换的,是当你必须使用特定 API 时再转。比如服务端要把收到的二进制写入文件,用 Buffer 更方便,那就应该用共享内存视图的方式去“转”,而不是拷贝。
3.2 Buffer、ArrayBuffer、TypedArray的发送成本对比
很多人以为Buffer.from(arrayBuffer)一定会拷贝,其实这是一个容易踩的认知陷阱。Node 文档写得很清楚:Buffer.from(arrayBuffer[, byteOffset[, length]])创建的是共享同一底层内存的视图,并不拷贝数据。
但是,Buffer.from(typedArray)和Buffer.from(buffer)是会拷贝内容的。我把常见操作按“是否产生拷贝”列了一张表:
| 操作 | 是否拷贝 | 说明 |
|---|---|---|
ws.send(arrayBuffer) | 否 | ws 内部包装成视图直接发送 |
ws.send(uint8Array) | 否 | 共享底层内存,前提是发送完成前不改数据 |
Buffer.from(arrayBuffer) | 否 | 共享底层内存,不是拷贝 |
Buffer.from(arrayBuffer, offset, length) | 否 | 共享指定区间内存 |
Buffer.from(typedArray) | 是 | 复制元素内容 |
arrayBuffer.slice() | 是 | 生成新的内存块 |
uint8Array.slice() | 是 | 生成新的 Uint8Array |
view.subarray(...) | 否 | 返回原视图上的新视图 |
buffer.toString('base64') | 是 | 还带来体积膨胀,二进制传输尤其要避免 |
这张表最反直觉的就是Buffer.from(arrayBuffer)不拷贝。知道这一点之后,代码里就会少掉很多无谓的“双保险”拷贝。
3.3 共享底层缓冲区的风险与释放时机
零拷贝不是白拿的。共享底层内存意味着:发送方如果复用了同一个 ArrayBuffer,而 ws 内部还没来得及把数据写完,下一批数据就可能把上一帧覆盖掉。
我实际踩过这个坑:做一个高频传感器推送服务,为了减少分配,我预分配了一块 8KB 的 ArrayBuffer,每次收到采样数据就往里写,然后ws.send(buffer)。一开始数据量小没事,后来并发上来了,前端显示的数据开始偶尔出现重复片段,排查了很久才发现是发送队列里同时存在两块视图指向同一底层内存,其中一个已经被覆盖了。
解决办法是等发送完成的回调,再归还缓冲区。ws 的send()支持回调:
ws.send(buffer, (err) => { if (err) { console.error('send failed', err); } pool.release(buffer); // 归还复用池 });这里要强调:回调触发只代表数据已经交给底层 socket 处理,并不代表对端已经收到,但对我们复用本地缓冲区来说,这个时机已经足够安全。如果追求更高的内存复用率,可以维护一个空闲池,把归还的缓冲区重新放到池里。
4. 技巧三:业务分片发送,别让一条消息吃掉你的整块内存
4.1 WebSocket帧分片和业务分片是两码事
先澄清一个容易混淆的点:WebSocket 协议本身有帧分片机制,一个消息可以被拆成多个 frame 发送,ws 库会自动重组,message事件里你拿到的永远是完整的一条消息。这个过程不需要你自己管,也管不了。
但如果你要从服务端发一个 200MB 的文件,不可能一次性构造一个 200MB 的 ArrayBuffer 再send出去。一来内存峰值太高,二来超过maxPayload后连接会被直接断开,三来浏览器端或者中间代理对单条消息的大小通常有限制。这时候需要的是业务分片:把大文件切成多块,分别作为独立的 WebSocket 消息发送,接收端再按协议拼起来。
4.2 分片协议怎么设计才够稳
我常用的一套分片协议头长这样,固定 18 字节:
- 2 字节 magic:
0xABCD - 4 字节 fileId:文件或会话的唯一 ID
- 4 字节 total:总字节数
- 4 字节 offset:当前分片在整个文件中的起始偏移
- 4 字节 length:当前分片的字节长度
发送端例子:
function sendLarge(ws, fileId, data, chunkSize = 256 * 1024) { const total = data.byteLength; let offset = 0; while (offset < total) { const length = Math.min(chunkSize, total - offset); const header = Buffer.alloc(18); header.writeUInt16BE(0xABCD, 0); header.writeUInt32BE(fileId, 2); header.writeUInt32BE(total, 6); header.writeUInt32BE(offset, 10); header.writeUInt32BE(length, 14); const chunk = Buffer.concat([header, data.subarray(offset, offset + length)]); ws.send(chunk); offset += length; } }注意data.subarray(offset, offset + length)返回的是视图,不拷贝数据;Buffer.concat这一步会拷贝一块,但拷贝的粒度是 256KB 一帧,比一次性拷贝整个文件可控得多。
接收端可以这样解析和拼接:
const wss = new WebSocketServer({ port, type: 'arraybuffer', maxPayload: 1 * 1024 * 1024 }); const sessions = new Map(); wss.on('connection', (ws) => { ws.on('message', (data) => { const view = new DataView(data); if (view.getUint16(0, false) !== 0xABCD) { // 非分片消息,走普通处理 return; } const fileId = view.getUint32(2, false); const total = view.getUint32(6, false); const offset = view.getUint32(10, false); const length = view.getUint32(14, false); let session = sessions.get(fileId); if (!session) { session = { buffer: new ArrayBuffer(total), received: 0 }; sessions.set(fileId, session); } new Uint8Array(session.buffer, offset, length) .set(new Uint8Array(data, 18, length)); session.received += length; if (session.received === total) { // 完整文件已拼好 handleComplete(fileId, session.buffer); sessions.delete(fileId); } }); });这段代码里有个很重要的细节:new Uint8Array(session.buffer, offset, length)创建的是目标大缓冲区的视图,直接往视图里写值,不会产生一次额外的中间数组拷贝。接收端始终只有一份完整的文件缓冲,外加当前分片的小缓冲。
4.3 接收端即收即写,避免大文件占内存
如果传输的是超大文件,接收端连“拼完整再存盘”都不划算。更好的是收到一个分片就写一次磁盘,只维护文件的偏移信息,不维护整块内存。
用 Node 的文件句柄写分片,关键是传对写入偏移:
const { open } = require('fs/promises'); async function writeChunk(fileHandle, payload, offset) { await fileHandle.write(payload, 0, payload.length, offset); }这里的offset就是分片头里的 offset。文件句柄会在所有分片写完后关闭。这种方式下,接收方内存占用始终只是一个分片的大小,无论总文件有多大。
分片的单位我也给一个建议:局域网强实时场景用 64KB 到 256KB,兼顾内存和系统调用;广域网弱网场景可以放大到 1MB,减少分片过多带来的头部开销和发送频率。
5. 技巧四:给ws装上内存闸门:maxPayload与二进制通道
5.1 一次超大消息为何会拖垮服务
WebSocket 服务端默认会限制单条消息大小,ws 库的默认maxPayload大约在 100MB 级别。如果业务代码直接收一个大 ArrayBuffer,服务端必须先分配一块对应大小的内存,才能触发message事件。攻击者或异常客户端只要连上来发几个超大消息,内存就被打高,甚至 OOM。
所以服务端一定要显式设置合理的maxPayload:
const wss = new WebSocketServer({ port: 8080, maxPayload: 1024 * 1024, // 单条消息不超过 1MB type: 'arraybuffer' });这里有个矛盾:如果业务上确实要传大文件,你不可能把所有大文件消息都限制在 1MB 内。解决思路就是结合技巧三:大文件拆成 256KB 的分片,单条消息天然小于 1MB,服务端又能设一个很紧的maxPayload,一举两得。这也是我把技巧三和技巧四放在一起讲的原因:它们是配套使用的。
5.2 别在二进制链路里掺base64
一个很典型但很糟糕的做法,是把 Buffer 转成 base64 字符串再走 WebSocket:
const payload = buffer.toString('base64'); ws.send(payload);接收端再Buffer.from(payload, 'base64')。这样做有三大代价:
- 体积膨胀约 33%,传输耗时和带宽全部增加
- 字符串在 JS 引擎里占用的内存比原始二进制大得多,同一条消息内存占用翻倍
- 编码和解码都是额外的 CPU 开销
对于小消息影响不大,但对于高频二进制流,这个方案会让整体吞吐肉眼可见地下降。我见过一个实时波形 Demo 把每帧采样点转成 base64 发,波形一旦密集帧率达到 30FPS,前端卡顿严重,后来改成直接发二进制帧,同样的硬件环境立刻流畅很多。
5.3 分片通道配合背压控制
当发送速率大于消费速率时,ws 内部会把待发送消息排队,内存随之增长。send()返回false就表示缓冲已满,此时应该暂停生产,等待底层清空。配合分片发送时,更要处理这个背压,否则分片越多,积压越严重。
function sendWithBackpressure(ws, chunk) { return new Promise((resolve, reject) => { const ok = ws.send(chunk, (err) => { if (err) reject(err); else resolve(); }); if (ok === false) { ws.once('drain', resolve); } }); }这里需要说明一下:ws 在缓冲满时返回false,并且后续写入排空后会触发drain事件,和 Node 原生流类似。实际使用中,我会把分片发送函数改造成异步逐帧发送,每一帧都等上一帧真正写进 socket 或至少被底层消费掉,再发下一帧,这样内存曲线非常平稳。
6. 技巧五:压缩、masking与小包合并的性能细节
6.1 perMessageDeflate对二进制是双刃剑
ws 默认关闭perMessageDeflate,但如果有人为了省带宽把它打开了,就要特别注意二进制消息。压缩算法对文本效果好,对已经压缩过的图片、音视频数据几乎毫无收益,反而白白消耗 CPU,增加延迟。
我的配置倾向是:主要传二进制数据时直接关掉压缩;主要传 JSON 文本时可以开启,并设置一个 threshold,让小于该大小的消息不参与压缩。这个threshold是 ws 支持的一个压缩阈值参数。
const wss = new WebSocketServer({ port: 8080, perMessageDeflate: { threshold: 1024 // 小于 1KB 的消息不压缩 } });这样既避免了二进制大消息被白压一遍,又保证了小文本消息的低延迟。如果业务里二进制和文本混合,且二进制是大头,我宁愿全局关掉perMessageDeflate,只对需要压缩的业务字段自己手动压缩。
6.2 masking开销与回包优势
WebSocket 协议要求客户端向服务端发送的数据必须做掩码(masking),而服务端向客户端发送的数据不需要。masking 是一种简单的异或变换,CPU 成本不高,但在高并发场景下累计起来也有体感。
Node 的 ws 客户端会自动处理 masking,所以客户端发送时没法省这一步。服务端回包则天然没有 masking 开销。在压测服务端吞吐时,你可能会发现“下行大流量”比“上行大流量”更轻松,原因之一就在这里。
如果你同时跑大量 Node 客户端往同一个服务端发数据,可以考虑把客户端逻辑尽量精简,不要在应用层做额外加密或二次编码,让 masking 只做一次。在低配机器上,这一步对 CPU 占用率有明显改善。
6.3 高频小帧攒批发送的实测体验
实时采样类数据有一个共同问题:消息太小、频率太高。比如每 20ms 发一个 80 字节的采样点,一秒 50 个包。每个包都有 WebSocket 消息头、TCP 段开销,系统调用频繁,服务端和客户端都在空转。
我试过攒批思路:把多个采样点合并成一个大 Buffer,达到一定大小或者攒够一定时间后一次发送。接收端按采样点长度解析。对采样率 20ms 的场景,攒 10 个点发一次,系统调用直接少一个数量级,体验上没有能感知的延迟增加。
简单实现一个攒批发送器:
class BatchSender { constructor(ws, batchSize = 8192, flushIntervalMs = 50) { this.ws = ws; this.batchSize = batchSize; this.flushIntervalMs = flushIntervalMs; this.buffer = Buffer.allocUnsafe(batchSize); this.length = 0; this.timer = null; } push(sample) { if (this.length + sample.length > this.batchSize) { this.flush(); } sample.copy(this.buffer, this.length); this.length += sample.length; if (!this.timer) { this.timer = setTimeout(() => this.flush(), this.flushIntervalMs); } } flush() { if (this.length === 0) return; // 发送的是当前批的拷贝,避免复用缓冲时覆盖 const out = Buffer.from(this.buffer.subarray(0, this.length)); this.ws.send(out); this.length = 0; this.buffer = Buffer.allocUnsafe(this.batchSize); if (this.timer) { clearTimeout(this.timer); this.timer = null; } } }有人会问:这里Buffer.from(this.buffer.subarray(...))不是又拷贝了吗?对,因为我要立刻复用this.buffer,不能让 ws 队列里还挂着旧视图。如果想连这一块也省掉,可以让每个 batch 独立分配一块 Buffer,发送完成后把 Buffer 归还给对象池,但这套复杂度除非在极限性能场景下,否则没必要。我实测下来,攒批 + 单批拷贝一次已经比逐条发送好了很多,实现也简单得多。
7. 实战复盘:一个图像标注Demo的二进制链路排错记录
7.1 问题现场和定位过程
我在做一个某图像标注 Demo,浏览器端binaryType设置成了arraybuffer,后端用的 ws Server 却是默认配置。前端拿到图片切片后,按约定拼ArrayBuffer,后端收到的是 Buffer,代码里有一段判断:
if (data instanceof ArrayBuffer) { // 走二进制解析 } else { // 走文本解析 }后端收到的 Buffer 不是 ArrayBuffer 实例,于是一整段二进制解析逻辑全部被跳过,消息被当成文本解析,直接就乱码了。
定位过程其实不复杂:我在回调第一行打印data.constructor.name,发现浏览器端打印ArrayBuffer,服务端打印Buffer。然后查了两边的配置,确认服务端type没有设置。改成type: 'arraybuffer'之后,前后端日志一致,问题消失。
这里值得记住的是:不要凭“感觉”判断 WebSocket 数据类型,应该写一个统一的入口日志。哪怕只是临时打印Object.prototype.toString.call(data),也能省下一晚上的排查时间。
7.2 我最终采用的整套配置模板
经过这几轮调整,我目前的默认组合是这样:
const { WebSocketServer } = require('ws'); const wss = new WebSocketServer({ port: 8080, type: 'arraybuffer', maxPayload: 1024 * 1024, perMessageDeflate: { threshold: 1024 } });type: 'arraybuffer':跨端解码逻辑统一maxPayload: 1024 * 1024:配合业务分片,单条消息天然小于 1MBperMessageDeflate.threshold:小文本压缩,大二进制不参与压缩
发送端如果是 Node 进程,客户端配置和这段保持一致;如果浏览器端是原生 WebSocket,就设置binaryType = 'arraybuffer'。所有二进制解析入口,都从“我拿到的到底是一个 ArrayBuffer 还是一个视图”开始写。
7.3 排错自查清单
最后把我这几个项目里遇到过的坑整理成一张清单,排查顺序也是这个顺序:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 消息被当文本解析 | 服务端type未设,data 是 Buffer,instanceof ArrayBuffer失败 | 统一设置type: 'arraybuffer'或用ArrayBuffer.isView()兜底 |
data.buffer拿到了意外内容 | 视图带byteOffset,直接取.buffer会带上前置数据 | 用new Uint8Array(data.buffer, data.byteOffset, data.byteLength) |
send()后数据被覆盖 | 复用了 Buffer 且未等回调 | 在send回调里归还缓冲 |
| 连接被断开,报 message too big | 单条消息超过maxPayload | 业务分片 + 调大上限 |
| 高并发下 CPU 突高 | perMessageDeflate压缩了大二进制 | 设置threshold或关闭压缩 |
| 内存曲线持续上涨 | 发送过快,ws 缓冲堆积 | 用send回调/drain控制背压 |
| 二进制传输体积异常大 | 走了 base64 编码 | 直接发送 ArrayBuffer 或 Buffer 视图 |
个人习惯上,只要涉及 WebSocket 二进制传输,我都会先定三件事:接收类型是什么、单条消息上限多大、发送速率怎么控。三件事定完,剩余的只是普通的二进制协议解析工作。上面这组合目前在我手头几个项目中跑起来都很稳,遇到类似问题可以直接照抄配置,再结合你自己的业务二分片逻辑,基本不会踩太深的坑。