开篇讲了 EventLoop 怎么把字节从 socket 搬进来、上一篇讲了这些字节装在 ByteBuf 里——但字节流要变成业务能用的消息,靠的是 ChannelPipeline 上的编解码器。
这一篇钻两件事:pipeline 的事件怎么传播,以及怎么把没有边界的 TCP 字节流切成完整的协议帧。Pipeline 是双向责任链,inbound/outbound 各走各的方向;TCP 只保证字节顺序、不保证消息边界,所以必须在解码器里自己切帧。
TL;DR
- ChannelPipeline 是一条双向链表,节点是
ChannelHandlerContext(包住你的ChannelHandler),两端是HeadContext和TailContext。 - 两个方向:inbound(读方向,
channelRead等)从 Head → Tail,只触发 inbound handler;outbound(写方向,write等)从写入点 → Head,只触发 outbound handler——两个方向互不经过对方的 handler。 - handler 顺序很关键:
addLast的顺序决定 inbound 的正向顺序和 outbound 的反向顺序;解码器要放在业务 handler 之前、编码器的位置按 outbound 反向理解。 - 粘包/拆包的本质:TCP 是面向字节流的协议,只保证字节顺序,不保证「一次 write 对应一次 read」;内核按 MSS/Nagle/滑动窗口自由合并或切分,消息边界丢失。
- 四种切帧方案:定长(
FixedLengthFrameDecoder)、分隔符(DelimiterBasedFrameDecoder/LineBasedFrameDecoder)、长度字段(LengthFieldBasedFrameDecoder,最常用)、自描述格式(如 HTTPContent-Length/ chunked)。 LengthFieldBasedFrameDecoder四参数:lengthFieldOffset/lengthFieldLength/lengthAdjustment/initialBytesToStrip——配错就解析错位或半包累积(本篇 war story)。- 编解码抽象:
ByteToMessageDecoder(带累积器 Cumulator,decode可能被多次调用)、MessageToByteEncoder、MessageToMessageCodec。 - 必设
maxFrameLength:给帧长上限,超长直接TooLongFrameException,避免错配/恶意长度把内存撑爆。 - 协议选型:OpenRTB over HTTP(通用、可读、易对接)vs 内部二进制协议(紧凑、低延迟)——竞价对内低延迟链路常用自定义二进制 + 长度字段。
- AdTech 落地:竞价 Server 用「LengthField 切帧 + Protobuf 解码 + 业务 handler」的标准三段式 pipeline。
Table of contents
Open Table of contents
1. ChannelPipeline:双向责任链
每个 Channel 有且仅有一条 ChannelPipeline,它是一条双向链表,每个节点是一个 ChannelHandlerContext,里面包着你注册的 ChannelHandler。链表两端是 Netty 内置的 HeadContext(连着底层 socket 读写)和 TailContext(兜底)。
Handler 分两类:
ChannelInboundHandler:处理入站事件(channelActive、channelRead、channelReadComplete、exceptionCaught等),即「数据/事件从网络进来」。ChannelOutboundHandler:处理出站操作(write、flush、connect、close等),即「数据/操作往网络出去」。
一个编解码器(Codec)往往同时实现两者。ChannelHandlerContext 是 handler 与 pipeline 交互的桥梁:ctx.fireChannelRead(msg) 把入站事件传给下一个 inbound handler,ctx.write(msg) 把出站数据传给上一个 outbound handler。
2. inbound 与 outbound 的传播方向
这是最容易搞混、也最关键的一点:
inbound 从 Head → Tail、只走 inbound handler;outbound 从写入点 → Head、只走 outbound handler。两个方向各走各的,互不经过对方。
- Inbound(读方向):socket 收到数据 →
HeadContext触发channelRead→ 沿链表向 Tail 依次经过每个 inbound handler(跳过 outbound handler)。 - Outbound(写方向):业务
ctx.write()→ 从写入点沿链表向 Head 依次经过每个 outbound handler(跳过 inbound handler)→HeadContext真正写 socket。
所以对于 addLast(frameDecoder, bidDecoder, bidEncoder, bidHandler):
- 入站顺序:
frameDecoder → bidDecoder → bidHandler(bidEncoder是 outbound,入站时跳过); - 出站(
bidHandlerwrite 响应)顺序:bidEncoder → HeadContext(写入点之前的 outbound handler 才会被触发;outbound 是反向的)。
实践约定:解码器(inbound)按添加顺序执行;编码器(outbound)按添加的逆序执行。因此编码器放的位置要按「写出去时反向经过」来推。
3. 粘包与拆包:TCP 没有消息边界
应用层觉得自己「发了 3 个包」,但 TCP 眼里只有一串连续字节:
TCP 只保证字节顺序、不保证「一次 write 一次 read」;内核按 MSS/Nagle/滑动窗口自由合并切分,应用必须自己定义并识别消息边界。
根因:TCP 是面向字节流的协议,它只保证字节的顺序到达,不保证边界。内核会根据 MSS(最大报文段)、Nagle 算法(攒小包)、滑动窗口等自由地合并或切分数据。结果:
- 粘包:多个逻辑包被合并进一次
channelRead; - 拆包(半包):一个逻辑包被拆到多次
channelRead。
因此应用层必须自己定义消息边界,并在解码器里把字节流重新切成完整的帧:
- 定长:每帧固定 N 字节 ——
FixedLengthFrameDecoder。简单,但不灵活。 - 分隔符:以特定字节(如
\n)分隔 ——LineBasedFrameDecoder/DelimiterBasedFrameDecoder。适合文本协议,正文不能含分隔符。 - 长度字段:帧头带「body 长度」——
LengthFieldBasedFrameDecoder。最通用、二进制协议首选。 - 自描述格式:如 HTTP 的
Content-Length/ chunked,本质也是长度或分隔思想。
4. LengthFieldBasedFrameDecoder 四参数
长度字段方案最灵活也最容易配错。以一个自定义竞价协议帧为例:magic(2) + version(1) + type(1) + length(4) + body(N),其中 length 记录 body 的字节数。
四参数只需盯住「长度字段在哪、多长、它的值相对 body 差多少、解码后剥掉几字节头」,配合 maxFrameLength 上限防止内存被撑爆。
四个参数:
lengthFieldOffset:长度字段前面有多少字节 → 这里magic+version+type = 4。lengthFieldLength:长度字段本身占几字节 → 这里4。lengthAdjustment:长度字段的值 与「长度字段之后到帧尾的实际字节数」的差值。这里length正好 = body 长度、且 length 后紧跟 body,所以= 0。如果length记的是「整帧长度(含头)」,则要lengthAdjustment = -(lengthFieldOffset + lengthFieldLength) = -8。initialBytesToStrip:解码后从帧头剥掉几字节再交给下游。这里想只把 body 给下游解码器,剥掉整个 8 字节头 →= 8。
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 的编解码器建立在几个基类上:
ByteToMessageDecoder(入站,字节 → 消息):内部有一个累积器(Cumulator),把多次channelRead收到的 ByteBuf 累积起来,反复调用你的decode(ctx, in, out)。decode可能被多次调用:数据不够一帧时你什么都不 add,Netty 会等更多字节再调;够一帧就 add 一个消息、循环直到不够。MessageToByteEncoder(出站,消息 → 字节):把你的 POJO 编码进 ByteBuf。MessageToMessageDecoder/MessageToMessageCodec:消息到消息的转换(如 Protobuf byte[] → POJO)。
一个典型的竞价协议解码器:
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 内部二进制
- 对外(与 SSP/交易所对接):多用 OpenRTB over HTTP(JSON)。通用、可读、生态成熟、易对接调试,代价是 JSON 体积大、解析慢。
- 对内(竞价引擎内部各服务):低延迟链路常用自定义二进制协议 + 长度字段切帧 + Protobuf。帧头紧凑、序列化快、体积小,把 tmax 预算尽量留给业务计算。
序列化格式:JSON(可读、慢、大)、Protobuf(紧凑、快、需 schema)、纯自定义二进制(最紧凑、最难维护)。竞价内部链路对延迟敏感,Protobuf + 长度字段是常见平衡点。相关的高吞吐消息传输也可参考 Kafka 核心原理 里的批量与序列化取舍。