ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Netty粘包拆包解析:解码器源码与ByteBuf实践

Netty粘包拆包解析:解码器源码与ByteBuf实践 1. 粘包拆包的本质与Netty的解码器设计1.1 为什么TCP会产生粘包和拆包我做了这么多年网络编程几乎每个接触Netty的人都会碰到粘包拆包问题。面试的时候我也经常拿这个问题去问候选人说实话能讲清楚底层原因的人真不多。这里先用最直白的话把本质讲透。TCP是面向字节流的传输协议所谓“流”就是没有边界的。你调用write方法发送100个字节TCP协议栈不会保证这100个字节作为一个整体到达对端它可能拆成3个包分批发出去也可能和后面的数据粘在一起到达。这是因为TCP为了保证传输效率内部有Nagle算法、滑动窗口、拥塞控制等机制这些机制会自动合并或拆分应用层的数据。打个比方你把一封信撕成三片分别塞进三个信封寄出去收信人收到三个信封后需要自己把这封信拼起来才能看懂内容。而粘包就是另一个极端——你把三封信装进同一个大信封寄出收件人得拆开大信封把三封信按照约定的帧边界重新分开。所以TCP根本没有“消息”这个概念它只管字节收发。应用层必须自己定义消息的边界规则。Netty的Decoder类本质就是一套帮你恢复消息边界的框架。1.2 Netty为什么选择“解码器责任链”这套方案刚接触Netty源码的时候我最困惑的一件事就是明明Handler里就能处理ByteBuf为什么非要在前面加一个Decoder其实这是Netty设计上的一个核心哲学关注点分离。业务Handler需要的应该是“一条完整消息”而不是“一堆还缺胳膊少腿的字节”。Netty把“字节流 → 消息对象”的转换过程单独抽出来做成一个Pipeline节点这样有几个显而易见的好处。第一点是可组合性。你可以在Decoder后面继续挂其他Handler比如解密Handler、反序列化Handler、业务处理Handler每个节点只关心自己那一层的事。第二点是可复用性。Netty内置了各种解码器定长的、分隔符的、长度字段的你不需要自己重新造轮子。第三点最核心Pipeline责任链模式天然天然契合Netty的I/O线程模型——数据在Pipeline里一站一站往后传整个过程无锁化串行执行不需要额外加锁。我还记得第一次阅读Netty源码时最大的震撼就是看到了ChannelHandler和ChannelPipeline之间的紧密协作。ChannelPipeline内部维护了一个双向链表每个节点是一个ChannelHandlerContext数据从头部进入经过每个handler的处理后继续向下传播。这种设计带来了极高的解耦度也让源码的逻辑非常清晰——你要分析一个解码器的实现只需要盯住它在Pipeline里的位置以及它的channelRead方法就够了。2. ByteToMessageDecoder所有解码器的“老祖宗”2.1 channelRead方法的核心逻辑我建议凡是想要搞懂Netty解码头的人都先从ByteToMessageDecoder这个抽象类下手。它是后面所有解码器的父类读懂了它等于拿到了解开所有解码器源码的钥匙。它的核心入口是channelRead方法源码不长但每一行都是精华。我先贴出简化版的逻辑Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof ByteBuf) { CodecOutputList out CodecOutputList.newInstance(); try { ByteBuf data (ByteBuf) msg; first cumulation null; if (first) { cumulation data; } else { cumulation cumulator.cumulate(ctx.alloc(), cumulation, data); } callDecode(ctx, cumulation, out); } catch (DecoderException e) { throw e; } catch (Exception e) { throw new DecoderException(e); } finally { // 处理半包保留剩余数据 if (cumulation ! null !cumulation.isReadable()) { numReads 0; cumulation.release(); cumulation null; } // 尝试移除解码器 if (decodeState STATE_HANDLER_REMOVED_PENDING) { handlerRemoved0(ctx); } // 转发解码后的消息 int size out.size(); fireChannelRead(ctx, out, size); out.recycle(); } } else { ctx.fireChannelRead(msg); } }我把这段代码拆成几个关键点来讲。第一累积器cumulation的作用。由于TCP的粘包拆包这一次收到的ByteBuf可能只是半条消息也可能包含好几条消息。Netty用一个累积器把之前残留的数据和这次新来的数据拼在一起拼成一个大的ByteBuf再交给decode方法去解析。注意first这个变量第一次进来时cumulation还是null直接把data赋给它非第一次就把旧数据和data通过cumulator合并。Netty默认使用的是MERGE_CUMULATOR它会尝试合并ByteBuf如果内存连续就直接扩容写进去避免不必要的拷贝。第二CodecOutputList是什么。它不是普通的ArrayList而是Netty针对解码场景做的一个对象池优化。因为解码过程极其频繁如果每次都new一个ArrayList再丢掉GC的压力会非常大。CodecOutputList内部维护了一个可扩展的对象数组用完了调用recycle归还到对象池下次还能用。这块代码建议所有做高性能Java开发的朋友都读一下它演示了如何通过对象池规避高频调用的内存分配。第三半包的处理机制。如果收到的数据只够组成半条消息decode方法中Netty会决定不再继续往下传播数据剩余的字节继续留在cumulation里等待下个TCP数据包到达后再拼接。也就是说半包不处理留到下一次channelRead再继续。第四fireChannelRead的时机。所有解码出来的消息都是在一个channelRead方法里统一向外传播而不是解码出一条就传一条。这样做的原因很简单避免在循环中频繁触发Pipeline的下一次传播从而提升性能。你可以去看一下Netty的fireChannelRead实现它会对out中的消息做循环调用ctx.fireChannelRead。2.2 callDecode的解码循环是怎么运转的理解了channelRead的整体框架再来看callDecode。这个方法是整条解码流水线的心脏它决定了一次累积器中的数据要经过几轮decode才能清空。protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { try { while (in.isReadable()) { int outSize out.size(); if (outSize 0) { fireChannelRead(ctx, out, outSize); out.clear(); if (ctx.isRemoved()) { break; } outSize 0; } int oldInputLength in.readableBytes(); decodeRemovalReentryProtection(ctx, in, out); if (ctx.isRemoved()) { break; } if (outSize out.size()) { if (oldInputLength in.readableBytes()) { break; } else { continue; } } if (oldInputLength in.readableBytes()) { throw new DecoderException( decode() did not read anything but decoded a message.); } if (isSingleDecode()) { break; } } } catch (DecoderException e) { throw e; } catch (Exception cause) { throw new DecoderException(cause); } }这个循环有几个地方特别容易看晕我逐个说清楚。最巧妙的是循环退出条件的判断。每次decode之前先记录outSize和oldInputLengthdecode之后对比如果outSize没变说明这次decode没有解码出任何消息。如果oldInputLength没变说明没有消费任何字节。如果两个都没变说明数据不够组成一条完整的消息也就是半包直接break退出循环——这也是前面说的半包机制在哪里真正生效的地方。如果outSize没变但oldInputLength变了说明消费了字节但没有产出消息这种情况一般发生在状态机类解码器里数据被缓存进内部状态了继续循环尝试解析。如果outSize变了说明decode成功产生了一条消息。此时要检查isSingleDecode——如果设置为true比如ByteToMessageCodec的某些场景解码出一条就立即break不再继续否则继续while循环看累积器里还有没有更多可解码的数据。这里还有一点值得注意当outSize 0 时Netty会先把已经解码出来的消息fire出去然后clear掉out再继续下一次decode。这是为了防止一个TCP包中包含大量消息时out无限膨胀占用内存。每次最多积攒一批、传播一批、清空一批。2.3 解码器的自动移除机制ByteToMessageDecoder还有一个非常巧妙的设计如果它内部检测到解码器已经不需要了可以自动把自己从Pipeline中移除。源码里有一个decodeLast方法专门在channelInactive时被调用。另外在channelRead的finally块中会检查decodeState是否等于STATE_HANDLER_REMOVED_PENDING如果是就调用handlerRemoved0来完成移除。这个机制有什么用最常见的场景是做协议升级。比如一个连接刚建立时走的是字符串协议后面切换成二进制协议你就可以自定义一个解码器解析到特定指令后让解码器自杀并替换成新的解码Handler。这种动态可插拔的设计在生产环境做灰度升级时非常管用。不过这里我要提醒一下在decode方法里千万不要直接ctx.pipeline().remove(this)。因为当前线程正在执行Pipeline的链路传播直接操作Pipeline链表会导致并发修改问题。Netty是通过pending机制来处理的设置状态后等channelRead整个流程走完在finally块再执行真正的移除。3. 四个内置解码器的行为差异与底层实现3.1 LineBasedFrameDecoder最简单的换行符解码器LineBasedFrameDecoder是学习源码最好的入门案例因为它逻辑非常直白——按行\n或\r\n切分消息。服务端聊天室之类的场景很多人会拿它来用。代码的核心是一个indexOf方法在ByteBuf里查找\n的位置。它有一个maxLength参数如果一行数据超过了这个长度还没有遇到\n会抛出TooLongFrameException并且跳过这行数据继续解析。需要关注的是处理\r\n时它会把结尾的\r也一起去掉。因为协议里Windows换行一般是\r\nLinux是\n如果只是按\n切Windows客户端的消息末尾会多出一个\r这就会导致后续处理的时候数据对不上。我当年第一次用LineBasedFrameDecoder的时候就踩过这个坑客户端是Windows服务端解析出来消息末尾多了个\r排查了好久才发现是换行符的问题。它的半包处理逻辑也很典型如果没有读到\n它会返回null保留所有数据不动等下一个数据包到达后继续累积查找。整个实现只有几十行非常适合作为源码阅读的第一站。3.2 DelimiterBasedFrameDecoder自定义分隔符的灵活方案如果你的协议不是简单按行切分而是自定义了分隔符比如\t或者自定义的字符串那就用DelimiterBasedFrameDecoder。它的构造函数需要传入一个ByteBuf数组delimiters这个数组理论上是多个分隔符但实际上超过一个分隔符时它会自动退化成LineBasedFrameDecoder的逻辑所以生产中我基本只用单个分隔符的场景。这里有个坑需要注意DelimiterBasedFrameDecoder不会自动处理分隔符前面的\r如果你用\r\n作为分隔符记得要手动处理。另外分隔符匹配是个比较朴素的逐字节比较过程它会把所有的分隔符都匹配一遍找最早出现的那个。如果分隔符本身比较长且报文里频繁出现相似内容性能会有一定损耗。3.3 FixedLengthFrameDecoder最极端的定长方案FixedLengthFrameDecoder最简单也最省事。构造时传入一个frameLength它每次从累积器中读取固定长度的字节当成一条消息。如果累积器中的数据不足frameLength就返回null等下个包。这种定长协议的实现成本最低但灵活性最差。它适合那种所有报文定长且固定长度的场景比如某个老式的门禁协议或GPS定位协议每条上报都是固定多少个字节。这里有一个性能优势值得一提FixedLengthFrameDecoder重写了decode方法直接用readSlice返回一个切片不需要拷贝数据。因为ByteBuf的slice操作只是创建一个共享内存的视图开销极小。对比DelimiterBasedFrameDecoder可能需要拆分和拷贝定长解码器在极端场景下性能是最好的。3.4 LengthFieldBasedFrameDecoder最强大也最复杂的长度字段方案LengthFieldBasedFrameDecoder是生产环境中用得最多、也最需要理解清楚的解码器。凡是二进制协议几乎都会用四种参数来定义消息格式lengthFieldOffset、lengthFieldLength、lengthAdjustment、initialBytesToStrip。我先说结论只要协议里有一个字段表示长度其他字段跟着长度走都能用这个解码器搞定。但它的参数组合确实容易把人绕晕我逐个解释。lengthFieldOffset长度字段在消息中的起始位置从消息头开始算起单位是字节。lengthFieldLength长度字段自身占用的字节数一般是1、2、4或8。lengthAdjustment长度字段的值是否有偏移量需要修正。这个值的意思是实际消息正文的长度 长度字段值 lengthAdjustment。initialBytesToStrip解码后从消息头部去掉的字节数通常是去掉长度字段本身。举个最经典的实际例子。假设协议格式是消息头header2字节 长度字段len4字节 消息体bodyN字节长度字段len的值表示从它自己之后到消息结束的字节数也就是N。那么lengthFieldOffset 2因为长度字段前有2字节的消息头lengthFieldLength 4lengthAdjustment 0len就是body长度不需要调整initialBytesToStrip 6解码后整个消息头都去掉只留body也就是去掉header len另一种常见场景长度字段的值包含自身长度。还是上面的协议但len表示“从消息头开始到消息结尾的总长度”即len 2 4 N。这时lengthAdjustment -6也就是6 lengthAdjustment 0调整后实际长度就是N。我给大家一个通用的推导公式正文起始位置 lengthFieldOffset lengthFieldLength正文长度 长度字段值 lengthAdjustment - lengthFieldLength。当你发现参数配出来读到的消息长度不对就用这两个公式去验算。讲一下它内部的实现细节。这个解码器在decode时会分两步走首先从累积的ByteBuf中读取长度字段的值根据这个值算出整帧的总长度然后检查累积器中的数据是否已经够一整个帧了。如果不够返回null继续等如果够了就通过retainedSlice截取核心数据段并交给业务层。很多人在使用LengthFieldBasedFrameDecoder时最容易忽视的参数是maxFrameLength。它用来做安全防护当解析出来的帧长度超过设定值直接抛异常防止一个异常的长度字段导致内存被撑爆。这个参数必须配否则线上可能会因为一个坏包导致内存溢出。我的建议是maxFrameLength 业务允许的最大消息长度 lengthFieldOffset lengthFieldLength lengthAdjustment再留出一点裕量。比如业务说最大body不超过1MB协议头是10字节就设成1MB 10 一个合理的余量。还有一个高级用法很多人不知道超过maxFrameLength的帧被丢弃后后续的数据会不会乱掉源码里会调用discardingTooLongFrame方法持续丢弃数据直到把这一帧剩余的数据全部丢掉再从下一帧开始重新解析。这个丢弃状态用discardingBytes和tooLongFrameLength两个字段记录整个恢复机制写得非常扎实建议细读。4. 粘包拆包的常见问题与排查技巧实录4.1 解码后业务Handler收到空消息或数据错乱我在社区里看到很多新手问为什么我的业务Handler收到的消息是空的或者收到的数据总是错位先说空消息。这种情况八成是用了不当的initialBytesToStrip把整个消息头都剥掉了导致业务Handler拿到的ByteBuf可读字节数为0。Netty的解码器会在fireChannelRead之前检查out中是否为有效数据但如果你把内容全剥掉了它会继续把这个空消息往下传然后下一个Handler就会摸不着头脑。解决方案是检查initialBytesToStrip的值确保剥掉的只是消息头部分而不是把整条数据都剥没了。再说数据错位。最常见的原因是LengthFieldBasedFrameDecoder的参数没配对。比如长度字段的位置算错了或者lengthAdjustment的正负搞反了导致截取的帧头、帧尾都不对。这时候最好的排查方式是写一个独立的测试用例模拟几条已知的报文先用ByteBuf手工拼出原始数据再通过解码器解析打印解析结果和期望值对比。我几乎每次遇到参数问题都是这么查出来的比直接翻代码猜快得多。4.2 多个解码器叠加时的顺序陷阱Netty的Pipeline可以挂多个解码器但挂错顺序会得到完全不同的结果。比如你有两个解码器一个负责解长度字段一个负责解加密数据。如果先挂LengthFieldBasedFrameDecoder再挂解密Handler那么LengthFieldBasedFrameDecoder读到的是密文长度字段有可能被加密搅乱解析出来的长度直接是错的。正确顺序一定是从“协议层”到“业务层”逐级展开先做粘包拆包恢复边界再做解密去掉加密层再做反序列化变成业务对象。Pipeline的添加顺序就是数据处理顺序这一点特别容易被忽略。每次调整Handler顺序之后记得用netty自带的LoggingHandler观察每个阶段的数据流转确认边界是否合规。另外一个和顺序有关的坑是不要再业务Handler的channelRead里直接修改ByteBuf的readerIndex。解码器已经把readerIndex推进到当前帧的末尾业务层只需要读取完整帧即可。如果业务层强行修改readerIndex会导致整个帧的读取错乱甚至影响后续数据包的解析。如果你真的需要在业务层再拆一层建议再写一个解码器而不是在业务Handler里操作ByteBuf索引。4.3 半包导致的OOM问题做长连接服务时如果客户端发送的数据量很大但是频率不高半包累积器会一直保存不完整的数据。默认的MERGE_CUMULATOR是动态扩容的如果协议设计上存在漏洞比如一个恶意客户端故意发送“只包含长度字段声称很大但永远不发完”的报文累积器就会无限增大最终拖垮服务端内存。针对这个问题Netty提供了最大累积上限的约束吗其实并没有ByteToMessageDecoder本身没有内置一个绝对上限的开关。所以做服务端的同学要自己把控风险。我的常规做法是在服务端入口handler前加一个简单的流量限制ChannelInboundHandler如果累积器中的可读字节数超过某个阈值比如业务最大消息的3倍直接强制关闭连接。还有一个办法是在LengthFieldBasedFrameDecoder中使用maxFrameLength参数从长度字段层面就拒绝超大帧它能提前发现并丢弃。但从安全角度两者最好都做一个是微观的长度限制一个是宏观的内存保护。这个OOM问题我没少在压测中碰到尤其是一些从C转过来的团队习惯把数据积压在内存里却忽略了Netty本身提供的内存回收机制。记住一点做完解码之后原ByteBuf的引用计数要平衡。解码器内部会用release来释放原始的ByteBuf但你业务Handler如果自己retain了一份用完必须release否则就会内存泄漏。定位这种问题最直接的方式是开启Netty的泄漏检测级别在启动参数里加上-Dio.netty.leakDetection.levelPARANOID压测一段时候后看日志有没有LEAK提示。4.4 面试高频追问JDK的ByteBuffer和Netty的ByteBuf有什么区别写Netty源码分析的文章不聊聊这个就太可惜了因为面试必问而且能看出来一个人是不是真的理解Netty的底层设计。JDK的ByteBuffer是一个单索引结构只有position一个指针读和写共用一个位置。你写完数据要手动flip才能读读完要compact或者clear才能继续写。这个设计不是不能工作但在实际网络编程中非常难用因为每次readChannel、writeChannel的数据组装都是一堆location和flip的排列组合代码写起来像在解魔方。Netty的ByteBuf引入了readerIndex和writerIndex两个指针读写分离不需要flip。而且它天然支持引用计数ReferenceCounted能够自动探测内存泄漏。更关键的是ByteBuf支持池化Netty在高并发场景下大量使用PooledByteBuf来减少GC压力。你可以想一下每秒百万级消息的话如果每个消息都从JVM堆里new一个byte数组再GC掉那整个服务基本都在“暂停-恢复-暂停-恢复”中度过了。从源码分析的角度我建议重点了解一下PooledByteBuf的分配和释放流程。它是在PoolArena中通过不同的缓存池进行分配有tiny、small、normal、huge四级分类释放时又回到缓存池。这套内存管理机制对于追求极致性能的系统非常关键。如果你用Netty做的是IO密集型服务这块理解不透彻写出来的代码离最佳实践还是有很大距离的。面试的时候把这套机制讲出来面试官通常都会认可你有深度的。4.5 我的一个调试技巧利用LoggingHandler观察数据帧很多人调试粘包拆包问题的时候喜欢在业务Handler里打日志但是业务Handler里的数据已经是解码之后的结果根本看不到原始字节流。我推荐一个更有效的办法在解码器之前挂一个LoggingHandler它不是用来打业务日志的而是用来打印原始进出的ByteBuf内容直观看到粘包现象。这个LoggingHandler默认的日志级别是DEBUG你可以通过构造参数指定为INFO或者自定义。如果我怀疑某个客户端发来的报文格式不规范我会把LoggingHandler放在Pipeline的最前端然后在日志里找到这一帧的HexDump自己手工解析一遍跟解码器解析出来的消息做对比。几乎所有的协议解析问题用HexDump一看就原形毕露。我还习惯在测试环境故意制造粘包和拆包客户端发送一条大消息超过MSS或者关闭Nagle算法后高频发多个小包然后在服务端观察解码器行为。这种“主动找麻烦”的测试往往能暴露出平时测不出来的协议边界问题。别等线上用户帮你踩坑那代价就太大了。写在最后的一点心得体会这套“认真系列”的第一篇我讲了Netty的整体架构和EventLoop这一篇把解码器和粘包拆包这块最核心的内容展开了。很多朋友问我源码到底怎么读才有效果我的建议是不要通读整个Netty源码那不是普通人能短期完成的。我更推荐“带着问题去读”线上出了粘包问题就去读ByteToMessageDecoder想知道为什么内存泄漏就去读ByteBuf的引用计数实现面试被问忘了就去读LengthFieldBasedFrameDecoder的源码并跑通几个测试样例。源码分析这件事难的不是读代码而是把代码的执行链路和实际应用场景对上。Netty的代码注释非常详尽边读边想“这段逻辑在什么情况下会触发”比单纯背源码有效得多。最后分享一个小习惯我每次分析完一个Netty组件都会在文末画一张简短的调用时序图存在自己的笔记里关键参数和坑点也会单独标注。下次再遇到类似问题翻笔记比重新翻源码快得多。
返回列表