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+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 对 TCP 协议的理解:面试官想看你是否真的明白 TCP 是 "面向字节流" 的协议,没有消息边界这回事。很多人粘包拆包讲不清楚,根因就是对 "流" 这个概念没吃透。

  2. Netty 实战经验:考察你在实际项目中是怎么处理这个问题的——是知道有现成的解码器,还是傻乎乎自己手写拆包逻辑。

  3. 解码器原理的理解深度:如果你只知道 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,核心逻辑就两步:

Netty 解码器累积缓冲区循环解码原理
Netty 解码器累积缓冲区循环解码原理

上图就是 ByteToMessageDecoder 的核心循环。说人话就是:

  • **累积:**每次有数据进来,不急着处理,先追加到 cumulation 这个大 ByteBuf 里。
  • **循环解码:**然后反复调用子类的 decode() 方法,每次成功解出一条消息就传给下游 handler,直到解码不出新消息为止。
  • **保留剩余字节:**最后剩下的半条消息(不够解的),继续留在 cumulation 里,等下一波数据来。

这套机制妙就妙在:子类不用关心 "数据够不够" 这种琐事,只管 "给我一坨字节,我能解几条算几条"。半包这事儿框架帮你兜底了。

面试高频追问

  1. 追问一:为什么 TCP 会出现粘包,UDP 会不会?

    UDP 不会。因为 UDP 是面向报文的,每个数据包有明确边界,send 一次就是 recv 一次。TCP 才是面向流的,没有边界。这也是为啥 RPC 框架基本都自己实现一套协议封装。

  2. 追问二:LengthFieldBasedFrameDecoderlengthAdjustment 参数什么时候不为 0?

    当长度字段表示的字节数包含了部分头部时需要调整。比如长度字段的值是 "整个消息总长"(含长度字段本身和头部),而你只想读消息体,就得减去头部长度,此时 lengthAdjustment 为负数。

  3. 追问三:如果不处理粘包拆包会怎样?

    业务侧拿到的 ByteBuf 可能是半条消息、一整条 + 半条、甚至多条半条混合。结果就是反序列化失败、业务报错、甚至直接挂掉。这坑踩过的人都懂。

常见面试变体

  • "讲讲 Netty 的 ByteToMessageDecoder 原理"
  • "LengthFieldBasedFrameDecoder 几个参数分别啥意思?"
  • "为什么 UDP 没有粘包拆包问题?"

记忆口诀

四个解码器:定长、换行、分隔符、长度字段——记不住的可以背 "固定行,分隔长"。生产首选长度字段法,记住五个参数:最大长、偏移、长长、调整、跳过

总结

一句话:粘包拆包是 TCP 流式协议的天然特性,必须在应用层解决。Netty 在 pipeline 里挂个解码器帮你把活儿干了,LengthFieldBasedFrameDecoder 是最通用的那个方案,四个解码器加自定义解码器,基本能覆盖各种业务场景。把字节累积 → 循环解码 → 剩余保留这套底层机制吃透,这块面试基本稳了。