本节目标:把「一次 read 不等于一帧」变成
ByteToMessageDecoder的固定套路:谁累积、谁释放、何时返回、关闭时怎么收尾。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-codec-base、netty-buffer、netty-transport模块)。
承接的问题
5.3 讲完 PooledByteBufAllocator 之后,linkd-v2 的 9093 端口已经有能力高效分配缓冲区,但还没有能力从字节流里取出帧。客户端发一个 HELLO 帧,服务端可能一次收到半个头,也可能一次收到三个完整的 REQUEST。如果直接在 channelRead 里写:
// 反例:把一次 read 当成一帧
LinkdFrame frame = parse((ByteBuf) msg);
那么半包时 parse 会读到越界,粘包时后两帧会被当成第一帧的负载。要解决这个问题,必须把「没读完的字节」留到下一次 read。
ByteToMessageDecoder 就是 Netty 提供的这个「留字节」的基类。它替子类做了四件事:跨 read 累积字节、在数据够时循环调用 decode、把 out 里的对象逐个传给下游、在连接关闭时调用 decodeLast 并释放累积缓冲。子类只需要回答一个问题:从当前可读字节里能拆出哪些完整消息?
这一节先用手写的行解码器看清四个钩子,再写 linkd-v2 的 LinkdFrameDecoder。6.2 会用 LengthFieldBasedFrameDecoder 替换其中的长度判断,类名和输出类型保持不变。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面标注「本机输出」的内容都来自这台机器,单次运行,未做统计。
核心机制一:cumulation 是一条跨 read 的缓冲
ByteToMessageDecoder 内部有一个 cumulation 字段。第一次收到数据时,它直接把入站 ByteBuf 当作累积缓冲;后续每次收到数据,如果累积缓冲还有可写空间,就把新字节追加进去,空间不够则分配更大的缓冲、把旧数据和新数据一起拷过去,再释放旧的。
对子类来说,decode(ctx, in, out) 里的 in 永远是「从第一段未消费字节到当前流末尾」的连续视图。in.readerIndex() 指向还没处理的第一字节,in.writerIndex() 指向已收到的最后字节。三次 read 拼起来的过程可以写成:
read 1: [00 05 68] -> decode 看不到完整长度域,readerIndex 不动
read 2: [00 05 68 65 6c 6c 6f] -> decode 取出 hello,readerIndex 移到 7
read 3: [00 05 77 6f 72 6c 64] -> 新字节接到残余之后,decode 取出 world
这张图里有两个容易误解的点。第一,decode 看到的不是「本次 read 的新数据」,而是「累积到现在、还没消费的数据」。第二,readerIndex 是唯一的进度标记:只要它没动,父类就认为「数据不够」,会把整个 in 留到下一次;只要它动了,父类就认为「已消费」,即使 out 里没有任何消息,也会继续尝试下一轮。
因此,判断「够不够」时不要调用 in.skipBytes() 或 in.readBytes() 先消费再检查。正确做法是用 getInt、getByte 这类不移动 readerIndex 的方法读头部字段,确认整帧可读之后,再一次性 skipBytes 或 readBytes。
核心机制二:callDecode 的循环与 isSingleDecode
一次 channelRead 可能带来多个完整帧,父类用一个循环反复调用 decode,直到不能继续。循环的退出条件可以归纳成三条:
| 条件 | 父类的判断 | 结果 |
|---|---|---|
| 数据不够 | out 没新增,readerIndex 没变 | 返回,保留 cumulation |
| 拆出了消息 | out 有新增 | 逐个 fireChannelRead,清空 out,继续 |
| 只消费了头部 | out 没新增,readerIndex 前进 | 继续下一轮,等待负载 |
第三条对应「先读头、再读体」的解码器:第一轮可能只消费了 16 字节头,out 还是空的,但 readerIndex 已经前进,父类必须再调一次 decode 才能拿到负载。如果子类既不产出消息、又不消费字节,父类会认为它在空转,直接退出,避免死循环。
isSingleDecode() 默认返回 false,也就是一次 read 事件里尽量把所有完整帧都拆完。把它改成 true 后,每轮只调用一次 decode,拆出一帧就返回,剩下的留到下一次 read 事件。下面这个每轮只吐 1 字节的解码器可以验证这个行为:
public final class SingleByteDecoder extends ByteToMessageDecoder {
@Override
public boolean isSingleDecode() {
return true;
}
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.isReadable()) {
out.add(in.readRetainedSlice(1));
}
}
}
喂入 abcd 再喂入 e,本机输出:
after one write, inbound messages=1
after second write, inbound messages=2
第一次 write 有 4 字节可拆,但 isSingleDecode 让它在 1 个消息后停下;第二次 write 只是触发了下一轮,父类从剩余的 bcd 里再拆 1 个。两次 write 一共只产出 2 个消息,而不是 5 个。isSingleDecode 适合「单帧解码很重、需要把一次 read 的 CPU 时间切片」的场景;默认的 false 延迟更低,适合小帧和高吞吐。linkd-v2 的控制帧很小,保持默认值。
核心机制三:一个最小的行解码器
行协议是最容易观察 cumulation 的载体:边界是 \n,可能带 \r,一帧的长度不固定。下面这个解码器扫描 \n,找到后把内容作为 ByteBuf 放进 out:
package com.plume.linkd.v2.protocol;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import java.util.List;
public final class LineDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
int start = in.readerIndex();
int lf = -1;
for (int i = start; i < in.writerIndex(); i++) {
if (in.getByte(i) == '\n') {
lf = i;
break;
}
}
if (lf < 0) {
return;
}
int end = lf;
if (end > start && in.getByte(end - 1) == '\r') {
end--;
}
out.add(in.readRetainedSlice(end - start));
in.skipBytes(lf - end + 1);
}
}
这段代码有三个所有权细节。getByte(i) 只读不消费,所以扫描阶段不会破坏 cumulation。readRetainedSlice 会推进 readerIndex,并返回一个引用计数加一的切片;这个切片放进 out 之后,所有权就交给下游,解码器自己不能再 release。skipBytes 只跳过 \r\n 或 \n,不跳过内容,所以下一帧的起点正好落在残余数据的第一个字节。
把 hel、lo\r\nwor、ld\n\nlast 分三次喂进去,本机输出:
after 'hel': readableInbound=0
line='hello' len=5
line='world' len=5
line='' len=0
第一次只有 3 字节,没有 \n,decode 直接返回,readerIndex 不动,所以下游一个消息也拿不到。第二次补齐 hello\r\n,同时带来 wor;父类在同一轮里继续扫描,但 wor 后面没有换行,decode 返回,wor 留在 cumulation。第三次补齐 world\n,再遇到一个空行,于是输出 hello、world 和一个长度为 0 的空行。最后的 last 没有换行,没有被当成帧;连接关闭时默认的 decodeLast 会再调用一次 decode,仍然找不到换行,残余字节随 cumulation 一起释放。
核心机制四:decodeLast 与关闭时的残余
channelInactive 触发时,父类会调用一次 decodeLast,然后把 cumulation 释放掉。默认实现只有一句:如果 in 还有可读字节,就再调用一次 decode。这意味着「末尾没有分隔符的半个帧」不会被自动补出来,只会被静默丢弃。
对行协议来说,丢弃最后一行通常可以接受;对二进制协议来说,半个帧意味着对端在发送途中断开,必须记录成异常关闭。可以在子类里覆写 decodeLast,把残余字节变成明确的错误:
@Override
protected void decodeLast(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.isReadable()) {
throw new CorruptedFrameException(
"truncated frame: " + in.readableBytes() + " trailing bytes");
}
}
喂入 4 字节的完整帧,再喂入 1 字节的残余,然后触发 channelInactive,本机输出:
decoded='abcd'
active before inactive=true
inactive fired
io.netty.handler.codec.CorruptedFrameException: truncated frame: 3 trailing bytes
这里抛出的是 CorruptedFrameException,它会沿着 pipeline 走 exceptionCaught。此时连接已经进入关闭流程,exceptionCaught 里再写 ERROR 帧通常来不及,正确动作是记录对端地址、残余长度和最近的帧类型,然后让关闭继续。还有两个边界:handlerRemoved 也会释放 cumulation,运行中动态移除解码器会丢掉半帧;ByteToMessageDecoder 持有 cumulation 和解析状态,不能标 @Sharable 跨连接复用。
在 linkd-v2 里落地:手写帧解码器
linkd-v2 的 9093 使用 16 字节头:magic(4) + version(1) + type(1) + flags(2) + requestId(4) + length(4)。长度字段在 offset 12,表示负载长度,上限 1 MiB。手写版解码器直接把完整帧转成 LinkdFrame:
package com.plume.linkd.v2.protocol;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.handler.codec.CorruptedFrameException;
import java.util.List;
public final class LinkdFrameDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.readableBytes() < ProtocolConstants.HEADER_LENGTH) {
return;
}
int start = in.readerIndex();
int magic = in.getInt(start);
if (magic != ProtocolConstants.MAGIC) {
throw new CorruptedFrameException("bad magic: 0x" + Integer.toHexString(magic));
}
byte version = in.getByte(start + 4);
if (version != ProtocolConstants.VERSION) {
throw new CorruptedFrameException("unsupported version: " + version);
}
int type = in.getByte(start + 5) & 0xFF;
int flags = in.getUnsignedShort(start + 6);
int requestId = in.getInt(start + 8);
int length = in.getInt(start + 12);
if (length < 0 || length > ProtocolConstants.MAX_PAYLOAD) {
throw new CorruptedFrameException("bad length: " + length);
}
if (in.readableBytes() < ProtocolConstants.HEADER_LENGTH + length) {
return;
}
in.skipBytes(ProtocolConstants.HEADER_LENGTH);
byte[] payload = new byte[length];
in.readBytes(payload);
out.add(new LinkdFrame(type, flags, requestId, payload));
}
}
这段实现的关键是「先验证、再等待、最后消费」。验证阶段全部用 getXxx,不改变 readerIndex;长度超过 MAX_PAYLOAD 时立刻抛错,不给对端用 0x7fffffff 把内存吃光的机会;只有 HEADER_LENGTH + length 字节全部到齐,才 skipBytes 并读出负载。负载复制进 byte[],LinkdFrame 不持有 ByteBuf,因此不存在引用计数转移问题。
把一个 21 字节的帧从第 19 字节处切开,分两次喂入,本机输出:
part1 bytes=19, part2 bytes=23
after part1, frames=0
LinkdFrame[type=5,flags=0,requestId=42,payload=hello]
LinkdFrame[type=6,flags=1,requestId=42,payload=world]
第一段有完整的 16 字节头,长度字段说负载还有 5 字节,但实际只有 3 字节,decode 在 readableBytes() < 16 + 5 处返回,readerIndex 一点没动。第二段补齐第一帧,同时带来第二帧的全部字节;默认 isSingleDecode=false,父类在同一个 read 事件里连续拆出两个 LinkdFrame。
为什么 ByteToMessageDecoder 不适合超大帧
ByteToMessageDecoder 的设计前提是「一帧小到可以完整放进内存」。长度域协议尤其明显:在整帧到齐之前,decode 不能返回任何东西,cumulation 必须一直持有已经收到的部分。一个 64 MiB 的帧意味着每条连接在解码期间至少占 64 MiB;一千条连接同时传大文件,就是 64 GiB 的峰值。
累积缓冲的扩容也会放大成本。默认的 MERGE_CUMULATOR 在可写空间不足时分配新缓冲,把旧数据和新数据一起复制过去,再释放旧缓冲。按倍数扩容的均摊复杂度是线性的,但单次扩容的拷贝量和峰值内存都随帧长增长;帧越大,越容易触发一次大的连续分配,池化分配器对大块请求也更难复用。
ByteToMessageDecoder 本身没有帧长上限,maxFrameLength 是 LengthFieldBasedFrameDecoder 才有的参数。手写解码器如果不做 length > MAX_PAYLOAD 检查,就会把「对端声明多大就等多大」写进内存。linkd-v2 把 MAX_PAYLOAD 固定为 1 MiB,9093 只承载控制帧;大块数据走 9090 的 HTTP 流式路径,由 HttpObjectAggregator 的 maxContentLength 或分块 HttpContent 处理,而不是塞进自定义帧。
需要处理大帧时,方向不是「把 cumulation 调大」,而是「不要让整帧进入 cumulation」:入站按固定大小切片,边收边写文件或下游;出站用 ChunkedWriteHandler 分块发送;协议层把单帧上限写进握手,超限直接拒绝。这些做法都比让一个 ByteToMessageDecoder 持有几百 MiB 更可控。
在 linkd-v2 里落地:pipeline 与处理器
LinkdFrameDecoder 是有状态的,每条连接一个实例。9093 的 ChannelInitializer 这样装配:
ch.pipeline().addLast(new LinkdFrameDecoder());
ch.pipeline().addLast(new LinkdFrameEncoder());
ch.pipeline().addLast(new LinkdFrameHandler());
LinkdFrameHandler 收到的是已经拆好的 LinkdFrame,不需要再关心半包。它继承 SimpleChannelInboundHandler<LinkdFrame>,但 LinkdFrame 是普通 record,不是 ReferenceCounted,所以自动释放对它没有作用;真正需要释放的是 out.add 进去的 ByteBuf,而这里已经转换成 byte[],所有权问题在解码器内部就结束了。
装配顺序有两个约束。解码器必须在业务处理器之前,因为入站事件从 head 往 tail 走;编码器必须在业务处理器之前,因为 ctx.writeAndFlush 从当前 handler 往 head 找出站处理器,放在业务处理器之后就不会被编码。LinkdFrameDecoder 不能写成单例字段复用,否则两条连接的 cumulation 会混在一起;LinkdFrameEncoder 如果无状态,可以共享,但每连接新建更省心。
常见错误与边界
错误一:在 decode 里 release 入参。
// 反例:父类还要用 cumulation,子类不能释放
in.release();
本机运行会得到两种错误之一。没有消费字节时,父类先报「没有读任何东西却产出了消息」:
io.netty.handler.codec.DecoderException: ...decode() did not read anything but decoded a message.
消费了字节再释放,父类在下一轮访问 cumulation 时报引用计数错误:
io.netty.util.IllegalReferenceCountException: ...#decode() might have released its input buffer, or passed it down the pipeline without a retain() call, which is not allowed.
in 的生命周期由父类管理,子类只读、只移动 readerIndex,不释放。
错误二:读不够却先推进 readerIndex。
// 反例:先把能读的都读掉,再判断够不够
in.skipBytes(in.readableBytes());
if (in.readableBytes() < ProtocolConstants.HEADER_LENGTH) {
return;
}
第一次 read 只有 10 字节时,这 10 字节被消费掉,父类看到 readerIndex 前进、out 为空,会继续循环;但 cumulation 里已经没有这 10 字节,下一帧的头部从第 11 字节开始,整条流从此错位。正确写法是先用 getInt(start) 读,确认整帧到齐后再 skipBytes。
错误三:out.add 之后又自己释放。
// 反例:所有权已经转移,解码器不能再碰
ByteBuf frame = in.readRetainedSlice(len);
out.add(frame);
frame.release();
下游拿到的 ByteBuf 引用计数已经归零,读取时抛 IllegalReferenceCountException。out 是所有权转移点:放进去之后,释放责任归 pipeline 中消费它的 handler。
错误四:把有状态的解码器标成 @Sharable。 ByteToMessageDecoder 的 cumulation、readerIndex 进度和解析状态都挂在实例上。两条连接共享一个实例,A 的半帧会被 B 的字节补齐,表现为随机解析失败。只有完全无状态的 handler 才能共享,解码器不在其列。
边界:decodeLast 默认只调用一次 decode。 如果开了 isSingleDecode=true,关闭时 cumulation 里可能还剩多个完整帧,默认的 decodeLast 只拆一个就释放剩余字节。需要保留末尾帧时,覆写 decodeLast 并循环调用 decode,直到 out 不再增长;不需要时,明确记录丢弃了多少字节。
边界:一次 read 的字节数与帧边界无关。 它受 MSS、接收窗口、内核调度影响,测试时不要断言「一次 read 应该收到多少字节」,应该断言「给定任意切分,最终能拆出哪些帧」。
小结
ByteToMessageDecoder用 cumulation 把多次 read 拼成连续字节流,readerIndex是唯一进度标记;没读够就返回,且不要移动它。callDecode循环在「数据不够」「拆出消息」「只消费头部」三种状态间推进;isSingleDecode控制每次 read 事件拆几帧。decode里不要release入参;out.add是所有权转移点,放进去的ByteBuf归下游释放。decodeLast在连接关闭时调用一次,默认只再执行一次decode;残余半帧要么显式报错,要么记录丢弃。- 手写的
LinkdFrameDecoder先验证 magic/version/length,再等待整帧,最后消费;6.2 会用LengthFieldBasedFrameDecoder替换长度判断。 - cumulation 必须容纳整帧,超大帧会带来每连接的高峰值内存和扩容拷贝;
linkd-v2用 1 MiB 上限把大块数据挡在 9093 之外。
拆帧解决的是「一帧从哪里开始、到哪里结束」。下一节把这套手写判断换成 Netty 内置的长度域、分隔符和定长解码器,并用三组字节序列把 offset、adjustment 和 strip 算清楚。
阅读导航:上一节:5.3 PooledByteBufAllocator 与 Arena · 下一节:6.2 长度域、分隔符与定长解码 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。