Skip to content

粘包拆包与协议设计

1. 这是什么

很多人第一次做网络程序时,都会下意识认为:

  • 我发送了 1 次消息
  • 对方就应该 1 次 read 收到 1 条完整消息

但现实不是这样。

在 TCP 里,底层看到的通常不是“消息”,而是:

  • 连续字节流

这就意味着:

  • 你发的两条消息,接收方可能一次读出来
  • 你发的一条消息,接收方也可能分多次才读完整

这两类现象,就是大家常说的:

  • 粘包
  • 拆包

如果先只记一句话,可以记成:

粘包拆包不是 Netty 的 bug,也不是 TCP 出错,而是 TCP 作为字节流协议的天然表现。

而所谓“协议设计”,本质上就是在回答:

  • 接收方怎样知道“这一条消息从哪开始,到哪结束”

2. 为什么重要

网络程序里很多看起来“偶发”的问题,根源都在这里:

  • 有时解析成功,有时失败
  • 同样的请求,偶尔字段错位
  • 明明发的是两条消息,却像一条
  • 业务层收到半截数据就开始处理
  • 协议升级后兼容性变差

这些问题往往不是业务逻辑本身有问题,而是:

  • 消息边界没定义清楚
  • 接收方没有按协议切完整帧

所以这部分不是“网络细节课外题”,而是:

协议能否稳定运行的底座。

如果这层没处理好,再好的业务代码也建立在不稳定输入上。


3. 先建立最关键的直觉:TCP 不认消息,只认字节

TCP 更像一根水管。

发送端做的是:

  • 把字节持续写进水管

接收端做的是:

  • 从水管里持续把字节读出来

TCP 关心的是:

  • 字节顺序是否正确
  • 数据是否可靠送达

不关心

  • 你这一段字节是不是“第 1 条业务消息”
  • 下一段字节是不是“第 2 条业务消息”

所以在 TCP 看来,下面两次发送:

text
hello
world

接收方可能看到的是:

text
helloworld

也可能看到:

text
hel
loworld

甚至可能是:

text
hello
wor
ld

这就是为什么:

  • 你不能把一次 read 当作一条完整消息

4. 什么是粘包,什么是拆包

4.1 粘包

粘包是指:

  • 发送端发了多条消息
  • 接收端一次读取时把它们连在一起读到了

例如发送端连续发:

text
msg1
msg2

接收端一次读到:

text
msg1msg2

这就是粘包。

4.2 拆包

拆包是指:

  • 发送端发了一条完整消息
  • 接收端却分多次才读到完整内容

例如发送端发:

text
hello-netty

接收端第一次读到:

text
hello-

第二次再读到:

text
netty

这就是拆包。

4.3 为什么会发生

原因很多,但你先不必背协议栈细节,只要先知道:

  • TCP 是字节流
  • 网络传输、缓冲区、发送接收节奏都可能影响读写边界
  • 应用层“发一次”不等于对方“收一次”

5. 关键结论:必须自己定义消息边界

既然 TCP 不帮你区分消息边界,那应用层协议就必须自己补上这件事。

也就是说,协议设计最基本的任务之一就是:

  • 让接收方能判断一条完整消息在哪里结束

常见方案主要有这几类:

  • 定长协议
  • 分隔符协议
  • 长度字段协议
  • 更复杂的自定义帧协议

下面逐个讲。


6. 定长协议

定长协议的意思是:

  • 每条消息长度都固定

例如规定:

  • 每条消息永远 32 字节

那接收端就很简单:

  • 每次按 32 字节切一条

优点

  • 简单
  • 解码逻辑直接

缺点

  • 不灵活
  • 消息短时浪费空间
  • 消息长时装不下

适用场景

  • 报文结构非常固定
  • 历史协议或某些特定设备协议

大多数通用业务场景里,它不是最常用方案。


7. 分隔符协议

分隔符协议的思路是:

  • 每条消息结尾放一个特殊分隔符

例如:

text
hello\n
world\n

只要读到 \n,就认为一条消息结束。

优点

  • 直观
  • 人类可读性强
  • 适合简单文本协议

缺点

  • 如果正文里也可能出现分隔符,就要做转义
  • 二进制协议里不够稳妥
  • 超长消息处理也要额外注意

适用场景

  • 简单命令协议
  • 文本协议
  • 演示教学

8. 长度字段协议

这是最值得重点掌握的一类,因为在通用业务里非常常见。

它的思路是:

  • 先写一个长度字段
  • 再写真正的消息体

例如一条消息可能长这样:

text
[长度字段 4 字节][消息体 N 字节]

如果消息体是:

text
hello

长度就是 5。

接收方解码时就可以这样做:

  1. 先看前 4 个字节
  2. 得到消息体长度是 5
  3. 再判断当前缓冲区有没有足够 5 个字节
  4. 如果够,就切出一条完整消息
  5. 如果不够,就继续等后续字节

为什么它好用

因为它具备几个优势:

  • 边界清晰
  • 不依赖正文内容
  • 适合文本和二进制
  • 易于扩展复杂协议头

所以你会发现,很多 RPC、IM、自定义二进制协议,最后都会走向“长度字段 + 消息体”的思路。


9. 一个简单协议长什么样

你可以先设计一个最小协议:

text
[4字节长度][1字节消息类型][消息体内容]

例如:

  • 长度:9
  • 类型:1(表示登录请求)
  • 内容:alice123

这样接收端的思路就是:

  1. 先读前 4 字节拿长度
  2. 如果后面还没收够,就先不处理
  3. 收够后再切出整帧
  4. 再按类型去解释消息体

这就把两个问题拆开了:

  • 先解决边界
  • 再解决语义

这是非常重要的工程思路。


10. 为什么不能让业务代码直接处理半包

很多初学者会这样写:

  • 一收到 channelRead 的字节就立刻按完整消息解析

这很危险,因为你根本不能保证此时数据已经完整。

如果一条消息只收到了前半段,你就开始解析,常见后果是:

  • 长度字段读错
  • 字段错位
  • JSON 不完整
  • 反序列化失败
  • 上层业务拿到脏数据

所以更合理的流程应该是:

  1. 先在协议层解决完整帧切分
  2. 只有拿到完整消息后,才交给业务层

这就是为什么:

粘包拆包问题应该在编解码层解决,而不是丢给业务层兜底。


11. Netty 里通常怎么处理

Netty 的强项之一就是:

  • 它已经把这类“切帧”问题抽象成了解码器

例如常见的:

  • LengthFieldBasedFrameDecoder
  • DelimiterBasedFrameDecoder
  • LineBasedFrameDecoder
  • FixedLengthFrameDecoder

这几个类本质上都是在做同一件事:

  • 把原始字节流切成一条一条完整消息

也就是说,Netty 推荐你的套路不是:

  • 在业务 Handler 里手动 if/else 拼半包

而是:

  • 先用帧解码器处理边界
  • 后面的 Handler 再只处理“完整消息”

12. 最常用例子:LengthFieldBasedFrameDecoder

这是入门必须会看的一个类。

一个典型配置可能是:

java
pipeline.addLast(new LengthFieldBasedFrameDecoder(
        1024, // 最大帧长度
        0,    // 长度字段偏移量
        4,    // 长度字段长度
        0,    // 长度调整值
        4     // 跳过的初始字节数
));

如果你的协议是:

text
[4字节长度][消息体]

那这个配置就很好理解:

  • 长度字段从第 0 位开始
  • 长度字段本身占 4 字节
  • 切出完整帧后,前面 4 字节长度头不再往后传

你真正要理解的不是参数背诵,而是思路

思路就是:

  • 先看头部长度字段
  • 按长度字段决定何时构成完整帧

13. 一个最小示例:长度字段协议

13.1 编码器

java
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;
import java.nio.charset.StandardCharsets;

public class StringMessageEncoder extends MessageToByteEncoder<String> {
    @Override
    protected void encode(ChannelHandlerContext ctx, String msg, ByteBuf out) {
        byte[] data = msg.getBytes(StandardCharsets.UTF_8);
        out.writeInt(data.length);
        out.writeBytes(data);
    }
}

13.2 解码链配置

java
pipeline.addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4));
pipeline.addLast(new StringDecoder(StandardCharsets.UTF_8));
pipeline.addLast(new StringMessageEncoder());

这套结构里:

  • 编码器负责写入长度 + 正文
  • 帧解码器负责按长度字段切出完整消息
  • 字符串解码器负责把完整字节转成字符串
  • 业务 Handler 完全不需要关心半包问题

这就是分层带来的清晰感。


14. 协议设计时要考虑什么

很多人以为协议设计就是“字段怎么摆”。

其实更关键的是这些问题:

14.1 边界怎么识别

这是第一优先级。

14.2 最大包长是多少

必须设上限。

否则如果对方发一个异常超大长度:

  • 可能导致内存压力
  • 甚至造成攻击面

14.3 是否要带消息类型

这样你就能区分:

  • 登录请求
  • 心跳包
  • 业务请求
  • 响应包

14.4 是否要带版本号

协议未来扩展时会很重要。

14.5 是否要考虑校验

例如:

  • 魔数
  • 校验码
  • 序列号

这些字段都可能帮助你提升协议可维护性和排障能力。


15. 动手建议

建议你至少做 3 个小实验。

15.1 自己写一个长度字段协议

目标:

  • 真正理解为什么先写长度

15.2 故意制造半包场景

例如:

  • 分两次写同一条消息

目标:

  • 观察没有帧解码器时业务为什么会乱

15.3 分别试试 3 种边界方案

  • 定长
  • 分隔符
  • 长度字段

目标:

  • 理解为什么长度字段协议在通用业务里更常见

16. 最容易踩的坑

16.1 误以为一次 read 就是一条完整消息

这是最经典的坑。

16.2 协议里没有明确边界字段

结果接收方根本不知道该怎么切。

16.3 业务代码直接处理半包数据

会把协议问题污染到业务层。

16.4 没有限制最大帧长度

容易带来内存风险和恶意输入风险。

16.5 协议设计一开始只够 demo,用到线上就扩不动

例如:

  • 没版本号
  • 没消息类型
  • 没保留字段

后面升级会很痛苦。


17. 自测问题

  • 为什么说粘包拆包是 TCP 的天然问题,而不是 Netty 的 bug?
  • 为什么应用层必须自己定义消息边界?
  • 定长协议、分隔符协议、长度字段协议各自适合什么场景?
  • 为什么长度字段协议在很多业务里更常见?
  • 为什么应该在编解码层解决半包,而不是交给业务层处理?

18. 这一章你至少要带走什么

如果你看完这一章只记住 5 件事,就记下面这 5 件:

  1. TCP 传的是字节流,不天然提供消息边界
  2. 粘包拆包是正常现象,不是框架故障
  3. 协议设计首先要解决“如何识别一条完整消息”
  4. 长度字段协议是最常见、最通用的解决方案之一
  5. 边界问题应该在编解码层解决,业务层只处理完整消息

把这几个点吃透之后,你再看自定义协议、RPC、IM、网关报文处理,会发现底层思路其实是通的。