深蓝黑背景品牌封面:居中大标题两行「TCP 粘包拆包,协议设计怎么解」,副标题「字节流没有边界,应用层自己定义」;下方装饰——发送侧两个独立的消息方块,经过一根 TCP 水管后,接收侧变成一截粘连的合并块与一截被切开的半块;顶部标注「经典题解析 · 网络」与「Day 10 · 09-28」 图 1:本文封面,点明「消息边界由应用层定义」的主线。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。「TCP 粘包」几乎是网络题里的必考,但面试官真正想听的结论是:粘包不是 TCP 的缺陷,而是字节流的天性——消息边界从来就不存在,除非你自己在应用层定义它。

想象面试现场,面试官问:客户端连发两条消息,服务端为什么可能一次读到两条、或者两次才读全一条?

第一步,先把成因说透。

TCP 是字节流协议:它保证字节按序、不丢、不重地到达,但不保证「一次发送对应一次接收」。发送方两次 write 的字节,可能被合并进一个 TCP 段;一次 write 的字节,也可能被拆成几个段分批发。接收方的内核缓冲区再叠加一层不确定:应用每次 read 拿到多少字节,取决于到达时序与缓冲区状态。于是连发的两条消息可能粘在一次 read 里(粘包),一条消息也可能被切开分两次读到(拆包)。水管不是快递盒——水流进来是连续的,不存在「包裹」。

第二步,三种应用层解法。

消息边界要由应用层协议自己定义,主流解法有三种。一是定长消息:每条固定字节数,实现最简单,代价是短消息也要填满,浪费带宽。二是特殊分隔符:用换行符等标记消息结尾,文本协议常用,但消息内容里出现分隔符必须转义,否则边界就错了。三是长度字段加消息体:头部固定几个字节声明 body 长度,接收方先读头、再按长度读够 body,二进制协议的主流方案,本文代码用的就是它。

概念图上半部分为成因:TCP 是字节流,只保证字节有序、不保证「一次发送等于一次接收」——发送方连发 Msg1、Msg2,接收方一次 read 可能读到粘在一起的 Msg1Msg2(粘包),也可能只读到半个 Msg1、下次再读到剩下半个加 Msg2(拆包)。下半部分为三种应用层解法对比:①定长消息——每条固定字节数,简单但浪费带宽;②特殊分隔符——如换行,内容里出现分隔符必须转义;③长度字段加消息体——头部固定长度字段声明 body 长度,先读头部再读够 body,是主流方案。底部结论:TCP 是水管不是快递盒,消息边界由应用层协议定义 图 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