图 1:本文封面,点明「消息边界由应用层定义」的主线。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。「TCP 粘包」几乎是网络题里的必考,但面试官真正想听的结论是:粘包不是 TCP 的缺陷,而是字节流的天性——消息边界从来就不存在,除非你自己在应用层定义它。
想象面试现场,面试官问:客户端连发两条消息,服务端为什么可能一次读到两条、或者两次才读全一条?
第一步,先把成因说透。
TCP 是字节流协议:它保证字节按序、不丢、不重地到达,但不保证「一次发送对应一次接收」。发送方两次 write 的字节,可能被合并进一个 TCP 段;一次 write 的字节,也可能被拆成几个段分批发。接收方的内核缓冲区再叠加一层不确定:应用每次 read 拿到多少字节,取决于到达时序与缓冲区状态。于是连发的两条消息可能粘在一次 read 里(粘包),一条消息也可能被切开分两次读到(拆包)。水管不是快递盒——水流进来是连续的,不存在「包裹」。
第二步,三种应用层解法。
消息边界要由应用层协议自己定义,主流解法有三种。一是定长消息:每条固定字节数,实现最简单,代价是短消息也要填满,浪费带宽。二是特殊分隔符:用换行符等标记消息结尾,文本协议常用,但消息内容里出现分隔符必须转义,否则边界就错了。三是长度字段加消息体:头部固定几个字节声明 body 长度,接收方先读头、再按长度读够 body,二进制协议的主流方案,本文代码用的就是它。
图 2:粘包拆包的成因,与定长、分隔符、长度字段三种协议解法。
第三步,写出可运行的代码。
下面这个例子用 Node.js 22.15.0 直接运行(node sticky.js):客户端连发两条消息,再把第三条拆成两半发送;服务端用「4 字节长度头 + 消息体」协议拆包,并把每次 data 事件的原始字节数打出来,直观看到粘包与拆包:
'use strict';
const net = require('node:net');
// 应用层协议:4 字节大端长度头 + 消息体
function encode(msg) {
const body = Buffer.from(msg, 'utf8');
const head = Buffer.alloc(4);
head.writeUInt32BE(body.length);
return Buffer.concat([head, body]);
}
const server = net.createServer((socket) => {
let pending = Buffer.alloc(0); // 未解析完的字节
socket.on('data', (chunk) => {
console.log(`[server] 一次 data 事件收到 ${chunk.length} 字节(原始字节流,不保证消息边界)`);
pending = Buffer.concat([pending, chunk]);
// 循环拆包:头不够 4 字节或 body 不够长就等下一次 data
while (pending.length >= 4) {
const len = pending.readUInt32BE(0);
if (pending.length < 4 + len) break;
const msg = pending.subarray(4, 4 + len).toString('utf8');
pending = pending.subarray(4 + len);
console.log(`[server] 解析出完整消息: "${msg}"(长度头声明 ${len} 字节)`);
}
});
});
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
const client = net.connect(port, '127.0.0.1', () => {
// 连发两条消息:大概率粘在一次 data 事件里到达
client.write(encode('hello'));
client.write(encode('world'));
// 第三条消息拆成两半发送:演示拆包
const half = encode('split-message');
client.write(half.subarray(0, 5));
setTimeout(() => {
client.write(half.subarray(5));
setTimeout(() => { client.end(); server.close(); }, 100);
}, 100);
});
});
实测输出(字节数与合并方式随运行时序略有波动,以下是某次真实运行):
[server] 一次 data 事件收到 23 字节(原始字节流,不保证消息边界)
[server] 解析出完整消息: "hello"(长度头声明 5 字节)
[server] 解析出完整消息: "world"(长度头声明 5 字节)
[server] 一次 data 事件收到 12 字节(原始字节流,不保证消息边界)
[server] 解析出完整消息: "split-message"(长度头声明 13 字节)
第一次 data 事件收到 23 字节——两条完整消息(各 9 字节)加上第三条的 5 字节头部残片粘在一起,这就是粘包;拆包循环解析出 hello 和 world 后,发现剩余字节不够一条完整消息,就存进 pending 等待。第二段 12 字节到达后凑齐第三条,解析完成——拆包被 pending 缓冲自然吸收。
易错点清单。
一,把粘包当成 TCP 的 bug——它是字节流的必然行为,错的是没定义边界的应用层协议。二,每来一次 data 就当成一条消息处理,粘包时两条并成一条、拆包时半条当全条。三,解析后丢掉 leftover——pending 缓冲是这类解析器的灵魂,丢了就丢数据。四,长度头不校验上限,畸形或恶意数据报一个巨大长度,缓冲区被撑爆。五,以为 UDP 也有粘包——UDP 保留报文边界,这正是它与 TCP 的区别之一。
回到面试现场,建议的答题节奏:先讲字节流与消息边界的矛盾,再列三种解法并说清取舍,然后给出长度头协议的循环拆包代码,最后主动提 pending 缓冲与长度上限校验两个工程细节。自研推理协议、流式输出的消息边界,都是这同一道题的变种。
如果面试官追问「流式场景下既要低延迟又要省带宽,长度头协议还能怎么优化」,比如把长度编码成变长整数,你会怎么设计?欢迎写出你的方案。
参考资料
- Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,
net模块、Buffer的readUInt32BE/subarray等行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/net.html