跳到正文

拆包粘包与编解码 ​

一句话:TCP 只保证字节顺序,不保证消息边界;拆包粘包不是异常,是流协议的正常现象,解法是在应用层定义「一帧从哪里开始、到哪里结束」。

适用版本:Netty 4.1.x。

为什么需要 ​

发送端 write 三次,接收端可能一次读到三段,也可能一段被拆成两次。原因不在 Netty:

  • TCP 是字节流,没有消息边界。
  • 发送端 Nagle 算法会合并小包。
  • 接收端滑动窗口、MTU、内核缓冲区都会影响到达节奏。

所以任何基于 TCP 的协议都必须自己定义帧边界;HTTP 用 Content-Length/chunked,Redis 用长度前缀,自定义 RPC 用长度字段。

核心机制 ​

四种常用分帧方式 ​

解码器边界依据适用代价
FixedLengthFrameDecoder固定字节数定长协议变长消息浪费带宽
LineBasedFrameDecoder\n/\r\n文本协议二进制内容可能含换行
DelimiterBasedFrameDecoder自定义分隔符文本/简单协议分隔符需转义
LengthFieldBasedFrameDecoder头部长度字段RPC/MQ/二进制协议要约定长度含义

长度字段:必须前后一致 ​

先定义协议,再配参数。假设帧头 8 字节:

+--------+---------+--------+----------+--------+
| Magic  | Version | Type   | Length   | Body   |
| 2B     | 1B      | 1B     | 4B       | N B    |
+--------+---------+--------+----------+--------+

两种常见约定,参数完全不同:

约定 A:Length = Body 长度(不含头部)

java
new LengthFieldBasedFrameDecoder(
    1 << 20,  // maxFrameLength = 1MiB
    4,        // lengthFieldOffset:Length 从第 4 字节开始
    4,        // lengthFieldLength:Length 占 4 字节
    0,        // lengthAdjustment:帧长 = 8 + Length,不再修正
    8         // initialBytesToStrip:跳过 8 字节头,业务只拿 Body
);

约定 B:Length = 头部 + Body(含自身)

java
new LengthFieldBasedFrameDecoder(
    1 << 20,
    4,
    4,
    -8,       // frameLength = 8 + (-8) + Length = Length
    8
);

通用公式:

frameLength = lengthFieldEndOffset + lengthAdjustment + lengthValue
lengthFieldEndOffset = lengthFieldOffset + lengthFieldLength

协议文档必须写清楚 Length 是否包含头部;解码器参数必须和文档一致。前后矛盾会导致「有时能解、有时多读/少读」。

编解码器在 Pipeline 中的位置 ​

Head → FrameDecoder → MessageDecoder → Business → MessageEncoder → Tail
  • FrameDecoder 必须在最前:它把字节流切成完整帧,后面的解码器才能按帧解析。
  • MessageEncoder 要在业务 Handler 靠近 Head 的一侧;业务用 ctx.write 时才会经过它。
  • 出站不需要 FrameDecoder:写出去的是完整消息,Encoder 直接编码成字节。

maxFrameLength 是安全边界 ​

长度字段是客户端给的。如果客户端发一个 2GB 的长度值,解码器会尝试等/分配这么大的缓冲,直接 OOM。必须设置业务上限:

  • RPC 常见 64KiB–1MiB。
  • 超过上限抛 TooLongFrameException,在 exceptionCaught 里关闭连接并记录来源 IP。
  • 文件传输不要复用 FrameDecoder,走 chunked 流式。

实战场景 ​

一、新版客户端三成请求解析失败:Length 含义与字节序 ​

现象:设备端 SDK 从 v1 升到 v2 后,接入网关约 30% 的消息抛 CorruptedFrameException,其余正常;老设备一切正常。网关代码没动过。

排查:分帧只由三件事决定——长度含义、字节序、字段偏移。抓包逐帧核对,不要只看单测。

定位层取证据判定
一帧实际字节tcpdump -A 或 handler 里 ByteBufUtil.hexDump(in.readSlice(in.readableBytes()))与协议文档逐字节比对
Length 含义手算 frameLength = lengthFieldEndOffset + lengthAdjustment + lengthValue算出的帧长比实际多/少 8 字节 → 是否含头部不一致
字节序头部 4 字节按大端和小端分别解一次v2 改成 little-endian 就会解出巨大的长度
字段偏移lengthFieldOffset 是否随 Magic/Version 长度变化v2 加了 2 字节扩展头,偏移没跟着改
版本来源按对端 Version 字段分组统计失败率只失败在某一版本 → 协议演进问题,不是网络

本例命中的是第一条与第二条叠加:v2 把 Length 改成“含头部”,而网关仍按约定 A 配置。lengthAdjustment=0 下解出的 frameLength 比实际多 8 字节,于是每帧多等 8 字节,表现为“有时成功(正好粘了下一帧的头)、有时错位”。

对症做法:

java
// 1. 版本协商:按 Version 选择解码参数,而不是让一个解码器猜
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    if (in.readableBytes() < 8) return;
    int version = in.getUnsignedByte(2);
    ctx.pipeline().replace("framer", "framer", framerFor(version));   // 在本通道 EventLoop 内安全
    super.decode(ctx, in, out);
}
private LengthFieldBasedFrameDecoder framerFor(int v) {
    return v >= 2 ? new LengthFieldBasedFrameDecoder(MAX, 4, 4, -8, 8)    // 约定 B:含头部
                  : new LengthFieldBasedFrameDecoder(MAX, 4, 4,  0, 8);   // 约定 A:只算 Body
}

// 2. Magic 校验放在解帧之后,错位能立刻暴露而不是继续传染
// 3. 协议文档三处一致:Length 是否含头、字节序、字段偏移;变更走版本号,不靠猜

改解码器属于结构变更,要走发布流程:文档 diff、对端版本清单、灰度一个接入实例并观察 CorruptedFrameException 数。

复盘要点:协议演进要有“不兼容就换版本号,网关按版本分支”这条硬约束。判据可以量化:按 Version 维度统计解码失败率,任一版本失败率超过 0.1%(经验值,需校准)就报警——全局平均会掩盖单版本故障。

二、客户端给一个巨大 length:半包长挂与内存上涨 ​

现象:凌晨网关 RSS 缓慢上涨,连接数从 8 万涨到 11 万且不释放;jmap -histo 里 byte[] 与 PoolChunk 占用显著。抓包发现有对端发送 Length=2130706433(约 2GB)后不再发数据。

排查:长度字段是客户端可控输入,解码器要为它预留缓冲,这是分帧机制自带的攻击面。

定位层取证据判定
上限配置maxFrameLength 的实际值;是否写了 Integer.MAX_VALUE等于把 OOM 权力交给客户端
是否真的分配了解码器内部累积缓冲大小指标 / RSS 与连接数曲线半包长期不齐会持续占住内存
连接状态ss -tn state established '( sport = :8080 )',看 Recv-Q 长期不增长的连接存在“收半帧就停”的僵死连接
失败行为日志里 TooLongFrameException 数量为 0 且内存涨 → 超限检查没起作用或被绕过
来源按 IP/设备 ID 统计异常帧集中来源通常是扫描或固件缺陷

对症做法:

java
// 1. 上限按业务真实最大帧设,RPC 常见 64KiB–1MiB
new LengthFieldBasedFrameDecoder(1 << 20, 4, 4, 0, 8, true);   // failFast=true:超限尽早失败

// 2. 异常必须断链,流已经错位,继续读只会更乱
@Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
    if (cause instanceof TooLongFrameException || cause instanceof CorruptedFrameException) {
        metrics.decodeError(cause.getClass().getSimpleName());
        log.warn("bad frame from {}", ctx.channel().remoteAddress(), cause);
        ctx.close();
        return;
    }
    ctx.close();
}

// 3. 半包长挂用空闲检测兜底,不靠客户端自觉
ch.pipeline().addLast(new IdleStateHandler(30, 0, 0, SECONDS));   // 30s 读空闲即判死
// userEventTriggered 里收到 READER_IDLE 就 close,并按来源打点

大报文(文件、批量上传)不复用 FrameDecoder,走分块流式;maxFrameLength 调大之前先算单连接最坏驻留内存 × 连接数,例如 1MiB × 10 万连接 = 100GB,这个数字本身就会否掉方案。

复盘要点:这一类故障的根因不是某个 bug,而是“把客户端输入当可信”。接入层要有三条常驻防线:帧长上限、超限断链、读空闲清理。指标上把“异常帧来源 IP 的 TopN”做进告警,扫描流量通常在造成 OOM 之前就在指标里显形。

三、二进制 payload 里的 0x0A:分隔符方案越界 ​

现象:图片审核服务用 LineBasedFrameDecoder,约 0.5% 的图片消息解析成两条残缺帧,且后续同一连接上的所有消息都错位;重新建连后前几条正常。

排查:文本分帧的前提是“分隔符不会出现在 payload 里”。

定位层取证据判定
payload 是否含分隔符对失败消息 hexDump,搜 0a / 0d0aJPEG/PNG 二进制必然含 0x0A
分帧方式选择协议是否声明“文本协议”二进制走换行/分隔符即错配
maxFrameLength 命中是否有 TooLongFrameException 与错位同时出现分隔符提前出现,剩余字节被当成新帧
转义是否落实对端是否真的做了转义文档写了但 SDK 没实现,是常见情况
错位是否自愈同一连接后续消息是否全部失败TCP 流不会自动对齐,必须断链重来

对症做法:

java
// 1. 二进制协议一律用长度字段,不依赖内容里的任何字节
new LengthFieldBasedFrameDecoder(8 << 20, 0, 4, 4, 0);   // Length(4B) + Header(4B) + Body

// 2. 必须沿用分隔符时,把转义做成协议强制项,并在两端各加一条测试
//    规则:payload 内的 0x0A 编码为 0x1B 0x0A,解码端还原

// 3. 分帧错误一律断链,不给“继续读”的机会
if (frame.missingDelimiter()) { ctx.close(); }

// 4. 上线前用 EmbeddedChannel 把真实样本喂进去
@Test void decodesRealJpeg() throws Exception {
    byte[] jpeg = Files.readAllBytes(Path.of("src/test/resources/sample.jpg"));
    EmbeddedChannel ch = new EmbeddedChannel(framer(), new ImageDecoder());
    ch.writeInbound(Unpooled.wrappedBuffer(header(jpeg.length), Unpooled.wrappedBuffer(jpeg)));
    assertThat(ch.<ImageMessage>readInbound().bytes()).hasSize(jpeg.length);
}

复盘要点:分帧方式的选择是协议级决定,写进文档就不能在实现层自由发挥。判据是“用真实样本做分帧测试”:每个 payload 类型(图片、压缩块、protobuf)都要有二进制样本进 EmbeddedChannel,只用 ASCII 样例测不出这类问题。连接错位后必须断链,因为 TCP 流不会自愈,这一点在 Netty 生产踩坑 里有对应的现场处置。

最小上手 ​

用 EmbeddedChannel 验证「半包 + 粘包」,不需要起网络:

java
@Test
void decodesHalfPacketAndStickyPacket() {
    LengthFieldBasedFrameDecoder decoder =
        new LengthFieldBasedFrameDecoder(1024, 4, 4, 0, 8);
    EmbeddedChannel ch = new EmbeddedChannel(decoder);

    ByteBuf first = Unpooled.buffer();
    first.writeInt(0xCAFE0001);   // Magic(2) + Version(1) + Type(1)
    first.writeInt(5);            // Length = Body 长度
    first.writeBytes("hel".getBytes(StandardCharsets.UTF_8));
    assertFalse(ch.writeInbound(first)); // 半包:还差 2 字节,不产出帧

    ByteBuf second = Unpooled.buffer();
    second.writeBytes("lo".getBytes(StandardCharsets.UTF_8));
    assertTrue(ch.writeInbound(second)); // 凑齐一帧

    ByteBuf frame = ch.readInbound();
    assertEquals("hello", frame.toString(StandardCharsets.UTF_8));
    frame.release();
}

把两帧写进同一个 ByteBuf,连续 readInbound() 两次,即可验证粘包。这个测试应该在接入业务前就通过。

生产动作 ​

  • 协议文档写清长度含义:Length 含不含头部、字节序(大端/小端)、最大长度;三处必须一致。
  • 参数核对:上线前用抓包字节手工算一次 frameLength,不要只看单测。
  • 异常处理:TooLongFrameException、CorruptedFrameException 要关闭连接并记录;不要吞掉后继续读,流已经错位。
  • 解码器位置:FrameDecoder 放在最前;业务解码器之后不要再放会改变字节边界的 Handler。
  • 大报文:调大 maxFrameLength 前先评估内存;更好的做法是拆消息或走流式。
  • 空闲检测:半包长期不齐会占住连接;配合 IdleStateHandler 关闭僵死连接。
  • 监控:解码异常数、TooLongFrameException 数、单连接读缓冲大小;异常突增通常是协议版本不匹配或攻击流量。

排障:

  • 业务收到半条消息 → 查 FrameDecoder 参数与协议文档 → 用抓包逐帧核对。
  • 解码异常 → 查 Length 是否含头部、字节序、Magic 校验 → 先确认对端协议版本。
  • 内存上涨 → 查 maxFrameLength 是否过大、是否有连接只发一半就停。

边界 ​

  • 定长协议才用 FixedLengthFrameDecoder;变长消息不要用补位硬凑。
  • 文本协议才用换行/分隔符;二进制内容含分隔符时要转义或换长度字段。
  • UDP 不需要拆包:Datagram 自带边界,但有丢包/乱序问题,不能套用 TCP 的假设。
  • 文件传输不要用 FrameDecoder:文件大小不可控,走 chunked/流式。
  • TLS 不改变分帧逻辑:SslHandler 解密后仍是字节流,FrameDecoder 仍要在解密之后。
  • 不要用 Integer.MAX_VALUE 当上限:等于把 OOM 权力交给客户端。

讲清楚 ​

TCP 是字节流,write 和 read 没有一一对应关系,所以拆包粘包是正常现象。解法是在应用层定义帧边界:定长、换行、分隔符,或最常用的长度字段。长度字段方案的关键是「Length 含不含头部」必须和协议文档、解码器参数三者一致;通用公式是 frameLength = lengthFieldEndOffset + lengthAdjustment + lengthValue。FrameDecoder 放在 Pipeline 最前,Encoder 放在业务 Handler 靠近 Head 的一侧;maxFrameLength 是安全边界,不能设成无限大。验证不要只靠肉眼,用 EmbeddedChannel 构造半包、粘包、连续多帧,确认业务 Handler 收到的是完整消息。

把一门技术讲透到既懂也会用