粘包拆包与协议设计
1. 这是什么
很多人第一次做网络程序时,都会下意识认为:
- 我发送了 1 次消息
- 对方就应该 1 次
read收到 1 条完整消息
但现实不是这样。
在 TCP 里,底层看到的通常不是“消息”,而是:
- 连续字节流
这就意味着:
- 你发的两条消息,接收方可能一次读出来
- 你发的一条消息,接收方也可能分多次才读完整
这两类现象,就是大家常说的:
- 粘包
- 拆包
如果先只记一句话,可以记成:
粘包拆包不是 Netty 的 bug,也不是 TCP 出错,而是 TCP 作为字节流协议的天然表现。
而所谓“协议设计”,本质上就是在回答:
- 接收方怎样知道“这一条消息从哪开始,到哪结束”
2. 为什么重要
网络程序里很多看起来“偶发”的问题,根源都在这里:
- 有时解析成功,有时失败
- 同样的请求,偶尔字段错位
- 明明发的是两条消息,却像一条
- 业务层收到半截数据就开始处理
- 协议升级后兼容性变差
这些问题往往不是业务逻辑本身有问题,而是:
- 消息边界没定义清楚
- 接收方没有按协议切完整帧
所以这部分不是“网络细节课外题”,而是:
协议能否稳定运行的底座。
如果这层没处理好,再好的业务代码也建立在不稳定输入上。
3. 先建立最关键的直觉:TCP 不认消息,只认字节
TCP 更像一根水管。
发送端做的是:
- 把字节持续写进水管
接收端做的是:
- 从水管里持续把字节读出来
TCP 关心的是:
- 字节顺序是否正确
- 数据是否可靠送达
它不关心:
- 你这一段字节是不是“第 1 条业务消息”
- 下一段字节是不是“第 2 条业务消息”
所以在 TCP 看来,下面两次发送:
hello
world接收方可能看到的是:
helloworld也可能看到:
hel
loworld甚至可能是:
hello
wor
ld这就是为什么:
- 你不能把一次
read当作一条完整消息
4. 什么是粘包,什么是拆包
4.1 粘包
粘包是指:
- 发送端发了多条消息
- 接收端一次读取时把它们连在一起读到了
例如发送端连续发:
msg1
msg2接收端一次读到:
msg1msg2这就是粘包。
4.2 拆包
拆包是指:
- 发送端发了一条完整消息
- 接收端却分多次才读到完整内容
例如发送端发:
hello-netty接收端第一次读到:
hello-第二次再读到:
netty这就是拆包。
4.3 为什么会发生
原因很多,但你先不必背协议栈细节,只要先知道:
- TCP 是字节流
- 网络传输、缓冲区、发送接收节奏都可能影响读写边界
- 应用层“发一次”不等于对方“收一次”
5. 关键结论:必须自己定义消息边界
既然 TCP 不帮你区分消息边界,那应用层协议就必须自己补上这件事。
也就是说,协议设计最基本的任务之一就是:
- 让接收方能判断一条完整消息在哪里结束
常见方案主要有这几类:
- 定长协议
- 分隔符协议
- 长度字段协议
- 更复杂的自定义帧协议
下面逐个讲。
6. 定长协议
定长协议的意思是:
- 每条消息长度都固定
例如规定:
- 每条消息永远 32 字节
那接收端就很简单:
- 每次按 32 字节切一条
优点
- 简单
- 解码逻辑直接
缺点
- 不灵活
- 消息短时浪费空间
- 消息长时装不下
适用场景
- 报文结构非常固定
- 历史协议或某些特定设备协议
大多数通用业务场景里,它不是最常用方案。
7. 分隔符协议
分隔符协议的思路是:
- 每条消息结尾放一个特殊分隔符
例如:
hello\n
world\n只要读到 \n,就认为一条消息结束。
优点
- 直观
- 人类可读性强
- 适合简单文本协议
缺点
- 如果正文里也可能出现分隔符,就要做转义
- 二进制协议里不够稳妥
- 超长消息处理也要额外注意
适用场景
- 简单命令协议
- 文本协议
- 演示教学
8. 长度字段协议
这是最值得重点掌握的一类,因为在通用业务里非常常见。
它的思路是:
- 先写一个长度字段
- 再写真正的消息体
例如一条消息可能长这样:
[长度字段 4 字节][消息体 N 字节]如果消息体是:
hello长度就是 5。
接收方解码时就可以这样做:
- 先看前 4 个字节
- 得到消息体长度是 5
- 再判断当前缓冲区有没有足够 5 个字节
- 如果够,就切出一条完整消息
- 如果不够,就继续等后续字节
为什么它好用
因为它具备几个优势:
- 边界清晰
- 不依赖正文内容
- 适合文本和二进制
- 易于扩展复杂协议头
所以你会发现,很多 RPC、IM、自定义二进制协议,最后都会走向“长度字段 + 消息体”的思路。
9. 一个简单协议长什么样
你可以先设计一个最小协议:
[4字节长度][1字节消息类型][消息体内容]例如:
- 长度:9
- 类型:1(表示登录请求)
- 内容:
alice123
这样接收端的思路就是:
- 先读前 4 字节拿长度
- 如果后面还没收够,就先不处理
- 收够后再切出整帧
- 再按类型去解释消息体
这就把两个问题拆开了:
- 先解决边界
- 再解决语义
这是非常重要的工程思路。
10. 为什么不能让业务代码直接处理半包
很多初学者会这样写:
- 一收到
channelRead的字节就立刻按完整消息解析
这很危险,因为你根本不能保证此时数据已经完整。
如果一条消息只收到了前半段,你就开始解析,常见后果是:
- 长度字段读错
- 字段错位
- JSON 不完整
- 反序列化失败
- 上层业务拿到脏数据
所以更合理的流程应该是:
- 先在协议层解决完整帧切分
- 只有拿到完整消息后,才交给业务层
这就是为什么:
粘包拆包问题应该在编解码层解决,而不是丢给业务层兜底。
11. Netty 里通常怎么处理
Netty 的强项之一就是:
- 它已经把这类“切帧”问题抽象成了解码器
例如常见的:
LengthFieldBasedFrameDecoderDelimiterBasedFrameDecoderLineBasedFrameDecoderFixedLengthFrameDecoder
这几个类本质上都是在做同一件事:
- 把原始字节流切成一条一条完整消息
也就是说,Netty 推荐你的套路不是:
- 在业务 Handler 里手动 if/else 拼半包
而是:
- 先用帧解码器处理边界
- 后面的 Handler 再只处理“完整消息”
12. 最常用例子:LengthFieldBasedFrameDecoder
这是入门必须会看的一个类。
一个典型配置可能是:
pipeline.addLast(new LengthFieldBasedFrameDecoder(
1024, // 最大帧长度
0, // 长度字段偏移量
4, // 长度字段长度
0, // 长度调整值
4 // 跳过的初始字节数
));如果你的协议是:
[4字节长度][消息体]那这个配置就很好理解:
- 长度字段从第 0 位开始
- 长度字段本身占 4 字节
- 切出完整帧后,前面 4 字节长度头不再往后传
你真正要理解的不是参数背诵,而是思路
思路就是:
- 先看头部长度字段
- 按长度字段决定何时构成完整帧
13. 一个最小示例:长度字段协议
13.1 编码器
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 解码链配置
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 件:
- TCP 传的是字节流,不天然提供消息边界
- 粘包拆包是正常现象,不是框架故障
- 协议设计首先要解决“如何识别一条完整消息”
- 长度字段协议是最常见、最通用的解决方案之一
- 边界问题应该在编解码层解决,业务层只处理完整消息
把这几个点吃透之后,你再看自定义协议、RPC、IM、网关报文处理,会发现底层思路其实是通的。