Netty 如何解决 TCP 粘包、拆包的问题?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书
《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于
Spring AI + Spring Boot 3.x + JDK 21...,查看介绍《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...,查看介绍;演示链接:http://116.62.199.48:7070/《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/
新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍
截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
对 TCP 协议的理解:面试官想看你是否真的明白 TCP 是 "面向字节流" 的协议,没有消息边界这回事。很多人粘包拆包讲不清楚,根因就是对 "流" 这个概念没吃透。
-
Netty 实战经验:考察你在实际项目中是怎么处理这个问题的——是知道有现成的解码器,还是傻乎乎自己手写拆包逻辑。
-
解码器原理的理解深度:如果你只知道
LengthFieldBasedFrameDecoder这个类名,但说不出它的几个参数啥意思、底层ByteToMessageDecoder是怎么累积数据的,那只能算 "会用",离 "懂原理" 还差一截。
核心答案
一句话总结:TCP 是面向流的协议,本身没有消息边界,粘包拆包是它的天然特性,不是 bug。Netty 的解法是在 pipeline 里加专门的解码器(Decoder),通过约定好的规则把字节流重新切分成一条条完整的消息。
Netty 内置了 4 种主流解码器:
| 解码器 | 切分依据 | 适用场景 |
|---|---|---|
FixedLengthFrameDecoder |
固定长度 | 简单协议、定长报文 |
LineBasedFrameDecoder |
换行符 \n / \r\n |
文本协议 |
DelimiterBasedFrameDecoder |
自定义分隔符 | 自定义文本协议 |
LengthFieldBasedFrameDecoder |
消息头长度字段 | 最通用、最推荐 |
绝大多数业务场景,答案就一个:LengthFieldBasedFrameDecoder。下面展开讲。
深度解析
一、为什么会出现粘包、拆包?
这块得先想明白,不然用啥解码器都是空中楼阁。TCP 是个 "流" 式协议,它根本不知道你应用层一条 "消息" 长啥样,在它眼里全是字节。底层只管把字节可靠地、有序地送达,至于边界——对不起,不归它管。
我画个图你就懂了:
上图把粘包拆包的几种典型情况都列出来了。造成这些情况的原因,捋一捋就三个方面:
- **写入方:**应用层一次写入的数据量,和 socket 缓冲区大小不匹配。写多了,TCP 会拆开发;写少了,多条消息可能塞在一起发。
- **传输方:**MSS(最大报文段长度)限制、链路 MTU 限制,会把大包切成小包。
- **读取方:**接收方应用层从缓冲区读数据不及时,多条消息在缓冲区里堆成一坨。
说到底:粘包拆包不是 bug,是 TCP 流式协议的天然产物。所以这玩意儿得在应用层解决,TCP 不会帮你解决。
二、Netty 的四种解码器
Netty 的设计很贴心——粘包拆包太常见了,干脆内置几套开箱即用的方案。它们都继承自 ByteToMessageDecoder,你只需要往 pipeline 里一加就行。
1. 固定长度:FixedLengthFrameDecoder
每条消息长度固定,比如每 100 字节算一条。
// 每条消息固定 100 字节
pipeline.addLast(new FixedLengthFrameDecoder(100));
缺点很明显:真实业务里消息长度哪能这么凑齐?短消息也得补齐到 100 字节,浪费带宽。基本只用于某些定长的二进制协议。
2. 行分隔符:LineBasedFrameDecoder
以 \n 或 \r\n 作为一条消息的结束。
// 一条消息最大长度 1024,超过就抛异常
pipeline.addLast(new LineBasedFrameDecoder(1024));
适合纯文本协议(比如 HTTP 的简化版、Redis 协议 RESP)。缺点是消息里不能出现 \n,所以二进制场景不能用。
3. 自定义分隔符:DelimiterBasedFrameDecoder
跟上面类似,但分隔符可以自己定,比如用 $_。
ByteBuf delimiter = Unpooled.copiedBuffer("$_".getBytes());
pipeline.addLast(new DelimiterBasedFrameDecoder(1024, delimiter));
LineBasedFrameDecoder 其实就是它的一种特例,分隔符固定为换行符而已。
4. 长度字段:LengthFieldBasedFrameDecoder(重点)
这是工业界最通用的方案,也是面试官最爱追问的。思路很直接:在消息头里加一个长度字段,告诉接收方 "后面有多少字节是真正的消息体"。
消息格式大概长这样:
+--------+----------+----------------+
| 长度字段 | 其他头部 | 消息体 (payload) |
+--------+----------+----------------+
用法:
pipeline.addLast(new LengthFieldBasedFrameDecoder(
1024 * 1024, // maxFrameLength: 最大帧长度,超过就抛异常
0, // lengthFieldOffset: 长度字段的偏移量(从第 0 字节开始就是长度字段)
4, // lengthFieldLength: 长度字段占 4 字节(int)
0, // lengthAdjustment: 长度调整值(长度字段的值 = 消息体长度,无需调整)
4 // initialBytesToStrip: 解码后跳过 4 字节(把长度字段本身剥掉)
));
这 5 个参数是面试重灾区,单独拎出来说:
| 参数 | 含义 |
|---|---|
maxFrameLength |
单条消息最大长度,防止恶意包撑爆内存 |
lengthFieldOffset |
长度字段从第几个字节开始(前面可能有魔数、版本号等头部) |
lengthFieldLength |
长度字段本身占几个字节(1/2/3/4/8) |
lengthAdjustment |
长度修正值(如果长度字段的值包含了头部,需要减掉) |
initialBytesToStrip |
解码后跳过的字节数(通常跳过长度字段本身) |
这块看着绕,但只要你画一下消息的字节布局,对应几个参数,就通了。别死记。
三、自己写解码器:继承 ByteToMessageDecoder
如果上面四种都不满足(比如协议特别复杂),Netty 也允许你自己写解码器。继承 ByteToMessageDecoder,实现 decode() 方法:
public class MyDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
// in 是累积缓冲区,可能包含半条消息,也可能包含多条消息
// 必须先判断是否有足够字节可读
if (in.readableBytes() < 4) {
return; // 连长度字段都凑不齐,等下次
}
in.markReaderIndex(); // 标记一下当前位置
int length = in.readInt(); // 读长度
if (in.readableBytes() < length) {
in.resetReaderIndex(); // 不够长,回滚,等下次
return;
}
// 读取真正的消息体
byte[] body = new byte[length];
in.readBytes(body);
out.add(new String(body, StandardCharsets.UTF_8));
}
}
关键点有两个:
- 不够读就 return:缓冲区里数据不够时千万别硬读,下次数据来了 Netty 会再调一次
decode()。 markReaderIndex/resetReaderIndex:读了一半发现不够,要能回滚到读之前的位置。
四、底层原理:ByteToMessageDecoder 是怎么累积数据的?
说到这儿,面试官可能就要追问 "底层怎么实现的" 了。ByteToMessageDecoder 内部维护了一个累积缓冲区叫 cumulation,核心逻辑就两步:
上图就是 ByteToMessageDecoder 的核心循环。说人话就是:
- **累积:**每次有数据进来,不急着处理,先追加到
cumulation这个大ByteBuf里。 - **循环解码:**然后反复调用子类的
decode()方法,每次成功解出一条消息就传给下游 handler,直到解码不出新消息为止。 - **保留剩余字节:**最后剩下的半条消息(不够解的),继续留在
cumulation里,等下一波数据来。
这套机制妙就妙在:子类不用关心 "数据够不够" 这种琐事,只管 "给我一坨字节,我能解几条算几条"。半包这事儿框架帮你兜底了。
面试高频追问
-
追问一:为什么 TCP 会出现粘包,UDP 会不会?
UDP 不会。因为 UDP 是面向报文的,每个数据包有明确边界,send 一次就是 recv 一次。TCP 才是面向流的,没有边界。这也是为啥 RPC 框架基本都自己实现一套协议封装。
-
追问二:
LengthFieldBasedFrameDecoder的lengthAdjustment参数什么时候不为 0?当长度字段表示的字节数包含了部分头部时需要调整。比如长度字段的值是 "整个消息总长"(含长度字段本身和头部),而你只想读消息体,就得减去头部长度,此时
lengthAdjustment为负数。 -
追问三:如果不处理粘包拆包会怎样?
业务侧拿到的
ByteBuf可能是半条消息、一整条 + 半条、甚至多条半条混合。结果就是反序列化失败、业务报错、甚至直接挂掉。这坑踩过的人都懂。
常见面试变体
- "讲讲 Netty 的
ByteToMessageDecoder原理" - "
LengthFieldBasedFrameDecoder几个参数分别啥意思?" - "为什么 UDP 没有粘包拆包问题?"
记忆口诀
四个解码器:定长、换行、分隔符、长度字段——记不住的可以背 "固定行,分隔长"。生产首选长度字段法,记住五个参数:最大长、偏移、长长、调整、跳过。
总结
一句话:粘包拆包是 TCP 流式协议的天然特性,必须在应用层解决。Netty 在 pipeline 里挂个解码器帮你把活儿干了,LengthFieldBasedFrameDecoder 是最通用的那个方案,四个解码器加自定义解码器,基本能覆盖各种业务场景。把字节累积 → 循环解码 → 剩余保留这套底层机制吃透,这块面试基本稳了。
