TCP 粘包与基于长度前缀的拆包
本节目标
- 理解 TCP "粘包 / 半包"的成因
- 手写基于"长度前缀"的可靠拆包协议
- 掌握在字节流上恢复消息边界的通用方法
问题:TCP 是字节流,没有消息边界。发送方连续 write 两次,接收方可能一次 data 收到两包(粘包),也可能分两次才收完一包(半包)。必须在应用层自己定边界。
方案:长度前缀:每条消息 = 4 字节大端长度 + 负载。接收方先读长度,再按长度凑齐负载。
// 运行环境:Node.js 14+
// 保存为 aj-l4.js,执行:node aj-l4.js
const net = require('net')
// 编码:加 4 字节长度前缀
function encode(msg) {
const payload = Buffer.from(msg)
const head = Buffer.alloc(4)
head.writeUInt32BE(payload.length, 0) // 大端 32 位长度
return Buffer.concat([head, payload])
}
// 解码:维护缓冲区,按长度前缀拆出完整消息
class Decoder {
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 // 负载还没收齐,等更多
out.push(this.buf.slice(4, 4 + len).toString())
this.buf = this.buf.slice(4 + len)
}
return out
}
}
// 调用示例:模拟粘包(两条拼一起发)与半包(分两段发)
const d = new Decoder()
console.log(d.push(Buffer.concat([encode('hello'), encode('world')]))) // ['hello','world'](粘包被拆开)
const part1 = encode('longmessage').slice(0, 6)
const part2 = encode('longmessage').slice(6)
console.log(d.push(part1), d.push(part2)) // [] 然后 ['longmessage'](半包被拼合)名词解释
- TCP 粘包 / 半包:TCP 只保证字节有序送达、不保证"发送几次=接收几次"。粘包=多次发送一次收到;半包=一次发送分多次收。本质是"字节流无边界"。
- 长度前缀(Length Prefix):在消息头放"负载字节数",接收方先读长度再按长度截取。是最常用、最可靠的自定义协议定界方式。
- 大端序(Big-Endian):高位字节在前(如
0x0000000A在内存里是00 00 00 0A)。网络协议普遍用大端("网络字节序"),writeUInt32BE即大端写。
课后练习
- 为什么不能用"换行符
\n"给二进制协议定界?- 答案:二进制负载里可能本身含有
\n字节,会误判边界;而长度前缀与内容无关,能承载任意字节。文本协议才常用换行定界。
- 答案:二进制负载里可能本身含有
Decoder里this.buf.length < 4 + len时为什么break而不是继续?- 答案:说明负载还没收齐,当前无法拆出完整消息;
break保留已读缓冲,等下一次push补齐全后再处理,避免误截。
- 答案:说明负载还没收齐,当前无法拆出完整消息;
总结
TCP 粘包是每一个写网络程序的人的"成人礼"——几乎所有初学者第一次用 socket 都会栽在"为什么我收的数据串味了"。这课的灵魂只有一句话:TCP 是流,不是消息;边界必须由你在应用层定义。长度前缀之所以成为行业标准(Redis 的 RESP、gRPC 的 length-delimited 都如此),是因为它简单、可靠、又能承载任意二进制。我特别想强调缓冲区 buf 的维护:它把"没收齐的半包"攒着,下次再来补齐——这个"攒buffer"的动作,是所有高性能网络框架(Node 的 readable 流、Netty 的 ByteToMessageDecoder)共同在做的事。理解了长度前缀拆包,你就拿到了手写任何私有协议的钥匙,RPC、游戏同步、IM 长连接都建立在这之上。