深蓝黑背景封面:左侧大标题「字节流里,消息边界在哪」,副标题「定长、分隔符、长度字段与 TLV 四种帧格式」;右侧原创示意图——一条字节流长条被两种不同切法处理:上面按分隔符切成三段,下面按长度前缀切成两段,切点处画着小剪刀记号;左上标注「知识专栏 · 协议设计」,右上标注「Day 07 · 10-25」 图 1:本文封面,字节流上的四种切法。

之前解析「TCP 为什么会粘包拆包」这道真题时,主轴是成因与解码器的用法;这篇往上游走一步:既然 TCP 只给你一条没有边界的字节流,那么「一条消息从哪开始、到哪结束」本身就是一道设计题——轮到你自己定协议时,这个边界该怎么切?

先把场景立起来。假设你在写一个长连接服务:客户端和服务器保持一条 TCP 连接,互发业务消息。TCP 承诺的是「字节按序、不丢不重」,但从来不承诺「一次 send 对应一次 recv」。你连着发三条消息,对端可能一次收全,也可能一条消息被拆成三次才到。所以任何基于 TCP 的私有协议,第一个要回答的问题就是帧格式:接收方凭什么知道一帧结束了?

**方案一:定长。**每条消息固定占 N 字节,不足补空。解析简单到极致——第 k 条消息的偏移量就是 k 乘 N,连「解析」都谈不上。代价同样明显:消息稍短就浪费带宽,稍长就装不下。它只适合内容永远等长的场合,比如固定结构的传感器心跳。

**方案二:分隔符。**每条消息末尾挂一个特殊字节或字节序列当哨兵,读到哨兵即一帧结束。变长灵活,人眼可读,很多文本协议用换行符分界就是这个思路。命门在于:正文里一旦真的出现哨兵字节,就必须转义,否则接收方会提前切帧。转义规则本身又是协议复杂度的一部分。

**方案三:长度字段。**先告诉对方「接下来多少字节」,再发正文。解码器先攒够长度头,读出长度,再攒够正文。这是二进制协议的主流做法,带宽零浪费,正文可以携带任意字节(包括看起来像分隔符的字节),不需要转义。要注意两个工程细节:长度头的字节序必须在协议里写死;长度值必须设上限并校验,否则一个恶意或错乱的包声明「我要两吉字节」,接收方就会傻等或预分配巨额内存。

**方案四:TLV。**Type-Length-Value 三段串联:类型、长度、值。它解决的其实是帧内部的结构问题——字段可以增删,老版本读到不认识的类型,按长度直接跳过,天然向后兼容。Protobuf 的线上格式就是这个思路:每个字段的键由字段编号和线类型拼成(编号左移三位再或上线类型),小整数用变长 varint 编码,字符串和嵌套消息走带长度前缀的 LEN 类型,旧解析器遇到新字段可以整体跳过。

四宫格加底部取舍坐标图:左上「定长」——每条消息占满固定宽度的格子,不足补空,解析最简单但浪费带宽,适合传感器心跳这类永远等长的报文;右上「分隔符」——消息后跟一个特殊字节作哨兵,变长灵活,但正文里出现哨兵必须转义;左下「长度字段」——先发固定长度的数字头再发正文,解码器先攒够头再按长度攒正文,是二进制协议的主流;右下「TLV」——类型、长度、值三段串联,字段可增删、老版本读到不认识的类型直接跳过,扩展性最好。底部提示:选帧格式是在解析简单、带宽效率、扩展弹性三者之间排优先级,TLV 与长度前缀可叠加——长度字段负责切帧,TLV 负责帧内结构 图 2:四种帧格式对照,取舍坐标是解析简单、带宽效率、扩展弹性。

动手实测:长度前缀解码器怎么处理粘包与半包。

下面这个迷你实现用 Node.js 22.15.0 直接运行(node frame-codec.js),演示两个经典场景:三条消息挤在一次到达(粘包),以及一条消息被切成三段到达(半包):

'use strict';

// 长度前缀帧:4 字节大端长度头 + 正文
function encodeFrame(text) {
  const body = Buffer.from(text, 'utf8');
  const head = Buffer.alloc(4);
  head.writeUInt32BE(body.length);
  return Buffer.concat([head, body]);
}

// 解码器:内部持有缓冲区,攒够一帧才吐出,攒不够就等下一段
class FrameDecoder {
  constructor() { this.buf = Buffer.alloc(0); }
  feed(chunk) {
    this.buf = Buffer.concat([this.buf, chunk]);
    const out = [];
    while (true) {
      if (this.buf.length < 4) break;            // 长度头都没攒够
      const len = this.buf.readUInt32BE(0);
      if (this.buf.length < 4 + len) break;      // 正文还没攒够
      out.push(this.buf.subarray(4, 4 + len).toString('utf8'));
      this.buf = this.buf.subarray(4 + len);
    }
    return out;
  }
}

// 场景一:三条消息挤在一次到达(粘包)
const d1 = new FrameDecoder();
const stream = Buffer.concat([encodeFrame('你好'), encodeFrame('frame-2'), encodeFrame('再见')]);
console.log('三条一次喂入,解出:', d1.feed(stream));

// 场景二:一条消息被切成三段到达(半包)
const d2 = new FrameDecoder();
const frame = encodeFrame('这条消息被切成了三段');
const pieces = [frame.subarray(0, 3), frame.subarray(3, 10), frame.subarray(10)];
pieces.forEach((p, i) => console.log(`第 ${i + 1} 段到达,解出:`, d2.feed(p)));

实测输出:

三条一次喂入,解出: [ '你好', 'frame-2', '再见' ]
第 1 段到达,解出: []
第 2 段到达,解出: []
第 3 段到达,解出: [ '这条消息被切成了三段' ]

注意解码器的核心动作只有两个:攒、切。攒不够就原样留着等下一段,攒够了也可能一次切出好几条——这段四十行的代码就是所有「拆包器」的骨架。

**几个容易踩的坑。**一是把「调用一次 send」当成「发出一帧」,测试环境数据小、网卡快时似乎成立,一上生产就出现随机解析失败;二是选了分隔符却没定转义规则,正文里偶然出现哨兵字节就错位;三是长度字段不设上限,被畸形包打爆内存;四是把长度前缀和 TLV 当成二选一——它们其实是两层:长度前缀负责「切帧」,TLV 负责「帧内结构」,主流二进制协议两者叠加使用。

落到选型上,设计前问自己三个问题:消息是否永远等长(是就考虑定长)?字段会不会持续演进(会就把帧内结构交给 TLV 式编码)?正文是否可能包含任意字节(会就别碰分隔符,用长度字段)?三个答案拼起来,方案基本就定了。你维护过的私有协议用的是哪种帧格式,踩过哪个坑?欢迎分享。