别让 TCP 粘包搞崩你的长连接服务

上个月帮一个做即时消息的项目排查问题。服务上线一周后,运营反馈有部分用户收不到推送通知。一开始以为是消息队列堆积或者 Redis 挂了,查了一圈都没问题。最后用 Wireshark 抓包,才发现是 Netty 服务端把两条消息拼在一起发出去,客户端解析到一半数据,状态全乱了。

TCP 是流式协议,没有边界。你以为发了两条独立的报文,对方收到的可能是一段连续的字节流。对于 HTTP 这种有 Content-Length 或者 \r\n 分隔的协议,问题还容易发现。但自定义的二进制协议一旦没有处理好边界,后续排查成本极高。

粘包拆包的根因

Netty 自定义协议设计与粘包拆包处理实战

TCP 层会做 Nagle 算法优化,小数据包可能合并发送;接收方也可能一次读到多个报文的数据。这两个行为都合法,都在 RFC 标准里写着。问题出在应用层——你写的业务协议假设"一次 write 对应一次 read",但 TCP 根本不保证这个。

粘包指多条消息黏在一起,拆包指一条消息被切成多次读取。两者本质是一回事:没有明确的消息边界。

常见的解决方案有三类:固定长度消息、特定分隔符、长度字段+变长内容。固定长度最简单,但不灵活,浪费空间;分隔符方式遇到内容里恰好有相同字符就会误判;长度字段方案最通用,也是工业界最常用的做法。

协议设计:TLV 还是 Length-Body?

TLV(Type-Length-Value)适合消息类型多样的场景,每个字段带类型标识,扩展方便,但多消耗字节。如果业务只有几种消息类型,用短整型标记就够,没必要每个字段都带 Type。

更常见的是 Length-Body 结构,或者叫 Framing:协议头固定长度,包含版本号、消息类型、数据长度和校验信息,协议体是可变长的业务数据。

| 魔数(2B) | 版本(1B) | 类型(1B) | 长度(4B) | 校验(2B) | 业务数据(NB) | CRC16(2B) |

魔数是防止误连,版本用于协议迭代,类型区分心跳/登录/业务消息,长度告诉解码器要读多少字节。这样的设计,解析器可以从容地判断"我现在读到多少了,还要读多少"。

有个取舍要注意:长度字段放在头部还是尾部?放头部方便流式解码,读多少字节就知道要补多少;放尾部必须读完整个消息才能知道长度,适合一次性读完的场景。绝大多数情况选头部。

Netty 里的解码器实现

Netty 提供了一套完整的编解码框架,核心思路是让 ByteToMessageDecoder 决定什么时候"认为收到了一条完整消息"。

自定义解码器需要重写 decode 方法。关键在于维护一个读状态机,记录当前读到了协议头的哪个字段,避免重复解析或数据错乱。

public class CustomProtocolDecoder extends ByteToMessageDecoder {
    private static final int HEADER_LENGTH = 10;
    private static final byte[] MAGIC = new byte[]{(byte) 0xAB, (byte) 0xCD};

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        if (in.readableBytes() < HEADER_LENGTH) {
            return;
        }

        int marker = in.indexOf(in.readerIndex(), 
                                 in.readerIndex() + 2, MAGIC[0]);
        if (marker == -1) {
            in.clear();
            return;
        }
        in.readerIndex(marker);
        in.skipBytes(2);

        if (in.readableBytes() < HEADER_LENGTH - 2) {
            in.readerIndex(marker);
            return;
        }

        byte version = in.readByte();
        if (version != 1) {
            throw new IllegalArgumentException("Unsupported version: " + version);
        }

        int msgType = in.readUnsignedByte();
        int length = in.readInt();
        byte checksum = in.readByte();

        if (in.readableBytes() < length) {
            in.readerIndex(marker);
            return;
        }

        byte[] body = new byte[length];
        in.readBytes(body);

        CustomMessage message = new CustomMessage();
        message.setVersion(version);
        message.setType(msgType);
        message.setBody(body);
        out.add(message);
    }
}

这个实现有几个细节值得说。

第一,魔数查找用 indexOf 而不是硬读,因为数据流里可能夹杂脏数据或者半包,找到下一个合法起始位置比直接报错更健壮。

第二,每次返回前检查 readableBytes() 是否足够。不够就 return,让缓冲区留着等下一批数据;够了就解析并产出消息对象。这是 ByteToMessageDecoder 的核心契约——不够就返回,不能抛异常,不能把不完整的消息交出去。

第三,状态回退用 readerIndex 而不是丢弃数据。Netty 的 ByteBuf 内部有引用计数,随意丢弃可能导致内存泄漏。

编码器对应实现

解码器负责拆,编码器负责装。编码器和解码器必须对称,否则必然出错。

public class CustomProtocolEncoder extends MessageToByteEncoder<CustomMessage> {
    @Override
    protected void encode(ChannelHandlerContext ctx, CustomMessage msg, ByteBuf out) {
        out.writeBytes(MAGIC);
        out.writeByte(msg.getVersion());
        out.writeByte(msg.getType());
        
        byte[] body = msg.getBody();
        out.writeInt(body.length);
        out.writeBytes(body);
        
        out.writeShort(crc16(out));
    }
}

编码端有一个容易忽视的点:writeInt 是网络字节序(大端),如果客户端用 Java 写、服务端用 Go 写,必须确认两端字节序一致。建议协议文档里明确写出字节序,或者用 Protobuf 这类自带字节序约定的框架。

线上排查的几个常见坑

坑一:心跳帧和消息帧混在一起解析。 有些项目用心跳帧判断连接存活,但心跳帧和业务帧走同一个解码器。如果心跳帧很短(比如只有魔数+类型),业务帧解码器读到一部分心跳数据后卡住,后面真正的业务消息全乱。解决方案:心跳单独处理,不进业务解码链。

坑二:ByteBuf 引用计数泄漏。 Unpooled 分配的缓冲区需要手动释放,Pooled 的由 Netty 回收。但如果你自定义了 ByteBuf 或者从外部传进来,一定要跟踪引用计数。排查方法:开启 io.netty.leakDetection.level=ADVANCED,启动时就能看到泄漏点。

坑三:粘包问题只在压测时出现。 开发环境流量小,Nagle 算法合并效果不明显,单条消息能完整到达。压测时并发高、缓冲满,粘包概率骤增。所以单元测试只能验证正确路径,粘包问题必须在集成测试和压测环境才能暴露。

坑四:协议头长度字段溢出。 int 最大 2GB,理论上够用。但如果对方发了一个负数或者超大的长度值,你的 new byte[length] 会直接 OOM。解码器里必须校验 length > 0 && length <= MAX_MESSAGE_SIZE。

选型建议

如果消息体积稳定且小(比如小于 512 字节),固定长度方案最简单,维护成本最低。

如果需要兼容多种消息类型且扩展频繁,TLV 结构更合适。

如果场景是大多数情况(不确定消息大小、需要跨语言互通),Length-Body 是最稳妥的选择。

不管选哪种,协议文档必须写清楚每个字段的字节序、取值范围和边界处理规则。代码里的注释可以过期,协议文档是跨团队沟通的唯一依据。

posted @ 2026-11-05 06:00  框架师  阅读(6)  评论(0)    收藏  举报