《Netty 高性能网络编程》6.1 ByteToMessageDecoder

粘包拆包都从 ByteToMessageDecoder 开始。本节拆开它的 cumulation 缓冲、callDecode 循环、decodeLast 与 isSingleDecode,给出可运行的行解码器和 linkd-v2 帧解码器,并说明 decode 里 release 入参、读不够却推进 readerIndex、以及用累积缓冲承载超大帧会付出什么代价。

本节目标:把「一次 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 长度域、分隔符与定长解码 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发