Netty 如何解决 TCP 粘包和拆包?常用解码器有哪些?
简化版
TCP 是「字节流」协议,没有消息边界——一次 write 不对应一次 read,多条消息可能粘在一起(粘包)、一条消息可能分几次到达(拆包)。这不是 TCP 的错,是流式传输的正常表现。解决办法只有一个方向:在应用层协议里明确定义「消息边界(帧)」。Netty 提供了现成的帧解码器按不同方式切分:FixedLengthFrameDecoder(固定长度)、DelimiterBasedFrameDecoder(分隔符)、LineBasedFrameDecoder(行)、LengthFieldBasedFrameDecoder(长度字段,最通用)——先读消息头里的长度,等完整 body 到齐再往后传一个完整帧。
详细版
为什么粘包拆包: TCP 只保证「字节有序可靠到达」,不保证「一次 write = 一次 read」。发送缓冲、Nagle 算法、接收缓冲、网络分片都会导致消息合并或分割。
四种常用帧解码器:
| 解码器 | 切分方式 | 适用 |
|---|---|---|
FixedLengthFrameDecoder | 固定长度 | 定长记录、心跳 |
DelimiterBasedFrameDecoder | 自定义分隔符 | 文本协议 |
LineBasedFrameDecoder | 换行符 | 行文本协议 |
LengthFieldBasedFrameDecoder | 长度字段 | 最通用(RPC、自定义协议) |
长度字段协议(最常用)示意:
+--------+--------+----------------+
| 魔数 | 长度 | body |
| 2字节 | 4字节 | 长度个字节 |
+--------+--------+----------------+
先读长度字段 → 等 body 到齐 → 向后传一个完整帧
完整版教学
一、为什么会粘包拆包
TCP 只保证「字节有序、可靠地到达」,但不保证「一次 write 对应一次 read」。多种因素导致消息的合并和分割:
- 发送端缓冲:数据先进 TCP 发送缓冲区,可能攒着一起发。
- Nagle 算法:为提高效率,把小包攒成大包再发(可能把多条小消息合并)。
- 接收端缓冲:接收缓冲区里可能积累了多条消息,一次 read 全读出来。
- 网络分片(MSS):一条大消息可能被分成多个 TCP 段传输。
所以接收端一次 read 到的字节,可能是「多条消息粘在一起」(粘包),也可能是「一条消息的一部分」(拆包/半包)。
二、根因是没有消息边界
问题的根本原因:应用以「消息」为单位思考,但 TCP 以「字节流」为单位传输。TCP 就像一根水管,你倒进去几杯水(几条消息),流出来的是连续的水流——接收端不知道「读到哪里算一条完整消息」。
所以解决方向永远是:在应用层协议里定义「帧格式」,明确消息边界。这不是 Netty 特有的问题,任何基于 TCP 的自定义协议都要处理。Netty 只是提供了现成的工具。
TCP 字节流: [msg1][msg2][msg3...]
一次 read: [msg1][msg2 的前半段]
下一次 read: [msg2 的后半段][msg3]
粘包拆包不是 TCP 的 bug,而是 TCP 字节流模型的正常结果;真正缺的是应用层消息边界。
三、固定长度协议
FixedLengthFrameDecoder:每条消息长度固定(如每条 100 字节),解码器每读满固定长度就切出一帧。
- 优点:简单高效。
- 缺点:浪费空间(消息短也要占满固定长度)或限制业务表达(消息不能超过固定长度)。
- 适用:心跳包、简单的定长二进制记录。
四、分隔符和行协议
DelimiterBasedFrameDecoder(自定义分隔符) 和 LineBasedFrameDecoder(换行符):用特殊分隔符切消息,读到分隔符就算一帧结束。
- 适用:文本协议(如以
\n分隔的命令、Redis 的部分协议)。 - 注意事项:要处理分隔符转义(消息内容里若包含分隔符怎么办)、最大长度限制(防止一直读不到分隔符导致 buffer 无限增长)、恶意输入(攻击者发一个没有分隔符的超长流,可能拖垮内存)。
五、长度字段协议(最通用)
LengthFieldBasedFrameDecoder 是最常用、最通用的——协议头里有一个长度字段,标明后面 body 的长度。解码器工作方式:先读长度字段 → 知道这一帧还要多少字节 → 等 body 完整到齐 → 切出一个完整帧向后传播。
它有几个关键参数(要理解):
lengthFieldOffset:长度字段在协议里的偏移(前面可能有魔数等)。lengthFieldLength:长度字段本身占几字节(如 4 字节)。lengthAdjustment:长度修正(长度字段的值和实际 body 长度的偏差修正)。initialBytesToStrip:解码后跳过多少字节(如跳过协议头,只把 body 给业务)。
RPC 框架(Dubbo)、自定义网关协议、IM 协议基本都用长度字段协议——因为它能处理任意长度的消息,最灵活。
| 协议方式 | Netty 解码器 | 适合场景 | 主要风险 |
|---|---|---|---|
| 固定长度 | FixedLengthFrameDecoder | 心跳、定长记录 | 浪费空间或限制表达 |
| 分隔符 | DelimiterBasedFrameDecoder | 简单文本命令 | 内容转义和超长帧 |
| 行协议 | LineBasedFrameDecoder | 按 \n 结束的文本协议 | 没有换行会持续累积 |
| 长度字段 | LengthFieldBasedFrameDecoder | RPC、IM、自定义二进制协议 | 参数配置错误或超大长度攻击 |
六、解码器放在 pipeline 的位置
解码器要放在业务 Handler 前面(更靠 Pipeline 头部,入站方向先经过):把 ByteBuf(字节)转成完整的消息对象,再传给业务 Handler。这样业务 Handler 拿到的永远是「完整的一条消息」,不用自己猜半包状态——协议边界处理和业务逻辑彻底分离。
对应地,编码器放在出站方向,把业务响应对象转成符合协议格式的字节(加上长度字段、魔数等)。
七、生产防护(安全)
生产环境的帧协议要考虑安全:
- 必须设置最大帧长度(
maxFrameLength):防止攻击者发一个「长度字段写成超大值」的包,导致解码器一直等 body、buffer 无限增长撑爆内存(DoS 攻击)。这是必配项。 - 协议头应包含更多字段:魔数(magic number,快速识别合法协议/过滤非法流量)、版本号(协议升级兼容)、序列号(请求响应对应)、序列化类型、压缩标记等。
八、协议演进也要考虑边界
长度字段协议不仅解决拆包,还为协议升级留空间。生产级协议头通常包含:魔数 + 版本 + 序列化类型 + 压缩标记 + 请求序号 + 长度字段。解码器先校验魔数和版本,再按长度取完整帧。
好处:遇到错连、旧客户端、恶意流量时能更快失败(魔数不对直接拒绝),而不是把非法字节继续交给业务层解析(可能引发异常或安全问题)。协议设计要为「演进」和「防御」留余地。
九、常见误区与追问
- 误区:粘包拆包是 Netty 或 TCP 出错。 TCP 本来就是可靠有序字节流,不承诺一次 write 对应一次 read,应用层必须定义帧边界。
- 误区:业务 Handler 里自己拼半包最简单。 这样会把协议边界和业务逻辑耦合,正确做法是用解码器先产出完整消息。
- 误区:使用分隔符协议不需要最大长度。 如果一直读不到分隔符,缓冲区会持续增长,必须配置最大帧长度防止内存被打爆。
- 追问:长度字段协议为什么最常用? 它能明确知道 body 长度,适合任意长度的二进制消息,也便于扩展魔数、版本、序列化等协议头字段。
- 追问:
lengthAdjustment用来解决什么? 用来修正长度字段值和实际帧长度之间的差异,例如长度字段只表示 body,或包含部分头部。 - 追问:解码器为什么要放在业务 Handler 前面? 入站数据先经过解码器,业务 Handler 才能拿到完整消息对象,而不是未分帧的 ByteBuf。
十、加强记忆
TCP 是字节流、无消息边界——粘包(多消息粘一起)/拆包(一消息分多次)是流式传输的正常现象,根因是「应用按消息、TCP 按字节流」。解决只能靠应用层协议定义「帧边界」。Netty 四种帧解码器:FixedLengthFrameDecoder(定长,简单但浪费/受限)、DelimiterBasedFrameDecoder/LineBasedFrameDecoder(分隔符/行,适合文本,注意转义和最大长度)、LengthFieldBasedFrameDecoder(长度字段,最通用,RPC/自定义协议首选,参数 offset/length/adjustment/strip)。解码器放业务 Handler 前(业务只拿完整消息、不猜半包)。生产必设 maxFrameLength 防超大包撑爆内存,协议头加魔数/版本/序列化/压缩/序号便于识别非法流量和演进(先校验魔数版本再按长度取帧)。