网络数据传输:分包/粘包与 Buffer 拼接
本节目标
- 理解 TCP 是"字节流"——它不保留你"每次发送"的边界,会分包也会粘包
- 用手写"长度前缀协议"对字节流重新分帧,根治粘包/半包
- 用
Buffer.concat累积数据,凑够一条完整消息再处理
这是 Buffer 最有价值的实战场景。HTTP、WebSocket 都跑在 TCP 之上,而 TCP 只保证"字节有序到达",不保证"你发几次、我就收几次"。如果你连续 socket.write(A) 再 socket.write(B),对方可能一次收到 AB(粘包),也可能分两次 A、B(分包)。所以任何基于 TCP 的协议,都必须自己定义"一条消息的边界在哪"。
最常用、最可靠的方案是长度前缀协议:每条消息 = 4 字节大端长度 + 负载。接收方先读 4 字节知会"负载多长",再等够这么长才切出一条完整消息。
// 运行环境:Node.js 14+(本地回环 127.0.0.1,无需外网)
// 保存为 buf-l4.js,执行:node buf-l4.js
const net = require('net')
// 编码:4 字节大端长度头 + 负载
function encode(msg) {
const body = Buffer.from(msg)
const head = Buffer.alloc(4)
head.writeUInt32BE(body.length, 0) // 先声明"负载有多少字节"
return Buffer.concat([head, body]) // 头部 + 负载
}
// 解析器:累积 Buffer,凑够一条就切出来(背靠背也能正确拆分)
class FrameParser {
constructor() { this.buf = Buffer.alloc(0) }
push(chunk) {
this.buf = Buffer.concat([this.buf, chunk]) // 把新到的字节接在后面
const out = []
while (this.buf.length >= 4) { // 至少够读长度头
const len = this.buf.readUInt32BE(0)
if (this.buf.length < 4 + len) break // 负载还没收齐,等更多数据
const body = this.buf.subarray(4, 4 + len).toString()
out.push(body)
this.buf = this.buf.subarray(4 + len) // 消费掉这条,剩下留给下次
}
return out
}
}
const server = net.createServer((socket) => {
const parser = new FrameParser()
socket.on('data', (chunk) => {
const msgs = parser.push(chunk) // 无论 TCP 怎么切,这里都能还原出完整消息
for (const m of msgs) socket.write(encode('回声:' + m))
})
})
server.listen(9123, () => {
const client = net.createConnection({ port: 9123 }, () => {
// 一次性把两条背靠背发出(刻意制造"粘包"场景)
client.write(Buffer.concat([encode('你好'), encode('世界')]))
})
const cparser = new FrameParser()
client.on('data', (chunk) => {
const msgs = cparser.push(chunk)
for (const m of msgs) console.log('收到:', m)
if (msgs.length === 2) { client.end(); server.close() }
})
})跑起来你会看到 收到: 回声:你好 和 收到: 回声:世界 两条都被正确还原——哪怕发送时它们是粘在一起的。这正是上一节 HTTP 解析器用"空行 + Content-Length"做边界的本质:所有 TCP 协议都在解决"我的消息到哪结束"。
名词解释
- 粘包 / 分包:TCP 把多次
write的数据合并成一个包到达(粘包),或把一次write拆成多次到达(分包)。注意:这是 TCP 的正常行为,不是 bug,是"字节流"的必然结果。 - 长度前缀协议(Length-Prefix):用固定字节数(常用 4 字节大端)在消息头声明负载长度,接收方据此切分。比"分隔符"更可靠(负载里出现分隔符也不会误判)。
- 累积缓冲(Accumulator):接收方把陆续到达的 chunk 用
Buffer.concat拼起来,直到凑够一条完整消息再处理,剩下的留到下次。这是处理流式协议的标准缓冲策略。 - 帧(Frame):经过分帧后得到的"一条完整、独立、可解析"的消息单元。网络协议的目标,就是把无边界的字节流,还原成一帧帧结构化的消息。
课后练习
- 如果负载里恰好也包含"4 字节长度头"那样的字节,长度前缀协议会误判吗?
- 答案:不会。因为长度头是协议层在消息最前面固定读取的,负载内容被当作"透明的字节"整体看待,内部长得像长度头也无所谓——这正是它优于"分隔符方案"的地方。
- 为什么必须用
Buffer.concat累积,而不是直接chunk.toString()解析?- 答案:一个 chunk 可能只含"半条消息"(分包),直接 toString 解析会失败或残缺;累积起来等字节够了再切,才能保证每条消息完整。
- 改成"用
\\n作分隔符"也能分帧,它和长度前缀各有什么优劣?- 答案:分隔符方案简单、人类可读(如文本行协议),但负载里若出现分隔符就需转义,且要先解码成字符串才能找分隔符;长度前缀对二进制友好、无需转义、性能更好,是使用最广的二进制协议方案。
总结
"粘包"这个词很容易让人误以为是 TCP 的缺陷,其实恰恰相反——它恰好证明 TCP 在忠实地做自己该做的事:把你的数据当作无边界的字节流可靠送达。边界,从来都是应用层协议的责任。这一节你手写的 FrameParser,和上一节 HTTP 解析器、后面 WebSocket 帧解码器,是同一个思想的三种面貌:在字节流上,用某种约定重新划出"一条消息"的边界。我强烈建议你把"长度前缀协议"作为默认武器——它简单、对二进制友好、不怕负载里出现特殊字节。当你以后碰到"为什么我收的数据总是错位/少一半/混在一起",第一反应就应该是:检查你的分帧逻辑,而不是怀疑网络。理解了这一节,你才真正跨过了"会用 socket"到"懂网络协议"的门槛。