别让 TCP 粘包搞崩你的长连接服务
上个月帮一个做即时消息的项目排查问题。服务上线一周后,运营反馈有部分用户收不到推送通知。一开始以为是消息队列堆积或者 Redis 挂了,查了一圈都没问题。最后用 Wireshark 抓包,才发现是 Netty 服务端把两条消息拼在一起发出去,客户端解析到一半数据,状态全乱了。
TCP 是流式协议,没有边界。你以为发了两条独立的报文,对方收到的可能是一段连续的字节流。对于 HTTP 这种有 Content-Length 或者 \r\n 分隔的协议,问题还容易发现。但自定义的二进制协议一旦没有处理好边界,后续排查成本极高。
粘包拆包的根因

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

浙公网安备 33010602011771号