《Netty 高性能网络编程》7.1 协议帧设计与魔数

16 字节头里的每个字段都有代价。本节讲清 magic 为什么不是安全措施、version 怎么兼容、type 与 flags 留了多少扩展位、requestId 的无符号语义和 1 MiB 上限,并给出 LinkdFrame 与两个编解码器的完整实现。

本节目标:把 16 字节头的每个字段讲到「为什么是这个长度、出错是什么表现、以后怎么扩展」,并用一帧真实的十六进制把它拆开。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-buffer、netty-codec-base、netty-transport 模块)。

承接的问题

6.1 到 6.3 解决的是「怎么从字节流里切出一帧」:ByteToMessageDecoder 的 cumulation、长度域的四个参数、异常怎么往上抛。切出来之后,帧里放什么、每个字节是什么意思,是另一件事。

linkd-v2 的 9093 端口从这一节开始有正式协议。卷一 4.2 比较过定长、分隔符与长度域三种成帧方式,主协议选了长度域;本节把那个选择落成 16 字节的定长头。定长头的代价很明确:字段位置一旦发布就不能改,新增字段没有「跳过未知字段」的余地。所以每个字节都要在写代码之前回答三个问题:它解决什么问题、出错时是什么表现、以后怎么扩展。

这一节先给设计理由,再给十六进制样例,最后给 linkd-v2 的共享类型。代码在本机用 JDK 25 + Netty 4.2.19.Final 编译并跑过,输出贴在对应段落后面。

核心机制一:16 字节头的账

linkd-v2 的帧头是 16 字节,字段顺序固定:

偏移长度字段取值为什么是这个长度
04magic0x4C4B44464 字节足够区分协议,又不会误伤正常流量
41version1版本只有「能用」和「不能用」两态,1 字节够
51type1–7分组留白,最多 256 种帧类型
62flags当前 0x000116 位开关,够用且保持 4 字节对齐
84requestId无符号与 length 一起落在 4 字节边界
124length0–1 MiB4 字节有上限保护,不靠字段宽度限制大小

16 不是随手选的。requestId 和 length 都是 4 字节,放在偏移 8 和 12,解码时 getInt(start + 8)、getInt(start + 12) 都落在 4 字节边界上;头总长 16 是 2 的幂,HEADER_LENGTH + length 和缓冲区预留都好算。如果头是 18 字节,length 会落在偏移 14,仍然能读,但每个字段都要记一个不对齐的偏移,出错概率随字段数上升。

另一个选择是 TLV 头:每个字段自带 tag 和长度,扩展时旧解码器可以跳过不认识的字段。TLV 的代价是每个字段多 2–4 字节、解析要循环、字段顺序不固定。linkd-v2 是内部协议,字段集合稳定,定长头更简单也更快;代价是新增字段只能靠 version 升级或在 payload 里放扩展块。

package com.plume.linkd.v2.protocol;

public final class ProtocolConstants {

    public static final int MAGIC = 0x4C4B4446;
    public static final byte VERSION = 1;
    public static final int HEADER_LENGTH = 16;
    public static final int MAX_PAYLOAD = 1 << 20;

    public static final int FLAG_COMPRESSED = 0x0001;

    private ProtocolConstants() {
    }
}

MAGIC 写成 int 常量而不是 byte[]:解码时一次 getInt 就能比较,不用逐字节。VERSION 是 byte,getByte 读出来直接比。MAX_PAYLOAD 用 1 << 20 而不是 1048576,让「1 MiB」这个意图留在代码里;后面所有上限检查都引用它,不重复写字面量。

核心机制二:magic 不是安全措施

magic 的作用是「协议识别」,不是「访问控制」。它做三件事:第一,端口配错时立刻失败——把 HTTP 请求发到 9093,前 4 字节是 GET ,解码器直接报 bad magic,而不是把请求行第 12–15 个字节当成一个长度值;第二,代理或负载均衡把 TLS 流量转进来时,能看出「这不是我的协议」;第三,抓包时有一个固定锚点,tcpdump -X 里一眼能找到帧边界。

它挡不住的事情同样明确:任何人构造 4 字节 4c 4b 44 46 的成本是零。magic 不参与认证,也不参与完整性校验;一个能连上 9093 的攻击者可以发任意合法的帧头。真正的准入在 7.3 的 HELLO 与 token,传输层机密性要靠 TLS,这两件事都不能指望 magic。

也不要把 magic 当校验和。TCP 已经有校验和,链路上的比特错误在传输层就被丢掉;magic 只能发现「对端根本不是在说这个协议」,发现不了「对端在说这个协议但内容是恶意的」。选值时避开 0x00000000 和 0xFFFFFFFF,清零内存、默认值和很多协议的填充会撞上;0x4C4B4446 是 ASCII 的 LKDF,在十六进制里可读,又不会和 HTTP 的 GET、POST 混淆。

核心机制三:version、type 与 flags 的扩展空间

version 是单字节,当前只有 1。解码器对任何非 1 的值直接抛 CorruptedFrameException,不做「尽力解析」。把一个字节再拆成 major/minor 看似留了余地,但没有协商机制时,接收方无法知道对端按哪套语义编码;猜错比拒绝更糟。

升级路径是「服务端先升级、双方在 HELLO 里确认」:服务端同时接受 1 和 2,客户端在 HELLO 的 payload 里带自己支持的版本,服务端用 HELLO_ACK 确认后,后续帧才按 2 解析。字段语义不变的改动不 bump version;只有「旧解码器会解析错」的改动才 bump,比如改字段偏移、改 length 的含义。版本不匹配时返回 ERROR 再关闭,而不是静默断开:对端能看到 unsupported version: 2,排障时不用猜。

type 是 1 字节,FrameType 只用了 1–7,0 保留给「未初始化」,8–255 预留。分组是握手 1–2、心跳 3–4、业务 5–7。解码器对未知 type 直接拒绝——一个不认识的帧无法安全处理,透传给业务只会让状态机停在半路。新增 type 的兼容条件是双方都升级;如果要在不升级旧客户端的前提下扩展,只能把新字段放进 payload 内部,不能新增 type。

package com.plume.linkd.v2.protocol;

public final class FrameType {

    public static final int HELLO = 1;
    public static final int HELLO_ACK = 2;
    public static final int PING = 3;
    public static final int PONG = 4;
    public static final int REQUEST = 5;
    public static final int RESPONSE = 6;
    public static final int ERROR = 7;

    private FrameType() {
    }

    public static boolean isValid(int type) {
        return type >= HELLO && type <= ERROR;
    }

    public static String nameOf(int type) {
        return switch (type) {
            case HELLO -> "HELLO";
            case HELLO_ACK -> "HELLO_ACK";
            case PING -> "PING";
            case PONG -> "PONG";
            case REQUEST -> "REQUEST";
            case RESPONSE -> "RESPONSE";
            case ERROR -> "ERROR";
            default -> "UNKNOWN(" + type + ")";
        };
    }
}

isValid 把「合法范围」收在一个地方,解码器和 LinkdFrame 构造器都调它;nameOf 是给日志用的,遇到未知值返回 UNKNOWN(9) 而不是抛异常——日志路径不应该因为一个坏帧再抛一次。

flags 是 2 字节、16 位,当前只有最低位 0x0001 = compressed。规则是:发送端不得设置未定义的位,接收端遇到未知位拒绝。为什么不选「忽略未知位」?如果未来把 0x0002 定义成 encrypted,旧接收端忽略它,就会把密文当明文解析,错误发生在业务层,很难定位。flags 是语义开关,不是提示位,不认识就不能继续。位序写成 0x0001 比写「第 0 位」清楚:大端下 writeShort(0x0001) 写出 00 01,和字段定义一一对应。

核心机制四:requestId 的无符号语义与 length 的上限

requestId 线上 4 字节按无符号解释,范围 0 到 4,294,967,295。0 保留给不需要关联的帧(PING、PONG、ERROR),1 以上用于请求关联。Java 的 int 是有符号的,0xFFFFFFFE 读出来是 -2,下面这种写法会把高半区的 ID 排到前面:

// 反例:有符号比较,0xFFFFFFFE 会被当成负数
if (frame.requestId() < lastRequestId) {
    stale = true;
}

要比较用 Integer.compareUnsigned(a, b),要打印用 Integer.toUnsignedString,要转 long 用 Integer.toUnsignedLong。本机把 0xFFFFFFFE 编成帧再解出来,输出是:

requestId as int=-2 unsigned=4294967294

服务端回 RESPONSE 时必须原样回填 requestId,不能重新生成;客户端用它在 RpcClient 的 map 里找回 CompletableFuture。4 字节空间在最坏情况下会回绕,回绕策略在 8.1 讲。

length 只算负载,不含 16 字节头。理由有三个:解码器用 HEADER_LENGTH + length 算整帧;分配 payload 数组时直接用 length;如果含头,0 长度负载会变成 length=16,每次都要多一次减法。上限 1 MiB,因为 9093 是控制协议,单帧上限直接决定单连接的最坏内存:1 MiB × 10,000 条连接 = 10 GiB,这个数要写进容量规划。大块数据走 9090 的 HTTP 路径,不进自定义帧。

解码时先判断 length < 0 || length > MAX_PAYLOAD。length 是 int,最高位被置位就是负数;不检查就 new byte[length],本机会得到 java.lang.NegativeArraySizeException: -1,异常类型看不出是协议错误,日志里也很难定位到是哪一帧。

一帧十六进制样例

下面是一帧完整的 REQUEST,requestId 取 0xFFFFFFFE,payload 是 12 字节的 GET /books/7:

4c 4b 44 46  01  05  00 00  ff ff ff fe  00 00 00 0c  47 45 54 20 2f 62 6f 6f 6b 73 2f 37
偏移字节字段值解释
0–34c 4b 44 46magic0x4C4B4446ASCII 的 LKDF
401version1当前唯一支持的版本
505type5FrameType.REQUEST
6–700 00flags0未压缩,无其他标志
8–11ff ff ff ferequestId4,294,967,294有符号读是 -2
12–1500 00 00 0clength12负载 12 字节
16–2747 45 ... 2f 37payloadGET /books/7协议层不解释内容

整帧 28 字节 = 16 + 12。payload 里的 GET /books/7 只是恰好长得像 HTTP 请求行,协议层不解析它,type=REQUEST 的语义由业务 handler 决定。这也是自定义协议和 HTTP 的分工:9090 说 HTTP,9093 只说帧。

再看一个空负载帧。PING 的 requestId 用 0,length 是 0,整帧正好 16 字节:

4c 4b 44 46  01  03  00 00  00 00 00 00  00 00 00 00

把上面那帧 28 字节的 REQUEST 交给编码器再解回来,并从第 19 字节切成两段喂入,本机输出:

encoded length=28
hex=4c 4b 44 46 01 05 00 00 ff ff ff fe 00 00 00 0c 47 45 54 20 2f 62 6f 6f 6b 73 2f 37
after part1 frames=0
decoded type=REQUEST flags=0 requestId=4294967294 payload=GET /books/7

编码器写出的 28 字节和上面的手工样例逐字节一致;第一段 19 字节里有完整的头,但 length 说负载还有 12 字节,实际只有 3 字节,解码器返回而不消费;第二段补齐第一帧,readerIndex 才前进。

在 linkd-v2 里落地:一个 record 与两个编解码器

LinkdFrame 是协议层唯一的输出类型。它不持有 ByteBuf,payload 复制进 byte[],所以引用计数的所有权在解码器内部就结束了(5.2)。构造器把「非法类型」和「超大负载」挡在源头,出站方向至少不会写出这两种坏帧。

package com.plume.linkd.v2.protocol;

public record LinkdFrame(int type, int flags, int requestId, byte[] payload) {

    public LinkdFrame {
        if (!FrameType.isValid(type)) {
            throw new IllegalArgumentException("bad frame type: " + type);
        }
        if (payload == null) {
            payload = new byte[0];
        }
        if (payload.length > ProtocolConstants.MAX_PAYLOAD) {
            throw new IllegalArgumentException("payload too large: " + payload.length);
        }
    }

    public boolean hasFlag(int flag) {
        return (flags & flag) != 0;
    }
}

LinkdFrameDecoder 继承 ByteToMessageDecoder,按「先验证、再等待、最后消费」的顺序写。6.2 讨论过用 LengthFieldBasedFrameDecoder 子类做同一件事的写法;本卷的共享类型以这一版为准,因为 version、type、flags 的校验和长度判断要在同一个 decode 里完成,而父类的 initialBytesToStrip=16 会把头丢掉。两种写法不要同时出现在一条 pipeline 里。

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;
        if (!FrameType.isValid(type)) {
            throw new CorruptedFrameException("unknown frame type: " + type);
        }
        int flags = in.getUnsignedShort(start + 6);
        if ((flags & ~ProtocolConstants.FLAG_COMPRESSED) != 0) {
            throw new CorruptedFrameException("unsupported flags: 0x" + Integer.toHexString(flags));
        }
        int requestId = in.getInt(start + 8);
        int length = in.getInt(start + 12);
        if (length < 0 || length > ProtocolConstants.MAX_PAYLOAD) {
            throw new CorruptedFrameException("bad length: " + Integer.toUnsignedString(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));
    }
}

几个细节:getInt、getByte 不移动 readerIndex,验证阶段失败时 cumulation 原样保留;只有 HEADER_LENGTH + length 字节全部到齐,才 skipBytes 并读负载。type 用 & 0xFF 转无符号,flags 用 getUnsignedShort;length 打印时用 Integer.toUnsignedString,负值不会显示成 -1。

LinkdFrameEncoder 是出站方向的唯一写点。它不拼接多个 ByteBuf,16 字节头和负载写在同一个 buffer 里;writeInt、writeShort 默认大端,正好是网络字节序。

package com.plume.linkd.v2.protocol;

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;

public final class LinkdFrameEncoder extends MessageToByteEncoder<LinkdFrame> {

    @Override
    protected void encode(ChannelHandlerContext ctx, LinkdFrame frame, ByteBuf out) {
        byte[] payload = frame.payload();
        out.ensureWritable(ProtocolConstants.HEADER_LENGTH + payload.length);
        out.writeInt(ProtocolConstants.MAGIC);
        out.writeByte(ProtocolConstants.VERSION);
        out.writeByte(frame.type());
        out.writeShort(frame.flags());
        out.writeInt(frame.requestId());
        out.writeInt(payload.length);
        out.writeBytes(payload);
    }
}

MessageToByteEncoder 默认分配的是初始容量 256 的 ioBuffer(本机在 encode 里打印过 initial capacity=256),负载接近 1 MiB 时会经历多次扩容;ensureWritable 一次把容量要到 16 + payload.length,少几次拷贝。这一行不是正确性要求,是减少大帧路径上的分配抖动。

大端序与 ByteBuf 的默认字节序

网络字节序是大端,ByteBuf 的默认 order() 也是 BIG_ENDIAN,所以 writeInt、getInt 不传 ByteOrder 就是线上格式,不需要在协议层显式设置。本机验证:

BE order=BIG_ENDIAN hex=4c 4b 44 46
LE order=LITTLE_ENDIAN hex=46 44 4b 4c

同一个 0x4C4B4446 写进小端 buffer,线上变成 46 44 4b 4c;对端用默认大端读,得到 0x46444b4c,直接 bad magic。这就是「两端字节序不一致」在协议层的表现:不是数值错一点,而是第一帧就拒绝。

不要把 ByteBuffer.order() 的习惯带到 ByteBuf:不要为了读一个小端字段把整个 buffer 的 order 改掉,因为 order 是 buffer 的属性,后续所有 readInt 都跟着变。协议里绝大多数字段是大端,个别小端字段用 readIntLE、getIntLE 单独处理。字符串统一 UTF-8,长度按字节算,不按字符算;token 里有中文时 payload.length 是 UTF-8 字节数,不是 String.length()。

常见错误与边界

错误一:把 magic 当鉴权。 认为「不知道 magic 就连不上」是错的,magic 是公开常量,写在文档和抓包里。用它做准入,等于把门锁画在门上。认证在 7.3,传输加密在 TLS。

错误二:length 的口径不一致。 编码器写 payload.length,解码器算 HEADER_LENGTH + length,这是 linkd-v2 的约定。如果某一端以为 length 含头,解码器会按自己的口径多读或少读 16 字节,把下一帧的头当成当前帧负载的一部分;错位从这一帧就开始,通常要等到后面某次 bad magic 才暴露。

错误三:漏掉 length 的负数检查。 反例:

// 反例:只查上限,0xFFFFFFFF 会走到 new byte[-1]
if (length > ProtocolConstants.MAX_PAYLOAD) {
    throw new CorruptedFrameException("bad length: " + length);
}
byte[] payload = new byte[length];

本机输出是 java.lang.NegativeArraySizeException: -1。这个异常不会告诉你是哪个连接、哪个字段,排查时只能从线程栈反推。

错误四:用有符号比较处理 requestId。 见核心机制四。TreeMap<Integer, ...> 按自然序会把 0x80000000 之后的 ID 排到最前,响应关联会错位。

错误五:给 flags 留「忽略未知位」的兼容。 这会把未来的语义开关变成静默错误。宁可让旧版本明确拒绝,也不要让它按错误语义继续跑。

边界:定长头没有「跳过未知字段」的能力。 想加一个「优先级」字段,只有两条路:bump version 并让旧端拒绝,或者把优先级放进 payload 的扩展块,由 type 决定怎么解析。不要在 16 字节后面偷偷追加字节,那会改变 length 的起点。

边界:1 MiB 上限不是「性能上限」。 它限制的是单帧最坏内存,不是吞吐。真正的吞吐取决于帧率、拷贝次数和写缓冲水位,这些在 9 章和 14 章测量。

小结

  • 16 字节头让 requestId 和 length 落在 4 字节边界,HEADER_LENGTH + length 的整帧计算也简单。
  • magic 只做协议识别和早期失败,不认证、不校验完整性;它的值是公开的,攻击者构造零成本。
  • version 只在「旧解码器会解析错」时 bump,升级顺序是服务端先支持两个版本,再由 HELLO 确认。
  • type 的未知值直接拒绝;flags 的未知位也拒绝,因为 flags 是语义开关而不是提示位。
  • requestId 是无符号语义,Java 里要用 Integer.compareUnsigned 和 Integer.toUnsignedString。
  • length 只算负载、上限 1 MiB;length < 0 必须先查,否则 new byte[-1] 抛的是 NegativeArraySizeException。

帧能切出来、字段能解释,下一个问题是「连接空闲了怎么发现」。7.2 用 IdleStateHandler 把读空闲变成事件,再用 PING/PONG 确认对端还活着。

阅读导航:上一节:6.3 编解码错误与异常传播 · 下一节:7.2 IdleStateHandler 与心跳 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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