Skip to content
Charles Shao
Go back

Netty 深挖 · Pipeline、编解码与粘包拆包

views

开篇讲了 EventLoop 怎么把字节从 socket 搬进来、上一篇讲了这些字节装在 ByteBuf 里——但字节流要变成业务能用的消息,靠的是 ChannelPipeline 上的编解码器。

这一篇钻两件事:pipeline 的事件怎么传播,以及怎么把没有边界的 TCP 字节流切成完整的协议帧。Pipeline 是双向责任链,inbound/outbound 各走各的方向;TCP 只保证字节顺序、不保证消息边界,所以必须在解码器里自己切帧。

TL;DR

Table of contents

Open Table of contents

1. ChannelPipeline:双向责任链

每个 Channel 有且仅有一条 ChannelPipeline,它是一条双向链表,每个节点是一个 ChannelHandlerContext,里面包着你注册的 ChannelHandler。链表两端是 Netty 内置的 HeadContext(连着底层 socket 读写)和 TailContext(兜底)。

Handler 分两类:

一个编解码器(Codec)往往同时实现两者。ChannelHandlerContext 是 handler 与 pipeline 交互的桥梁:ctx.fireChannelRead(msg) 把入站事件传给下一个 inbound handlerctx.write(msg) 把出站数据传给上一个 outbound handler

2. inbound 与 outbound 的传播方向

这是最容易搞混、也最关键的一点:

ChannelPipeline 双向事件传播:addLast 顺序为 frameDecoder(IN) → bidDecoder(IN) → bidEncoder(OUT) → bidHandler(IN);Inbound 读方向的 channelRead 从 HeadContext 向 TailContext 传播、只触发 IN handler;Outbound 写方向的 write 从写入点向 HeadContext 传播、只触发 OUT handler,跳过所有 IN handler

inbound 从 Head → Tail、只走 inbound handler;outbound 从写入点 → Head、只走 outbound handler。两个方向各走各的,互不经过对方。

所以对于 addLast(frameDecoder, bidDecoder, bidEncoder, bidHandler)

实践约定:解码器(inbound)按添加顺序执行;编码器(outbound)按添加的逆序执行。因此编码器放的位置要按「写出去时反向经过」来推。

3. 粘包与拆包:TCP 没有消息边界

应用层觉得自己「发了 3 个包」,但 TCP 眼里只有一串连续字节:

粘包与拆包示意:应用层 write 三个逻辑包 P1(18B)/P2(24B)/P3(15B),TCP 内核缓冲区里它们变成一条连续无分隔的字节流 …P1P2P3…,接收端两次 channelRead 事件把边界切乱——channelRead #1 收到 P1+P2前半(粘包),channelRead #2 收到 P2后半+P3(拆包)

TCP 只保证字节顺序、不保证「一次 write 一次 read」;内核按 MSS/Nagle/滑动窗口自由合并切分,应用必须自己定义并识别消息边界。

根因:TCP 是面向字节流的协议,它只保证字节的顺序到达,不保证边界。内核会根据 MSS(最大报文段)、Nagle 算法(攒小包)、滑动窗口等自由地合并或切分数据。结果:

因此应用层必须自己定义消息边界,并在解码器里把字节流重新切成完整的帧:

四种切帧方案。定长 FixedLength、分隔符 Line/Delimiter、长度字段 LengthField(首选)、自描述 HTTP/chunked。底部说明二进制协议首选长度字段,并务必设 maxFrameLength。

  1. 定长:每帧固定 N 字节 —— FixedLengthFrameDecoder。简单,但不灵活。
  2. 分隔符:以特定字节(如 \n)分隔 —— LineBasedFrameDecoder / DelimiterBasedFrameDecoder。适合文本协议,正文不能含分隔符。
  3. 长度字段:帧头带「body 长度」—— LengthFieldBasedFrameDecoder最通用、二进制协议首选
  4. 自描述格式:如 HTTP 的 Content-Length / chunked,本质也是长度或分隔思想。

4. LengthFieldBasedFrameDecoder 四参数

长度字段方案最灵活也最容易配错。以一个自定义竞价协议帧为例:magic(2) + version(1) + type(1) + length(4) + body(N),其中 length 记录 body 的字节数。

LengthFieldBasedFrameDecoder 四参数图解:竞价协议帧布局 magic(2B,偏移0) + version(1B,偏移2) + type(1B,偏移3) + length(4B,偏移4,值=body长度) + body(N,偏移8);lengthFieldOffset=4、lengthFieldLength=4、initialBytesToStrip=8(剥掉整个8B头只把body交给下游)、lengthAdjustment=0

四参数只需盯住「长度字段在哪、多长、它的值相对 body 差多少、解码后剥掉几字节头」,配合 maxFrameLength 上限防止内存被撑爆。

四个参数:

new LengthFieldBasedFrameDecoder(
    10 * 1024 * 1024, // maxFrameLength:上限 10MB,超长抛 TooLongFrameException
    4,                // lengthFieldOffset
    4,                // lengthFieldLength
    0,                // lengthAdjustment
    8                 // initialBytesToStrip(剥掉 8B 头,只留 body)
);

⚠️ 务必设 maxFrameLength:否则一个错配或恶意构造的超大 length 会让 decoder 一直等「后续字节」、把内存无限撑大——这正是下一节 war story 的核心。

5. 编解码抽象:ByteToMessageDecoder 与累积器

Netty 的编解码器建立在几个基类上:

一个典型的竞价协议解码器:

public class BidFrameDecoder extends ByteToMessageDecoder {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        if (in.readableBytes() < 8) return;      // 不够帧头,等更多字节
        in.markReaderIndex();
        int magic = in.readUnsignedShort();
        if (magic != 0xCAFE) { ctx.close(); return; }  // 协议不符,断连
        in.skipBytes(2);                          // version + type
        int len = in.readInt();
        if (in.readableBytes() < len) {           // body 还没到齐(半包)
            in.resetReaderIndex();                // 回退,等下次
            return;
        }
        ByteBuf body = in.readRetainedSlice(len); // 取出一帧 body(引用计数见上一篇)
        out.add(body);                            // 交给下游解码器
    }
}

实践中通常直接用 LengthFieldBasedFrameDecoder 切帧 + 一个 MessageToMessageDecoder 做反序列化(如 Protobuf),比手写累积逻辑更省心、更不易错。

6. 协议选型:OpenRTB over HTTP vs 内部二进制

序列化格式:JSON(可读、慢、大)、Protobuf(紧凑、快、需 schema)、纯自定义二进制(最紧凑、最难维护)。竞价内部链路对延迟敏感,Protobuf + 长度字段是常见平衡点。相关的高吞吐消息传输也可参考 Kafka 核心原理 里的批量与序列化取舍。

延伸阅读


views
Share this post on:

Previous Post
Netty 深挖 · 背压、水位线与百万连接调优
Next Post
Netty 深挖 · ByteBuf、引用计数与零拷贝内存模型