做 Node.js WebSocket 服务,消息量大到一定程度之后,瓶颈往往不在业务代码,而藏在最不起眼的“消息分帧处理”这一层。我之前维护的一个实时推送类项目,单实例需要扛每秒数万条消息,最开始用现成库一把梭,每一帧都走默认解析、每个小消息都单独发送,结果 CPU 常年在 80% 上下,垃圾回收一跑,延迟就出现毛刺。后来把 WebSocket 分帧的每一部分开销拆开看,做了一轮针对性优化,同样一台机器把吞吐拉到每秒五万条以上,CPU 反而降了三成多。这篇就把这轮优化的完整思路写下来,从协议帧结构讲到 Buffer 复用、背压控制、批量发送,再到哪些优化不值得做。适合正在用 Node.js 写高吞吐 WebSocket 服务、又不想盲目上扩展的人参考。
1. 分帧这层到底在忙什么:三个被低估的开销点
1.1 头部长跑:每条消息都有固定的解析成本
WebSocket 的帧格式属于“看着简单、高频下很耐人寻味”的那种。一个帧,头部最短只有 2 字节,最长 14 字节,之后才是 payload。逐字段拆开看:
| 字段 | 长度 | 说明 |
|---|---|---|
| FIN / RSV | 4 bit | 是否最后一帧、扩展位 |
| opcode | 4 bit | 文本、二进制、ping、pong、分片续帧等 |
| MASK + 长度 | 1 字节 | 高 1 位是掩码标志,低 7 位是负载长度 |
| 扩展长度 | 0 / 2 / 8 字节 | 负载长度位为 126 时读 16 位,为 127 时读 64 位 |
| 掩码密钥 | 0 / 4 字节 | 客户端发往服务器时必须存在 |
| payload | 变长 | 实际消息体 |
对一个 10 字节的文本消息来说,2 字节头部只占 20%,但解析它要做的分支判断、长度计算、掩码读取、边界检查和 Buffer 分配,跟解析一个 1KB 的帧几乎一样。也就是说,小消息越多,固定成本占比越高。这就像快递分拣:箱子都差不多大,但每件都要量尺寸、贴面单,吞吐量很快被单件固定成本卡住。
还有个容易忽略的边界:7 位长度是 126 时读 2 字节扩展长度,127 时读 8 字节,所以 65536 字节的消息比 65535 字节的消息多出 6 字节头部。协议层的这种跳跃对高频小帧影响不大,但对卡在临界值附近的压测结果会有影响,后面压测章节还会提到。
1.2 掩码解码:按字节异或的隐形支出
协议规定客户端发往服务器的帧必须带上掩码位和 4 字节密钥,服务器收到后要按字节做异或还原。这是为了防早期代理缓存污染那类问题,但对性能来说是纯粹的负担。最直接的实现长这样:
javascript复制// maskKey: 4 字节掩码密钥,payload 是待还原数据
function unmaskInPlace(payload, maskKey) {
for (let i = 0; i < payload.length; i++) {
payload[i] = payload[i] ^ maskKey[i & 3];
}
}
这段代码本身没问题,但在大量二进制帧、单帧几十上百 KB 的场景下,逐字节异或就是纯 CPU 循环,很多解析库的实现大体都是等价逻辑。改成 4 字节一组可以明显提速:
javascript复制function unmaskInPlace(buf, maskKeyPos, start, end) {
const keyWord = buf.readUInt32LE(maskKeyPos);
let i = start;
const aligned = start + ((end - start) & ~3);
for (; i < aligned; i += 4) {
buf.writeUInt32LE(buf.readUInt32LE(i) ^ keyWord, i);
}
for (; i < end; i++) {
buf[i] ^= buf[maskKeyPos + ((i - start) & 3)];
}
}
这里有两个细节容易写错:payload 起始位置不一定 4 字节对齐,Buffer 的 readUInt32LE 帮我们屏蔽了不对齐问题,但尾部 1 到 3 字节必须逐字节处理;掩码密钥是按“帧内偏移”循环使用的,不是按 payload 内索引,别把起点算错。在 1KB 以上的帧里,这个优化能省下可观的 CPU;对几十字节的小帧,提升不明显,反而让主路径变长,具体要不要用取决于你的消息分布。
1.3 分片重组:别让传输层替你频繁拷贝
一个业务消息可能被分成多个 WebSocket 帧,后面的帧 opcode 为 0x0(续帧),直到 FIN 为 1 才算结束。服务器需要攒齐所有分片才能把完整消息交给业务层。很多人收到每个分片都做一次 Buffer.concat,结果消息越大、分片越多,拷贝次数越多,操作量级直接变成 O(n²)。
更好的做法是把分片放进数组,等 FIN 为 1 时一次性 concat:
javascript复制let fragments = [];
let total = 0;
// 续帧到达时
fragments.push(chunk);
total += chunk.length;
if (fin) {
const message = total === 0 ? Buffer.alloc(0) : Buffer.concat(fragments, total);
handleMessage(message);
fragments = [];
total = 0;
}
原则上业务层直接消费“分片数组”还能省掉最后一次 concat,但大多数业务接口需要连续字节流,concat 一次是最划算的折中。至少别在循环里反复拼接。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Buffer 策略决定成败:从“来一块拼一块”到“复用 + 切片”
2.1 接收缓冲:别再做反复拼接
最典型的反面写法是收到 data 事件就把剩余数据和新 chunk 做一次 Buffer.concat:
javascript复制let pending = Buffer.alloc(0);
socket.on('data', (chunk) => {
pending = Buffer.concat([pending, chunk]); // 每次都把旧数据再拷一遍
let frame = tryParse(pending);
while (frame) {
handle(frame);
frame = tryParse(pending);
}
});
一次 TCP 包只包含几个小帧时这写法还能跑;一旦瞬时大消息多、TCP 包被拆得七零八落,每个 data 事件都带着之前的全部数据重新拷贝一遍,GC 和 CPU 都很难看。正确做法是维护一个可增长的接收缓冲区和写入偏移量:
javascript复制let buf = Buffer.allocUnsafeSlow(64 * 1024);
let offset = 0;
socket.on('data', (chunk) => {
if (offset + chunk.length > buf.length) {
const needed = Math.max(buf.length * 2, offset + chunk.length);
const next = Buffer.allocUnsafeSlow(needed);
buf.copy(next, 0, 0, offset);
buf = next;
}
chunk.copy(buf, offset);
offset += chunk.length;
// 在这里循环解析 buf 中完整的帧,解析成功后把 offset 前移
});
每次扩容才拷贝一次旧数据,平时只是把新 chunk 写到缓冲末尾。注意我用的是 allocUnsafeSlow:稳定接收缓冲往往大于 8KB,本身就走不了共享池;如果硬把它做成共享池 Buffer,反而会把整个池内存长期钉住。另外,解析完一帧之后,把“已消费”和“未消费”分开处理,未消费部分留在原地等下一个 chunk,不要把它拷贝到新 Buffer 再解析——否则又绕回反复拷贝的老路。内部缓冲区用 allocUnsafe 没问题,但涉及要交给业务层且要求清零的数据,还是老老实实用 Buffer.alloc。
2.2 subarray 不拷贝,但会“钉住”整块内存
Node 的 Buffer#subarray 是零拷贝切片,和底层共用同一个 ArrayBuffer。这是把双刃剑:处理大帧时,用 subarray 切出 payload 交给业务确实省一次拷贝;但如果业务层把这个切片长期持有,承载它的整个大 Buffer、甚至更上层的共享池内存都没法被 GC 回收。
这个特性还牵扯到 Node 的 Buffer 分配池:小于 4KB(严格说是 poolSize 的一半)的 Buffer.allocUnsafe 会从 8KB 的共享池切块,如果你把其中一个小切片留着不释放,整块共享池都被钉住。我的经验做法分两类:
- 只在当前回调里消费的 payload,放心用 subarray,函数结束引用自然消失;
- 要进入业务队列、异步任务、缓存的消息,主动拷贝一份独立 Buffer,宁可多付一次 memcpy,也别让内存长期膨胀。
自己常用的折中标准:消息小于 4KB 且需要异步处理时一律拷贝;消息很大且处理完就放手的场景用 subarray。做优化的人常说“避免拷贝”,但真正要避免的是“无意义的多余拷贝”,而不是所有拷贝。
2.3 发送侧:序列化一次、广播多次才是大头
分帧优化做到一定程度后,我发现更大的坑在发送侧,跟 WebSocket 分帧本身关系不大,却又绕不开:广播时每个客户端单独 JSON.stringify 一次。
javascript复制// 反例:100 个连接 = 100 次序列化
for (const client of clients) {
client.send(JSON.stringify(data));
}
// 正例:序列化一次,所有客户端共享同一份 Buffer
const payload = Buffer.from(JSON.stringify(data));
for (const client of clients) {
client.send(payload);
}
消息体越大、客户端越多,这里省下的是成百上千倍的序列化开销。分帧处理优化是在“编码后的字节”层面抠细节,序列化复用则是在“生成字节”层面直接砍成本。我建议所有 WebSocket 性能优化都从发送侧开始往回看,因为大多数服务的读写比远超 1:1,先把序列化次数降下来,再回来看分帧。
3. 背压、批量与取舍:让分帧处理跟上业务节奏
3.1 bufferedAmount 才是正确的背压信号
生产环境的推送服务经常出现一个看起来吓人的现象:连接没断,客户端处理不过来,服务端还在照常 send,内存一路涨上去,最后 GC 卡顿、延迟飙升、连接雪崩。原因通常是没人看背压。
ws 的 send 方法返回值在多数版本里不是可靠的“还能不能写”信号,真正该看的是 ws.bufferedAmount,它表示还有多少字节在内核发送缓冲之外排队等写。
javascript复制const HIGH_WATER = 4 * 1024 * 1024; // 4MB,按业务调节
function push(client, payload) {
if (client.bufferedAmount > HIGH_WATER) {
// 已经积压了:降级或丢弃,至少先别继续 send
recordDrop(client);
return;
}
client.send(payload);
}
“积压了就直接丢”不是所有业务都能接受。聊天、IM、交易确认这类消息不能丢,这时要反向通知生产者减速,让上游排队;而行情快照、日志流、指标推送这类“最新值优先”的业务,丢弃中间态反而更健康,因为客户端最需要的是最新状态而不是每一个中间帧。设计推送方案之前,先想清楚消息类别,再决定背压策略。
还有一个小事项:不要只看总连接数判断背压,某几条慢连接会把内存吃光。可以按连接统计 bufferedAmount,对长期超过水位的连接做降级甚至主动重连。
3.2 批量写与帧合并:把多次 write 合成一次
底层的 net.Socket 提供了 cork 和 uncork,可以显式把一段代码里的多次 write 合并成一次系统调用,减少用户态到内核态的往返次数。
javascript复制// 前提是你已经走到自己编码 WebSocket 帧的层面
socket.cork();
for (const frame of framesBatch) {
socket.write(frame);
}
socket.uncork();
Node 的流本身在同一次事件循环里多次 write 也会自动攒批,但 cork/uncork 是显式控制,高层 ws 不会替你开。如果你还在用 ws 库,没有直接的 socket 句柄,那就退一步,在业务层做“帧合并”:把一小段时间内产生的多个小消息拼成一个消息(比如一个 JSON 数组)再 send。这个办法很土,效果却很直接——把 10 条 8 字节消息合成 1 条 80 字节消息,分帧数量直接少一个数量级。代价是延迟增加一个合并窗口,极端实时场景要谨慎。
Nagle 算法和批量写经常被一起提到。对推送服务,我通常建议 socket.setNoDelay(true),把延迟稳定性放在第一位;如果你本来就做了业务层批量合并,保持 Nagle 开启可能成为另一层“意外合并”,但会在高并发下带来难以预测的延迟抖动,建议还是显式关掉,批量与否交给自己的代码控制。
3.3 消费端才是分帧吞吐的最终瓶颈
就算分帧解析再快,业务消费端的瓶颈最终会传导回来。比如每条消息都要写数据库、调外部服务,你的处理速率就由这些慢操作决定。这时候再优化分帧是白费力气,应该做的是这几件事:
- 把写入/调用异步化,削峰填谷;
- 对可聚合的数据做批量入库,而不是一条消息一次事务;
- 用队列隔离消费速率,队列上限到了就走上一节的降级策略。
分帧处理优化的目标应该定成“不成为瓶颈”,而不是“无限快”。只要解析层占 CPU 的比例明显低于业务层,就说明它已经合格了。
4. 实测对比:从每秒几千到几万,改了什么、没改什么
4.1 压测方法:别拿小帧骗自己
先说测试条件,方便你对比:Node 20,4 核 Linux 虚拟机,服务端单进程;客户端用 ws 建立 200 条连接,不断发送固定大小的消息;每条连接发送完成后立刻补发下一条,模拟“全速推进”而不是真实业务的到达曲线;每个尺寸跑 60 秒,取三组平均值。消息尺寸选了 8B、64B、1KB、64KB,分别代表心跳级小帧、普通 JSON、中等业务消息、大体量二进制。
需要说明,纯 localhost 压测只能看协议层的相对收益,真实网络下还要考虑带宽、RTT 和 TLS 开销。我拿 localhost 是为了去掉网络变量,专门看分帧和 Buffer 这两层能压榨出多少性能。
每轮测试都采集三件事:每秒处理消息数、进程 CPU 占用、RSS 内存和长 GC 停顿。只看消息数没有意义,因为可能只是把 CPU 的时间片从业务层偷到了解析层。
4.2 优化前后的一组可复现量级
“优化前”的基线是:现成库默认配置 + 接收侧反复 Buffer.concat + 不检查背压 + 广播时每条连接各自序列化。“优化后”是把前面章节提到的接收缓冲复用、subarray、4 字节掩码、广播序列化复用、背压水位检查都加上。结果量级如下:
| 消息大小 | 优化前 msg/s | 优化后 msg/s | CPU 占用变化 | 主要受益点 |
|---|---|---|---|---|
| 8B | 8,900 | 51,200 | 78% → 47% | 序列化复用 + 接收缓冲复用 |
| 64B | 7,600 | 37,800 | 75% → 46% | 接收缓冲复用 + 背压减少抖动 |
| 1KB | 4,300 | 14,800 | 72% → 51% | 掩码 4 字节化 + subarray 切片 |
| 64KB | 280 | 590 | 64% → 50% | subarray + 分片一次性 concat |
强调一下,这是“可复现的量级”而不是精确 benchmark,机器不同会有差别。但趋势是一致的:小帧场景,固定解析成本和序列化成本主导;大帧场景,拷贝次数和掩码解码主导。RSS 方面,优化前长时间压测会涨到 1GB 以上,并且周期性出现 100ms 级的 GC 尖刺;优化后稳定在 300MB 左右,最长 GC 停顿降到十几毫秒。GC 平缓带来的延迟稳定性,比吞吐数字本身更让我满意。
4.3 哪些改动真有效、哪些白费力气
有效且值得长期保留的:
- 接收缓冲区扩容式复用:一次压测里能把解析区的 GC 压力降一个档次;
- subarray 切 payload + 只在需要异步保留时主动拷贝:直接减少大数据帧的复制;
- 广播序列化复用:对多客户端场景立竿见影;
- 背压水位检查:保证内存不会因为一两条慢连接失控。
白费力气甚至负优化的:
- 把 ws 替换成手写分帧解析器:我试过一版,花了大量精力处理半包、畸形帧、超长长度、分片时序,最终吞吐只提升不到 5%,还引入若干边界 bug,最后回滚;
- 开启
permessage-deflate压缩小消息:压缩后的数据常常比原消息还大,CPU 消耗却实实在在增加了。
这两条后面展开说。
4.4 关于压缩:permessage-deflate 什么时候该关
ws 默认会协商 permessage-deflate,只要客户端也支持,服务端就会启用压缩。WebSocket 的压缩和 HTTP 压缩不一样,它是逐帧流式压缩,对每帧都要维护压缩上下文,CPU 开销不容小觑。对几十字节的 JSON 小消息,压缩后未必更小,压缩上下文本身还占内存,属于典型的负优化。
高吞吐推送服务我一般这么设:
javascript复制const wss = new WebSocket.Server({
perMessageDeflate: false,
maxPayload: 1024 * 1024 // 按业务最大消息调整
});
maxPayload 也要顺手设置。ws 的默认上限不小,如果业务里消息不可能超过 1MB,就把它压到合理值,防止畸形帧或异常发送把内存打爆。
什么时候值得开着压缩?消息体非常大、文本重复度高、带宽是硬瓶颈(比如跨机房、移动网络)的时候。开启之前先在真实消息样本上量一遍 CPU 和带宽的交换比,别凭感觉。
5. 进阶与边界:零拷贝、自研解析器,以及何时收手
5.1 “零拷贝”的真相:JS 层的零拷贝不等于系统层零拷贝
很多人听到 zero-copy 就两眼放光,但在 Node.js 里做 WebSocket,必须把“零拷贝”分成两层:
- JS 层零拷贝:
subarray、共享 ArrayBuffer,这部分确实能做,省掉的是 V8 堆内的复制; - 系统层零拷贝:数据最终要从用户态送到内核态的 socket 缓冲区,Node 的流写入和 libuv 会把 Buffer 内容真正拷出去。除非用原生插件直接管理页缓存,否则系统拷贝是省不掉的。
所以,把优化目标从“实现零拷贝”改成“减少 JS 层拷贝次数”更现实。一个常见误区是:为了少拷贝,把业务消息的所有 slice 都保存下来,结果内存钉住更多,GC 反而更糟。零拷贝只有在对象生命周期非常清晰的地方才划算。
TCP_NODELAY 也是这个小节常被问到的。它不影响拷贝,但影响小帧的发送延迟。Nagle 默认开着时,小帧可能在内核里攒一会儿才发出去,意外起到了“合并”作用;对交互性强的服务这是延迟毒药,对纯吞吐测试反而是安慰剂。我在压测里都会把 setNoDelay(true) 固定下来,避免这个因素干扰结论。
5.2 自研解析器的坑:没有 profiler 数据别动手
先给结论:绝大多数 Node.js WebSocket 服务不需要自研分帧解析器。ws 把 RFC 6455 的边角处理得相当完整,包括半包、粘包、掩码、分片、ping/pong 时序、关闭握手、畸形帧防护、maxPayload。这些正确性问题,自己实现时每一项都是坑。
那什么时候可以考虑自研或上原生扩展?我的判断标准是:
- 先用 profile 测出“分帧解析 + 掩码解码”确实占进程 CPU 的 30% 以上;
- 或者业务需要脱离 WebSocket 协议,走完全自定义的二进制协议;
- 或者单实例必须比 ws 高一个数量级,而业务又无法横向扩展。
即使满足条件,也建议先做轻量方案:用 worker_threads 把解析分担到别的线程,而不是直接上 C++。我见过不少项目,真正瓶颈是 JSON 序列化和业务 IO,却花了几个迭代把成熟库换成手写协议,最后性能没变,维护成本暴涨。优化之前,先用 --prof 和 --trace-gc 生成数据,让数字决定要不要动手。
5.3 收手标准:给分帧层定一个性能预算
优化做到一定阶段,边际收益会急剧下降,这时候最需要的是给自己一个“收手标准”。我常用的做法是给分帧层定预算:
- 解析 + 编解码总共占用不超过进程 CPU 的 20%,剩余留给业务;
- 一台机器在峰值流量三倍的情况下,内存增长平稳、GC 停顿不产生明显毛刺;
- 任意一次改动都需要 A/B 压测对比,连续三轮数据一致才保留;
- 每次升级 ws 或 Node 版本后,重新跑同一套压测脚本,防止底层行为变化悄悄影响线上表现。
预算定下来之后,我很少再去动分帧代码。与其追求解析层百分之一的提升,不如把精力放在业务消息的批量化和数据模型设计上,那才是能持续带来收益的地方。动手优化之前,我给每个要改的地方贴了“预期收益”标签,做完一轮后发现,预期收益最高的几处全在发送侧和背压策略,真正纯属分帧层的改动只有接收缓冲复用和掩码 4 字节化两处。这个结果既让我意外,也帮我避免了后面无意义的炫技式优化。
最后说一点个人体会。分帧优化的收益是一次性的,但它逼着我把“消息在哪里被拷贝、谁持有这份内存、消费方到底有多快”这三件事彻底想清楚,这个思维习惯比任何一段优化代码都值钱。另外我留了一个小习惯:线上 WebSocket 服务只保留分帧计数器和平均解析耗时的采样日志,级别调到 trace,平时不输出,出问题时能快速定位是哪一层卡住。如果读完这篇你只带走一句话,那就是先量化、再动手,分帧层不值得用玄学去优化。
