4-Netty TCP 粘包拆包
4-Netty TCP 粘包拆包核心概念什么是粘包/拆包TCP 像一根水管数据是水它只管按顺序把水流送到目的地不在乎你原来的水是分几杯装进去的。这就导致了粘包多个“水杯”被合并接成一桶和拆包一个“水杯”被劈成几瓢。用一个更形象的例子你通过邮局寄四本书给朋友。正常情况朋友收到四个包裹每包一本。✅粘包邮局为了省邮费把前两本书塞进了一个大箱子。朋友打开箱子发现两本书粘在了一起。❌拆包第三本书太厚邮局把它拆成上下两册分两个包裹寄。朋友先收到上册再收到下册。❌发生原因发送方的“攒单”习惯Nagle算法就像外卖员不想为了你的一杯奶茶跑一趟他会等攒够几个订单再一起送这就可能把几条小消息合并成一个包发出。包裹尺寸超限MSS/MTUTCP 这条“运输带”有宽度限制比如1500字节。一条大消息比如一张高清图会被自动切成几段才能送上“传送带”。接收方的“一次性读取”接收方应用层去缓冲区取数据时可能一次把积压在那里的多个消息包都读走了就像你一次性把信箱里所有信都拿出来分不清哪封是哪天的。四种解决方案对比选对工具事半功倍方案原理它怎么分包裹适用场景优缺点LineBasedFrameDecoder按“回车键”切分。约定每条消息以换行符\n或\r\n结尾读到这个符号就认为一条消息结束。文本协议如 Telnet、Redis 协议。优点极简零配置。缺点消息体里绝不能包含换行符否则会“误切”。DelimiterBasedFrameDecoder按“自定义暗号”切分。你指定一个特殊符号如$$作为消息分隔符读到它就切一刀。自定义文本协议。优点比换行符灵活可自定义。缺点同样要保证消息体里绝对不出现这个“暗号”否则乱套。FixedLengthFrameDecoder按“固定尺寸”切分。不管内容是什么约定每条消息都是 100 字节收到数据就按 100 字节一段来切。定长消息如某些金融、工控协议。优点简单粗暴效率高。缺点严重浪费带宽。如果消息只有10字节也得用100字节发剩下90字节填0。LengthFieldBasedFrameDecoder看“包裹尺寸标签”。最聪明的办法在每条消息的头部先用几个字节写明“我这条消息总共多长”。接收方先读这个长度再按长度精确读取后面的内容。通用二进制协议工业级首选。优点最灵活、最可靠能处理任意内容。缺点协议设计稍复杂需要理解几个参数。深入剖析LengthFieldBasedFrameDecoder“看标签”方案详解这是 Netty 解决粘包拆包的“王牌武器”理解它的五个核心参数你就掌握了协议设计的钥匙。假设我们设计一个简单的协议格式魔数类似文件后缀.jpg用于快速识别这是不是我们的协议包防误处理。类型消息类型比如 1登录2聊天。长度关键字段指明消息体部分有多少字节。消息体真正的业务数据。对应解码器参数设置newLengthFieldBasedFrameDecoder(maxFrameLength,// 最大帧长度防攻击或错误导致OOMlengthFieldOffset,// 长度字段的偏移量从第几个字节开始数lengthFieldLength,// 长度字段本身占几个字节lengthAdjustment,// 长度修正值最难理解见下文initialBytesToStrip// 解码后要跳过开头的几个字节)逐参数解析对照上面协议maxFrameLength 65535最大允许的帧长度防止恶意超长数据打爆内存。lengthFieldOffset 3长度字段从整个数据帧的第3个字节开始前面有2字节魔数1字节类型。lengthFieldLength 4长度字段自己占4个字节说明长度值是个 int。lengthAdjustment 0这是关键。调整后要读取的字节数 长度字段值 lengthAdjustment。如果长度字段的值 消息体的长度如上例则adjustment 0。如果长度字段的值 整个数据包的长度从魔数到消息体尾那么它包含了长度字段自身和前面的部分。这时adjustment - (lengthFieldLength lengthFieldOffset)。因为我们需要把长度字段自身和它前面的部分从总长度里减掉才是消息体的真实长度。initialBytesToStrip 7解码成功后我们希望把“包装”魔数2类型1长度47字节去掉只把纯净的“消息体”传递给后面的处理器。设置为7就会自动跳过前7个字节。一句话理解lengthAdjustment它告诉解码器长度字段里写的数字到底包含了哪些部分。你只需要对照协议做一次简单的加减法。对于LengthFieldBasedFrameDecoder的最佳实践生产环境无脑选LengthFieldBasedFrameDecoder它是为复杂、高性能场景而生的灵活性碾压其他方案。maxFrameLength必须设置这是安全防线防止内存耗尽。协议设计模板推荐魔数(2/4字节) 版本号(1字节) 类型(1字节) 长度(4字节) 消息体。魔数防误拆版本号便于协议升级。与LengthFieldPrepender配对使用这是编码器放在出站处理器里能自动帮你给消息加上长度字段头实现编码解码全自动。调试技巧初期可以用LoggingHandler放在最前面打印出十六进制的原始数据流对照你的协议格式逐一核对是排查粘包拆包问题最快的方法。配合 LengthFieldPrepender自动在出站消息前添加长度字段8. 自定义编解码器处理私有协议的灵魂许多物联网、游戏、金融等特定领域的通信都离不开自研的私有二进制协议。标准库的StringEncoder/Decoder或ObjectEncoder/Decoder在这些场景下完全失效。这时必须搬出Netty的底层能力——自定义编解码器Custom Codec。8.1 核心实现原理自定义编解码器本质上就是实现Netty定义的MessageToByteEncoder和ByteToMessageDecoder两个接口。编码器Encoder负责将应用程序中的Java对象即你的协议消息体转换成通过网络传输的字节流ByteBuf。解码器Decoder负责将接收到的原始字节流ByteBuf按照你的协议规则解析成一个或多个完整的、可供业务层处理的Java对象。8.2 实战一个简易协议的完整处理流程假设一个最基础的私有协议格式如下----------------------------------- | 魔数(2B) | 长度(4B) | 消息体(N字节) | -----------------------------------魔数Magic Number固定0xABEE用于快速识别协议开头过滤非法连接。长度Length指消息体的长度用于后续的拆包。消息体Body实际的数据载荷可以是JSON、Protobuf等序列化后的字节。第1步定义消息对象这是你的业务实体是解码后的结果也是编码前的输入。publicclassCustomMessage{privateshortmagic;// 魔数privateintlength;// 消息体长度privatebyte[]body;// 消息体// 省略getter/setter和构造函数}第2步实现解码器ByteToMessageDecoder解码器是处理粘包/拆包的绝对核心。核心在于decode方法它会反复被调用直到ByteBuf中没有足够的数据构成一个完整包。publicclassCustomDecoderextendsByteToMessageDecoder{privatestaticfinalintMAGIC_NUMBER0xABEE;privatestaticfinalintMAGIC_FIELD_LENGTH2;privatestaticfinalintLENGTH_FIELD_LENGTH4;privatestaticfinalintHEADER_LENGTHMAGIC_FIELD_LENGTHLENGTH_FIELD_LENGTH;Overrideprotectedvoiddecode(ChannelHandlerContextctx,ByteBufin,ListObjectout){// 1. 检查可读字节数是否至少满足最小包头长度if(in.readableBytes()HEADER_LENGTH){return;// 数据不足等待下次触发}in.markReaderIndex();// 标记当前读指针位置// 2. 读取并验证魔数shortmagicin.readShort();if(magic!MAGIC_NUMBER){// 魔数不匹配可能是非法数据或半包错位in.resetReaderIndex();// 重置读指针// 处理策略可以跳过1字节继续探测或直接关闭连接in.skipBytes(1);return;}// 3. 读取消息体长度intbodyLengthin.readInt();// 安全校验防止恶意超大包导致内存溢出if(bodyLength0||bodyLength1024*1024){// 例如限制1MBin.resetReaderIndex();thrownewCorruptedFrameException(Invalid body length: bodyLength);}// 4. 检查可读字节数是否满足“包头长度 消息体长度”if(in.readableBytes()bodyLength){in.resetReaderIndex();// 数据不足重置读指针等待下次return;}// 5. 数据足够读取完整消息体byte[]bodynewbyte[bodyLength];in.readBytes(body);// 6. 构造消息对象添加到解码列表供后续Handler处理out.add(newCustomMessage(magic,bodyLength,body));}}第3步实现编码器MessageToByteEncoder编码器相对简单它接收一个CustomMessage对象并按规定格式写入ByteBuf。publicclassCustomEncoderextendsMessageToByteEncoderCustomMessage{Overrideprotectedvoidencode(ChannelHandlerContextctx,CustomMessagemsg,ByteBufout){// 写入魔数out.writeShort(msg.getMagic());// 写入消息体长度out.writeInt(msg.getBody().length);// 写入消息体out.writeBytes(msg.getBody());}}第4步在Pipeline中装配将你的编解码器加入到服务端/客户端的ChannelPipeline中顺序至关重要。// 服务端接收客户端消息ch.pipeline().addLast(newCustomDecoder())// 入站先解码.addLast(newCustomEncoder())// 出站后编码通常也添加用于回复.addLast(newCustomServerHandler());// 业务处理器// 客户端发送请求ch.pipeline().addLast(newCustomEncoder())// 出站先编码.addLast(newCustomDecoder())// 入站后解码接收响应.addLast(newCustomClientHandler());// 业务处理器8.3 处理粘包/拆包的进阶技巧使用LengthFieldBasedFrameDecoder如果你的协议本质是“长度内容”Netty已经为你造好了轮子。它可以通过参数配置自动处理长度字段偏移量、调整等极大简化了解码器开发。上面的自定义解码器示例完全可以用它替代。半包处理的艺术关键在于ByteToMessageDecoder.decode方法中的in.markReaderIndex()和in.resetReaderIndex()。当发现数据不足时必须重置读指针让下一次TCP数据到来时能重新从正确位置开始解析。忘记重置是新手最常见的Bug会导致后续所有数据解析错乱。处理协议版本协议可能升级。可以在消息头中增加版本字段Version。解码时根据版本号选择不同的解析逻辑。编码时同理。8.4 最佳实践清单单一职责解码器只负责协议解析解析出的对象应立刻交给后续的业务Handler处理业务逻辑。不要混在一起。资源释放ByteBuf是引用计数的如果在解码器中读取了数据但没有传递出去例如解析失败丢弃需要手动release()防止内存泄漏。通常成功添加到out列表的ByteBufNetty会自动处理。线程安全ByteToMessageDecoder和MessageToByteEncoder本身是Sharable的但前提是你的实现是无状态的。如果解码器内部有可变状态如用于解析变长协议的临时缓冲区则不能标注Sharable每个Channel都需要新实例。性能考量避免在编解码器中创建大量临时对象如频繁new byte[]。考虑使用对象池如Recycler复用CustomMessage对象。对于高频消息byte[]拷贝是性能瓶颈可研究使用CompositeByteBuf实现零拷贝。健壮性优先在解码器中加入所有你能想到的校验魔数、长度范围、校验和等。一个健壮的协议处理器是系统稳定的第一道防线。掌握自定义编解码器意味着你真正理解了网络数据流的本质。从此任何千奇百怪的二进制协议在你面前都将变得有迹可循。