尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Netty粘包拆包源码解析:ByteToMessageDecoder与LengthFieldBasedFrameDecoder深度剖析

Netty粘包拆包源码解析:ByteToMessageDecoder与LengthFieldBasedFrameDecoder深度剖析 Netty源码分析写了好几篇了后台不断有朋友催更“认真系列”第二篇。上一篇我们把 Netty 的整体脉络、NioEventLoop 线程模型和启动流程啃了一遍这次我想换个角度挑一个实际工作中几乎每天都会碰到、面试也高频被问的方向来拆——就是粘包拆包处理也就是 Decoder 这一整条链路。为什么选这个因为说实话Netty 里最容易被误用、也最容易踩坑的恰恰是解码器。很多人只知道“在 Pipeline 里加个 LineBasedFrameDecoder 或者 LengthFieldBasedFrameDecoder 就能拆包”但要是换个私有协议、或者线上突然出现半包错乱就开始懵了。这背后其实是一个非常有代表性的源码命题Netty 是怎么用一套统一的机制把“字节流的读取”“半包暂存”“整包切分”“内存释放”全都优雅地串起来的。这篇文章我会直接深入到ByteToMessageDecoder的源码把坑点一个个挖出来讲然后会分析几个最常用的拆包器尤其是LengthFieldBasedFrameDecoder的私藏细节。篇幅会比较长但我保证每一段都值得你看能帮你彻底搞懂 Netty 拆包的底层逻辑顺便把面试里可能连环追问的底层点都打通。1. 为什么非要把拆包器源码读懂1.1 粘包拆包到底是什么先把它说透TCP 是流式协议它不关心你的应用层报文边界。就好比你把三封信扔进一个快递管道管道那头收到的可能是一整坨烂纸也可能是半封信剩下半封等下一趟才到。这在网络编程里就是经典的两个问题粘包和半包。粘包多个完整报文被合并发送接收方一次读到两三个报文的数据。半包一个报文被拆成了多个 TCP 段接收方某一次只读到其中一部分。其实从 TCP 的角度看这根本不是“问题”它本来就是干这个的。问题出在我们应用层需要从连续的字节流里重新找出清晰的“消息边界”。Netty 的 Decoder 就是干这个活的。很多人觉得粘包是 Nginx、网关、RPC 框架才会遇到的问题但其实只要你自己写 NIO 程序、用 Netty 做通讯比如写个游戏服务器、物联网设备接入服务、IM 消息推送只要传的是字节流就必须处理这个问题。理解 Netty 的拆包器是怎么设计的比死记硬背几个参数要有用得的多。你只有明白ByteToMessageDecoder内部是怎么“攒数据、识别边界、切分帧”的才真正能在复杂场景下把拆包逻辑写得心里有底。1.2 源码阅读的正确入口ByteToMessageDecoder进入源码阅读的第一步不是到处找具体某个 Decoder 的实现而是先看它们的公共父类ByteToMessageDecoder。这个类相当于整个拆包机制的“发动机”。它屏蔽掉了累积缓冲区的维护、解码循环的控制、消息向下游的转发这些繁琐逻辑把最关键的算法步骤留给了子类去实现。子类只需要重写一个decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out)方法从in里读字节如果能拼出一帧完整的消息就out.add()加进去读不完整就什么都不用做。从使用者的视角看责任链的传递非常直观Pipeline 上的顺序 入站消息 - ByteToMessageDecoder.decode 切出若干完整帧 - 每个帧作为一个独立 message 继续向后传递 - 下一个业务 Handler 收到一个完整的“对象/消息”这个设计其实是典型的“模板方法模式”。父类负责流程控制子类负责具体算法。所以我们读源码的顺序应该是先把ByteToMessageDecoder吃透再去看各种FrameDecoder怎么实现decode。一旦把这个膨胀点看懂了后面所有解码器都是弟弟。1.3 设计上的取舍为什么用责任链而不是直接在 IO 层处理你可能会问为什么不直接在NioEventLoop读数据的时候就把包拆好之所以要引入 Pipeline 和责任链核心原因是职责解耦。Netty 的核心哲学是每个组件只干一件事。IO 线程只负责把字节从 socket 读到ByteBuf至于这些字节怎么切分、怎么反序列化那应该由业务侧通过 Pipeline 灵活组装。同样是拆包有的协议用分号分隔有的协议固定多少字节有的协议带长度头甚至同一个项目里还可能并存多种协议。如果把这些写死在 IO 层框架就死掉了。另外责任链还带来一个额外的好处可以在任意位置插入关注点组件比如日志、加密解密、流量统计。只要实现了对应的ChannelHandler编解码逻辑可以完全解耦。这套设计非常符合“开闭原则”——对扩展开放对修改关闭。2. ByteToMessageDecoder整个拆包机制的发动机2.1 累积缓冲区 cumulation 的设计有没有更好的方案先说最核心的成员变量cumulation。ByteBuf cumulation;它的职责很明确把每次从网络读到的数据暂存起来直到可以解析出至少一个完整的帧。这个设计是解决半包问题的关键。你可以把它理解成一个“待解析的缓冲池”。每次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; } else if (numReads discardAfterReads) { numReads 0; discardSomeReadBytes(); } // 循环向下游传递解析出的完整帧 fireChannelRead(ctx, out, size); out.recycle(); } } else { ctx.fireChannelRead(msg); } }注意这里的first判断如果cumulation null说明当前还没有暂存数据就直接把新读到的data交给cumulation持有如果已经有暂存数据了就需要把新旧数据合并。合并的工作由cumulator完成。Netty 提供了两种实现实现合并策略适用场景MERGE_CUMULATOR扩容现有缓冲区把旧数据和新数据复制到一起默认方案性能均衡实现简单COMPOSITE_CUMULATOR用CompositeByteBuf组合新旧缓冲区避免复制但组件更多索引处理更绕MERGE_CUMULATOR展开看其实一点都不神秘核心就是两步if (cumulation.writerIndex() cumulation.maxCapacity() - data.readableBytes() - 16) { // 扩容 ByteBuf newCumulation alloc.buffer(compositeCapacity, cumulation.maxCapacity()); newCumulation.writeBytes(cumulation); cumulation.release(); cumulation newCumulation; } ByteBuf byteBuf cumulation.writeBytes(data); data.release(); return byteBuf;它就是在空间不够的时候创建一个更大的ByteBuf把旧数据搬进去然后接着把新数据写进去。所谓“累积缓冲区”其实就是这么简单的一个大ByteBuf只不过 Netty 帮我们把扩容和释放都自动做了。按照我这几年的使用经验除非你明确对 GC 和拷贝特别敏感否则默认的MERGE_CUMULATOR就够了。COMPOSITE_CUMULATOR的实现在某些特定版本里还有一些隐藏的坑比如maxCapacity的计算、读写索引的统一管理都更复杂收益却有限不建议随便切换。提示cumulation.release()一定不能忘。如果忘了释放旧的cumulation等到缓冲区反复扩容的时候就会产生严重的内存泄漏。好在 Netty 的这些逻辑封装得比较完整源码里基本都处理好了但阅读时要注意体会这种“谁申请谁释放”的纪律。2.2 callDecode 循环逻辑为什么这么写callDecode是整个拆包逻辑最核心的循环。源码如下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( StringUtil.simpleClassName(getClass()) .decode() did not read anything but decoded a message.); } if (isSingleDecode()) { break; } } } catch (DecoderException e) { throw e; } catch (Exception cause) { throw new DecoderException(cause); } }这个循环有几处极其关键的逻辑我逐个拆解。第一每次循环开始都要检查outSize如果已经解析出了帧马上通过fireChannelRead向下游传递然后清空out。这里有个深层考虑避免out列表无限增长同时让解码结果尽快进入后面的业务 handler。如果解码器和业务 handler 之间还有别的入站处理器它们可以立刻感知到消息。第二int oldInputLength in.readableBytes()这一行非常关键。它记录了解码前的可读字节数之后调用decodeRemovalReentryProtection这个方法实际上会去调用子类重写的decode。第三outSize out.size()且oldInputLength in.readableBytes()的时候直接break。这段逻辑是防半包的灵魂。当子类decode发现累积的数据不够组成一帧完整的消息时它不会消费任何字节也不会往out里添加消息。此时输入可读字节数没变out里也没多东西说明数据确实不够继续等下一次channelRead才是正确的做法。如果outSize out.size()但oldInputLength ! in.readableBytes()说明子类消费了一部分输入却没产出完整消息这种情况往往发生在“跳过垃圾数据”或者“丢弃超长帧”的场景。循环继续让子类有机会继续处理剩余的数据。第四oldInputLength in.readableBytes()但out里加了消息的时候直接抛异常。这是个保护性逻辑防止子类在没读输入的情况下凭空产生消息导致死循环或数据错乱。这个循环写得很细腻面试的时候如果能把oldInputLength的设计意图讲明白基本上就能证明你真读过源码而不只是背概念。2.3 两个容易忽略的收尾细节内存释放与读索引偏移回到channelRead的finally块。很多人读源码时关注点全在前面忘记看这个收尾逻辑但这里其实藏着两个非常重要的技巧。if (cumulation ! null !cumulation.isReadable()) { numReads 0; cumulation.release(); cumulation null; } else if (numReads discardAfterReads) { numReads 0; discardSomeReadBytes(); }第一个分支累积缓冲区已经不可读了也就是里面的字节被全部消费完了直接释放整个缓冲区并置空。这是最好的情况下一轮数据来了直接新分配ByteBuf没有历史包袱。第二个分支缓冲区里还有没消费完的数据说明出现了半包。这时候如果每次读完都立即丢弃已读部分会频繁触发discardReadBytes的内存搬移白白浪费 CPU。所以 Netty 引入了一个计数器numReads每进入一次channelRead就加一累计到discardAfterReads默认值是 16才做一次清理。discardSomeReadBytes内部其实就是调用discardReadBytes把已读部分的内存交还给分配器同时把读索引归零。这种“攒一波再清理”的思路在 Netty 里到处都是属于典型的性能优化手段阅读时要留意。接着看fireChannelRead的实现。它在ByteToMessageDecoder里是静态方法static void fireChannelRead(ChannelHandlerContext ctx, CodecOutputList out, int size) { for (int i 0; i size; i) { ctx.fireChannelRead(out.getUnsafe(i)); } }循环把out里的每个解析结果作为一个独立的入站消息沿 Pipeline 向后传递。这里有一点需要特别注意out列表里的消息并没有被释放因为下游业务 Handler 可能还需要使用。最终如果没人消费Pipeline 末尾的TailContext会负责兜底释放引用计数。这也是为什么解码器的产出不需要手动 release而解码器自己临时创建的 ByteBuf 需要小心的原因。CodecOutputList是另一个优化点。它复用了ArrayList的语义但内部用类似Object[]的方式管理元素并且有getUnsafe这种绕过ArrayList安全检查的方法目的就是减少 IO 线程上的 GC 压力。等你真正在项目的核心链路上做 IO 优化时会体会到这种抠细节的价值。3. 按行拆包与分隔符拆包从最简单入手看编码套路3.1 LineBasedFrameDecoder 源码走读读行协议就这么简单LineBasedFrameDecoder是最常用的拆包器之一。它的典型应用是解析按行分隔的文本协议比如 Redis 的 RESP 协议、SMTP、FTP 等。它的核心就是一个“找换行符”的算法。protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception { final int eol findEndOfLine(buffer); if (!discarding) { if (eol 0) { final ByteBuf frame; final int length eol - buffer.readerIndex(); final int delimLength buffer.getByte(eol) \r ? 2 : 1; if (length maxLength) { buffer.readerIndex(eol delimLength); fail(ctx, length); return null; } if (stripDelimiter) { frame buffer.readRetainedSlice(length); buffer.skipBytes(delimLength); } else { frame buffer.readRetainedSlice(length delimLength); } return frame; } else { final int length buffer.readableBytes(); if (length maxLength) { discarding true; discardedBytes length; buffer.skipBytes(length); return null; } return null; } } else { // 丢弃超长行剩余部分 final int readableBytes buffer.readableBytes(); if (readableBytes 0) { discardedBytes readableBytes; buffer.skipBytes(readableBytes); } failIfNecessary(ctx); return null; } }findEndOfLine是核心查找逻辑它逐字节找\n返回索引位置。如果找到了就以换行符为界切出当前这一行数据。这里有一个值得学习的细节readRetainedSlice。这个方法不会复制数据而是创建一个共享底层内存的切片同时将引用计数加一。这样解析出来的 Frame 和累积缓冲区共享同一块内存避免了不必要的拷贝。这是 Netty 4.1 优化后的结果如果你还在用很老的版本可能看到的是readSlice效果一样但不持引用。还有一个隐藏的逻辑不允许超长行。一旦可读字节超过maxLength解码器会马上进入discarding模式把当前这一行剩余数据统统跳过。这是防止恶意或异常数据把内存打爆的兜底策略。很多人在用的时候不设maxLength默认是 1024这个数值在一些慢日志场景下可能不够自行评估后设置。如果你在用这个解码器处理自定义文本协议要注意设置stripDelimiter。很多协议要求接收方拿到的是不带换行符的干净数据那你可以设置为 true如果协议本身把分隔符作为数据的一部分就保持 false。这个字段不是性能问题但一不小心就把业务逻辑搞偏了。3.2 DelimiterBasedFrameDecoder 的多分隔符裁剪DelimiterBasedFrameDecoder从功能上讲是LineBasedFrameDecoder的通用版。它支持任意分隔符而且支持多个分隔符同时匹配。比如你可以同时指定\r\n和\n作为分隔符。它的构造函数接受一个ByteBuf... delimiters数组。源码在匹配时会遍历所有分隔符找到“最早出现且最短”的那一个private int indexOf(ByteBuf haystack, ByteBuf needle) { for (int i haystack.readerIndex(); i haystack.writerIndex(); i) { int haystackIndex i; int needleIndex; for (needleIndex 0; needleIndex needle.readableBytes(); needleIndex) { if (haystack.getByte(haystackIndex) ! needle.getByte(needleIndex)) { break; } else { haystackIndex; if (haystackIndex haystack.writerIndex() needleIndex ! needle.readableBytes() - 1) { return -1; } } } if (needleIndex needle.readableBytes()) { return i; } } return -1; }这个匹配逻辑写得比较朴素就是逐个字节比对。它找的是“最早出现”的分隔符处理方式上按最短匹配来切分。源码里注释也明确说了如果同时给\r\n和\n它会优先匹配更短的\n这样可能切出来的数据跟你预期不同。举一个实际例子。数据是abc\r\n如果同时指定了\r\n和\n作为分隔符实际匹配到的是\n的位置前面的\r会被算进数据里。所以你配置多分隔符的时候要非常小心除非你很确定协议不会冲突否则推荐只用一种分隔符。另外DelimiterBasedFrameDecoder的内部实现里当分隔符不可见字符较多比如自定义二进制分隔符 0x01 0x02时它的匹配效率并不高。如果协议非常追求性能还是建议用LengthFieldBasedFrameDecoder长度头通常比内容探测效率高。3.3 从这两兄弟身上学到的通用套路读完这两个解码器其实可以抽象出一个通用套路解码器通过查找内容中的“边界标记”从累积缓冲区的读索引开始扫描找到边界之后把[读索引, 边界索引)这段数据切出来作为一帧。这个过程中最难的是如何优雅地处理“找不到边界”的情况。上面的源码里处理方式就是直接返回null不消费任何字节。这完全符合ByteToMessageDecoder的约定消费的字节数可以少但不能凭空让out.size()增长。还有一个非常重要的编码纪律子类decode方法中如果你想丢弃数据一定要buffer.skipBytes()或者修改readerIndex不要只是“假装没看到”。因为你一旦跳过了一些字节但没产出消息调用callDecode的时候oldInputLength会变化从而驱动循环继续执行让子类有机会处理后续内容。如果只是返回null又不消费循环会立即退出那些多余的数据就会一直留在累积缓冲区里越积越多直到内存溢出。4. LengthFieldBasedFrameDecoder最高频、最值得啃的一块4.1 参数含义和典型配置回看LengthFieldBasedFrameDecoder是 RPC、私有协议中应用最广泛的解码器因为它的逻辑最通用在报文头部固定位置放置长度字段解码时读长度、再根据长度切帧。它有一串参数理解这些参数是读懂源码的前提。官方注释里给出了 4 个经典例子我这里就不完整抄了只整理一份对照表方便读者快速切入。参数含义典型值maxFrameLength单帧最大长度根据协议估算lengthFieldOffset长度字段起始位置的偏移量如果长度字段就在报文最开头就是 0lengthFieldLength长度字段自己占用的字节数1、2、3、4、8lengthAdjustment长度字段的值是否需要加上一个修正值常见场景为- lengthFieldOffset - lengthFieldLength或 0initialBytesToStrip解析出整帧后去掉前面几个字节再往下传长度字段不参与业务意义时设 4最常见的配置例子是一个报文由4字节长度头 业务数据组成长度头的值表示后面业务数据的长度。那么配置就是new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)这里lengthFieldOffset 0表示长度字段在开头lengthFieldLength 4表示长度字段占 4 字节lengthAdjustment 0表示长度值不需要额外偏移initialBytesToStrip 4表示把长度头剥掉后再往下传。如果报文格式是magic(2字节) version(1字节) length(4字节) payload而且 length 只表示 payload 的长度那么配置应该是new LengthFieldBasedFrameDecoder(65535, 3, 4, 0, 7)这里lengthFieldOffset 3是因为前三个字节是 magic 和 versionlengthAdjustment 0initialBytesToStrip 7是把整个头部都剥掉。还有一种情况是 length 字段包含头部自身比如length 头部长度 正文长度那lengthAdjustment就需要设为负数来修正。这些参数组合起来非常灵活但正因为灵活也成了大量线上事故的高发区。我建议在接入新协议时先用真实的报文抓包数据对着配置验证一遍跑一两个完整的收发用例再上线。4.2 源码中如何读数帧长度与跳过字节我们直接看decode方法的核心实现。省略掉版本差异我挑关键部分讲。protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception { if (in.readableBytes() lengthFieldEndOffset) { return null; } int actualLengthFieldOffset in.readerIndex() lengthFieldOffset; long frameLength getUnadjustedFrameLength(in, actualLengthFieldOffset, lengthFieldLength, byteOrder); if (frameLength 0) { failOnNegativeLengthField(in, frameLength, lengthFieldEndOffset); } frameLength lengthAdjustment; if (frameLength 0) { throw new CorruptedFrameException(negative pre-adjustment length field: frameLength); } if (frameLength maxFrameLength) { // 丢弃超长帧 } int frameLengthInt (int) frameLength; if (in.readableBytes() frameLengthInt) { return null; } if (initialBytesToStrip frameLengthInt) { throw new CorruptedFrameException(Adjusted frame length ( frameLength ) is less than initialBytesToStrip: initialBytesToStrip); } in.skipBytes(initialBytesToStrip); int readerIndex in.readerIndex(); int actualFrameLength frameLengthInt - initialBytesToStrip; ByteBuf frame extractFrame(in, readerIndex, actualFrameLength); in.readerIndex(readerIndex actualFrameLength); return frame; }这段代码的思路非常清晰我建议你跟着执行一遍逻辑第一步检查累积缓冲区里是否已经有lengthFieldEndOffset字节。lengthFieldEndOffset lengthFieldOffset lengthFieldLength也就是最少要够读到一个完整的长度字段。如果不够直接返回null等待更多数据。第二步调用getUnadjustedFrameLength读取长度字段。这个方法会根据lengthFieldLength分别是 1、2、3、4、8 字节来做不同的位运算。比如 4 字节的处理就是if (lengthFieldLength 4) { return buf.getUnsignedInt(index); }值得留意的是 3 字节长度字段也能处理。它把三个字节按大端拼起来case 3: return (buf.getByte(index) 0xFF) 16 | (buf.getByte(index 1) 0xFF) 8 | (buf.getByte(index 2) 0xFF);这就允许了比较特殊的协议头设计。从这些细节可以看出 Netty 对协议兼容性考虑得有多细。第三步加上lengthAdjustment修正得到最终frameLength。这时候如果发现帧长度大于maxFrameLength就会走failOnFrameLengthTooLong分支根据failFast参数决定是立即抛异常丢弃还是先把整帧数据耗尽再抛。这其实是一种防御策略立即抛异常有可能导致数据流中断无法恢复而先跳完整个超长帧再抛异常可以尽量让后续消息保持同步。第四步判断累积缓冲区里是否已经有完整的frameLengthInt字节。如果不够返回null等待更多数据这就是经典的“半包处理”。注意这里用的是in.readableBytes() frameLengthInt也就是说它要求缓冲区里包含“长度字段 修正后的整帧数据”而不是只看长度字段本身。第五步执行skipBytes(initialBytesToStrip)去掉头部然后extractFrame从当前读索引开始切出真正要传给业务层的数据。4.3 四个判空与越界处理这才是源码的精华这个解码器里最值得细品的不是正向流程而是几处“判空和越界”的防御性逻辑。我在读的时候曾经忽略掉它们直到线上踩了坑才回头重新研究。第一个是帧长度字段值本身为负数。源码会调用failOnNegativeLengthField直接抛CorruptedFrameException。这种异常通常意味着对方发送的报文头已经错乱或者是通信双方对协议的理解不一致。第二个是加上lengthAdjustment之后得到的值还是负数。这说明原始长度字段的值加修正值后小于零数据链路肯定出了问题。第三个是frameLength maxFrameLength。这一条特别关键因为网络数据是不可信的。如果解码器不做任何限制一个恶意客户端可以发送一个长度为0xFFFFFFFF的长度字段然后代码就会一直等待“那么多字节”内存被无限消耗。设置maxFrameLength是保障服务可用性的底线之一。第四个是initialBytesToStrip frameLengthInt。如果你配置的剥离字节数比整帧还大那剥完之后就没东西可用了。这通常是配置错误需要尽早暴露出来而不是静默返回空数据。把这些防御逻辑梳理完你会发现一个生产级解码器不只需要“把正确的数据解出来”更要把异常数据挡在外面、暴露配置错误。这才是源码里最有价值的思路。4.4 粘包/半包状态下的完整处理路径为了帮你把这一节的逻辑拼起来我用一个具体例子走一遍。假设协议是4字节长度头 正文服务端收到了 TCP 流的以下字节第一次 channelRead [0 0 0 10 | A B C D E] 第二次 channelRead [F G H I J]第一次channelReadcumulation为空直接把数据放入。callDecode读到 4 字节长度字段值为 10但当前可读字节是 459小于 10414于是decode返回null。此时字节没被消费等待下一轮。第二次channelRead新数据到达cumulator将旧数据和新数据合并cumulation里变成完整 14 字节。callDecode再次进入循环这次readableBytes() 14成功切出 10 字节的正文字段out.add(frame)。循环继续此时in里可能还有剩余字节Netty 会继续尝试解析下一帧。假设第二次实际收到的数据是F G H I J K L M N O P Q多出两个字节属于第二帧那么切完第一帧后in里还剩P Q。循环看到oldInputLength从 14 变成 2且out已经被消费于是继续调用decode。此时可读字节不够一个长度头返回null循环退出。剩余P Q继续留在cumulation中等待第三帧剩余的数据。这个例子把粘包、半包的处理路径全部串起来了。你会发现ByteToMessageDecoder的循环和LengthFieldBasedFrameDecoder的 null 返回配合得非常紧密一个负责“不断尝试”一个负责“不够就退”最终保证数据不会丢、也不会乱。5. 解码结果如何传播与内存管理5.1 fireChannelRead 与 out 列表的流转现在再回过头看ByteToMessageDecoder.channelRead末尾的finally块里面有一行容易被忽略的代码int size out.size(); decodeWasNull !out.insertSinceRecycled(); fireChannelRead(ctx, out, size); out.recycle();fireChannelRead是静态方法它会把out里收集到的所有解析结果逐个向下游传递。但注意这里使用的是out.getUnsafe(i)而不是out.get(i)。原因上面提过CodecOutputList的内部实现为了让 IO 线程尽量少分配对象使用了一种更轻量的方式管理元素getUnsafe会绕开一些边界检查性能更高。out.recycle()则是把整个列表对象还回对象池。这保证了高频调用下不会产生大量临时的ArrayList实例对降低 GC 压力帮助很大。如果你在自己写的解码器里要用到类似“批量输出”的列表可以考虑借鉴这种池化设计。另一方面decodeWasNull这个字段会记录本次decode是否没有产出。Netty 4.1 引入这个字段是为了性能优化当decodeWasNull且当前 Channel 的配置允许时解码器可以跳过某些不必要的处理路径。5.2 什么时候需要释放什么时候绝对不能释放内存管理是 Netty 新手最容易犯迷糊的地方。我帮你梳理一套简单的判定方法。解码器内部会产生两种 ByteBuf累积缓冲区cumulation它是由解码器持有的当它不再被需要时不可读解码器会主动release。解码产物out里的消息这些消息由readRetainedSlice或readSlice等生成引用计数在生成时增加。解码器不会主动释放而是默认把所有权移交给下游。下游业务 Handler 如果不再需要就要调用ReferenceCountUtil.release(msg)或者msg.release()释放如果需要继续传递就原样转发。用一句口诀谁最后持有数据谁负责释放。解码器负责累积缓冲区的生命周期业务处理器负责解码结果的最终消费。实际工作中我见过不少内存泄漏都是因为业务 Handler 拿到了解码后的ByteBuf却忘记 release。比如做了异步处理把 ByteBuf 丢到线程池里等处理完也不释放最终内存溢出。这种问题用 Netty 的LeakDetector能查出来但别依赖工具兜底写代码时就要有清晰的“所有权”意识。注意当你实现了自定义ChannelInboundHandler并覆盖channelRead方法时如果决定不把消息继续下传一定要手动释放它否则泄漏就会落在你的代码里。最稳妥的写法是ReferenceCountUtil.release(msg)。5.3 MessageToMessageDecoder 与 ByteToMessageDecoder 的分工Netty 里还有一类解码器继承体系不一样但也非常常用那就是MessageToMessageDecoderT。它和ByteToMessageDecoder的分工很清晰ByteToMessageDecoder输入是ByteBuf输出是若干消息。它负责从字节流里切帧。MessageToMessageDecoderT输入已经是某个消息类型输出另一种消息类型。它负责把一种消息转换成另一种。典型的场景是先通过LengthFieldBasedFrameDecoder把字节流拆成一个个ByteBuf然后通过StringDecoder把ByteBuf转成String再交给 JSON 解码器转换成对象。public abstract class MessageToMessageDecoderI extends ChannelInboundHandlerAdapter { Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { CodecOutputList out CodecOutputList.newInstance(); try { if (acceptInboundMessage(msg)) { SuppressWarnings(unchecked) I cast (I) msg; try { decode(ctx, cast, out); } finally { ReferenceCountUtil.release(msg); } } else { out.add(msg); } } catch (DecoderException e) { throw e; } catch (Exception e) { throw new DecoderException(e); } finally { int size out.size(); fireChannelRead(ctx, out, size); out.recycle(); } } }仔细观察会发现MessageToMessageDecoder在把消息传给decode之后会在finally里主动释放原始消息。这意味着如果你实现了MessageToMessageDecoder你的decode方法里不需要关心输入消息的释放只管产出新的对象就行。这种设计保持了内存语义的一致性原始的 ByteBuf 在解码后使命结束由解码器负责释放产出的新对象继续向下传递由后续 handler 管理。理解了这两类解码器的区别你就不会在自定义解码器的时候用错父类了。写字节级拆包逻辑就继承ByteToMessageDecoder写对象到对象的转换就继承MessageToMessageDecoder。6. 实战排查与面试高频追问6.1 线上常见的粘包、半包、超大帧问题怎么排查这里整理几个我实际处理过的线上问题每个都是血泪教训。问题一客户端发送速度较快时服务端偶发出现消息粘连。排查思路先确认 TCP 层有没有粘包抓包看实际数据传输。如果确认粘包那大概率是解码器没配对。最简单的修复方式是给 Pipeline 加上按协议定制的拆包器。如果你用的是行协议用LineBasedFrameDecoder如果有长度头用LengthFieldBasedFrameDecoder。加了之后要重点验证半包场景确保数据被拆开传输时也能被正确组装。问题二服务端收到“半包”后消息一直不上送。排查思路不是真的卡住而是解码器一直在等剩余字节。检查LengthFieldBasedFrameDecoder的lengthFieldOffset和lengthFieldLength是否配置正确尤其注意长度字段的含义。我碰到过一种情况长度字段的值包含 head而业务方配置的lengthAdjustment算错了结果解码器一直认为帧没来齐导致消息积压。问题三恶意或异常客户端发送超大长度字段服务端内存暴涨。排查思路看是不是没设maxFrameLength或者设得太大。生产环境中这个值要结合协议定义来定不能拍脑袋。比如某私有协议最多 1MB 数据那你设 2MB 就足够了没必要给 100MB。设置过大会给攻击者可乘之机。问题四乱用LineBasedFrameDecoder处理二进制协议。排查思路看协议定义。二进制协议经常包含 0x0A 这种字节会被误认为换行符。如果误用LineBasedFrameDecoder数据会被切得乱七八糟。这种场景应该用固定长度或长度头方案。6.2 源码级面试题拆解这样答才能过既然题目挂在热词里还提到面试题我就把常见追问和答题要点整理一下。问Netty 如何解决粘包拆包问题回答要点先说清楚粘包拆包的本质是 TCP 流式传输没有消息边界。然后说 Netty 通过 Pipeline 里挂载解码器解决解码器基类是ByteToMessageDecoder它内部维护累积缓冲区cumulation在channelRead中把新数据合并进累积区然后通过callDecode循环调用子类的decode方法直到无法解析出完整消息为止。再具体说你可以用按行、分隔符、长度头三种思路来拆包。问半包状态下ByteToMessageDecoder是怎么保存未处理完的数据的回答要点核心是累积缓冲区cumulation。当decode发现数据不足时字节不会被消费剩余数据留在cumulation中。下次channelRead到达时Cumulator会把新旧数据合并然后继续尝试解析。通过MERGE_CUMULATOR或COMPOSITE_CUMULATOR两种策略控制是否复制内存。问LengthFieldBasedFrameDecoder的原理是什么回答要点从累积缓冲区中先确保能读到完整的长度字段然后读取长度字段值加上lengthAdjustment计算出整帧长度。判断是否超过maxFrameLength再判断累积区是否已经有这么多字节。满足条件后跳过initialBytesToStrip切出业务数据最后更新读索引把剩余数据留作下一次解析。问自定义一个解码器需要继承什么类注意什么回答要点如果是字节流切帧继承ByteToMessageDecoder在decode方法里判断输入够不够一帧不够就返回不消费任何字节如果是对象转对象继承MessageToMessageDecoder。注意不要在decode里改动in的读索引除非已经确定可以消费否则会让循环逻辑错乱。问Netty 解码过程中的内存释放是谁负责的回答要点cumulation由ByteToMessageDecoder负责释放解码产物会沿 Pipeline 传递由最终消费方负责释放。如果没人消费Pipeline 末尾的TailContext会兜底释放。业务 Handler 如果不再使用解码结果必须手动release否则内存泄漏。6.3 扩展建议自定义解码器应该怎么写如果你需要对接完全自定义的协议我建议按下面这个模板来写public class MyMessageDecoder extends ByteToMessageDecoder { private static final int HEAD_LENGTH 8; Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) throws Exception { // 1. 判断够不够读一个完整头 if (in.readableBytes() HEAD_LENGTH) { return; } // 2. 标记当前读位置便于后面恢复 in.markReaderIndex(); // 3. 读取协议头解析消息长度 byte magic in.readByte(); byte version in.readByte(); int msgLength in.readInt(); short checksum in.readShort(); // 4. 校验合法性 if (magic ! 0x5A || msgLength 0 || msgLength MAX_LENGTH) { throw new CorruptedFrameException(invalid protocol header); } // 5. 判断够不够读完整的一帧数据 if (in.readableBytes() msgLength) { // 不够恢复读位置等下一次数据 in.resetReaderIndex(); return; } // 6. 切帧并向下传递 ByteBuf frame in.readRetainedSlice(msgLength); out.add(frame); } }这个模板有下面几个关键点。一是“先标记再读取不够就重置”。使用markReaderIndex和resetReaderIndex是自定义解码器里最安全的处理方式能保证半包时不会误消费头部数据。二是“协议头校验”。magic 和 checksum 这种校验一定要做尤其是对公网服务不校验的话可能因为一个错包导致整个解码链路全乱。三是readRetainedSlice产出的帧由业务 Handler 负责释放。如果你的协议还需要继续向 Pipeline 传对象可以在这里立刻做转换但要注意对象的生命周期。四是异常处理。遇到协议错误要尽早抛CorruptedFrameException但要考虑连接是否需要关闭。如果错误不可恢复应该在业务 Handler 里关闭 Channel。写自定义解码器最常见的问题就是有人喜欢在读了一半发现数据不够后不重置读索引直接 return。这样第二轮数据进来时读索引已经跳到错误位置整个协议解析就全乱了。凡是这种“尝试性读取”一定要记住先markReaderIndex。另外还要提一句关于isSingleDecode()的用法。如果你的协议是一次连接只解释一条消息或者每条消息是独立的、不能连续解析的可以考虑让isSingleDecode()返回true。但这个用法非常少见通常用于有明确状态机的协议普通场景保持默认就行。最后再分享一个小技巧。解析完一帧之后如果发现累积缓冲区里还剩大量数据说明大概率是粘包了。你可以在解码器后面再挂一个统计 Handler记录每一帧之间累积缓冲区遗留的字节数用这个数据判断线上是否存在严重的粘包现象。这比我见过很多团队“瞎猜调参数”要靠谱得多。因为只有当你看到底层数据时才会真正理解协议设计和解码器参数为什么需要这样配置。这篇文章写到这里其实已经把 Netty 拆包链路的源码主脉络理清楚了。从ByteToMessageDecoder的累积缓冲区到callDecode的循环控制再到具体 FrameDecoder 的边界切分策略每一步都经得起推敲。我个人这两年读 Netty 源码的最大感受是源码不是用来背的是用来反复对照实际场景验证的。你只要带着“这个半包到底会走哪条分支”这种问题去读每读一遍都会有新的收获。如果后续时间允许我再把“认真系列”的下一个主题定为ChannelPipeline的事件传播机制那也是一块绕不开的硬骨头。
返回列表