Skip to content
Go back

TCP粘包——三种应用层解决方案与Netty的实现

TCP 粘包:字节流没有消息边界

一句话结论(30s)

TCP 粘包/拆包的根本原因是 TCP 是字节流协议,只保证字节顺序和完整,不保留应用层的消息边界。解法是在应用层自己划分边界,三种方案:固定长度(每条消息定长 N 字节)、特殊分隔符(用 \r\n 等标记结尾)、消息头+长度字段(先读定长头拿到长度再精确读消息体,最通用,Netty 的 LengthFieldBasedFrameDecoder 就是这套思路)。

核心原理(2min)

底层深入(5-10min)

根因

TCP 是字节流协议——发送方调两次 write(buf1, 100B)write(buf2, 200B),接收方可能一次 read() 读出 300B(粘在一起),也可能三次 read() 分别拿 50B、100B、150B(拆散)。TCP 只保证字节的顺序和完整性,不保留”应用层的消息边界”。

为什么 TCP 偏偏是”字节流”,而不是”消息流”? 这是分层设计的必然结果。TCP 工作在传输层,它根本不知道、也不关心上层发的是 JSON、Protobuf 还是自定义二进制,它唯一的承诺是”把这些字节可靠、有序地从 A 搬到 B”。如果硬把”消息边界”塞进 TCP,就意味着每种应用都要和内核重新协商格式,违背了”分层 + 通用”的初衷。所以边界这件事被刻意”甩锅”给了应用层——这既是粘包问题的来源,也决定了它只能由应用层来解决

反过来想:如果 TCP 是消息流,会失去什么? 你会失去流式传输的灵活性——视频、文件、HTTPS 的加密流都是连绵不断的字节,根本没有”一条消息”的概念。字节流是 TCP 能同时服务这么多上层协议的基石,粘包只是这个基石的”使用成本”。

内核视角:内核是怎么把字节流塞进接收队列的

粘包不是 bug,而是内核”只认字节、不认消息”的直接体现。看 Linux 接收路径 tcp_data_queue()net/ipv4/tcp_input.c):

static void tcp_data_queue(struct sock *sk, struct sk_buff *skb)
{
	struct tcp_sock *tp = tcp_sk(sk);
	...
	/*  Queue data for delivery to the user.
	 *  Packets in sequence go to the receive queue.
	 *  Out of sequence packets to the out_of_order_queue.
	 */
	if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt) {
		if (tcp_receive_window(tp) == 0) {
			...
		}

		/* Ok. In sequence. In window. */
queue_and_out:
		if (tcp_try_rmem_schedule(sk, skb, skb->truesize)) {
			...
		}

		eaten = tcp_queue_rcv(sk, skb, &fragstolen);
		if (skb->len)
			tcp_event_data_recv(sk, skb);
		if (TCP_SKB_CB(skb)->tcp_flags & TCPHDR_FIN)
			tcp_fin(sk);
		...
	}
}

注意这段代码干了什么:它只按序列号判断数据是否”按序到达”(seq == rcv_nxt),按序就调用 tcp_queue_rcv() 把 skb 挂到 sk_receive_queue 接收队列尾部,乱序则先丢进 out-of-order 队列等重排。整个过程从头到尾没有”消息”这个概念——内核眼里只有”一段连续字节流”,read() 返回的只是当前队列里攒了多少字节,而不是”几条消息”。这正是粘包/拆包的根源:两次 write 的字节可能被内核合到一个 skb 里一次交付(粘),一次 write 的字节也可能被拆成多个 skb 分次交付(拆)。

方案一:固定长度

所有消息固定为 N 字节,不足部分填充:

消息体[100B] + 填充[0x00...] = 总长 256B
接收端每次读 256B → 这就是一条完整消息

简单,但浪费(短消息也要填充到 N 字节)。适用消息长度已知且固定的场景。

设问:固定长度方案里,接收端”每次读 256B”真的安全吗? 不一定。TCP 是字节流,read() 不一定一次就返回 256B——可能先返回 100B,再返回 156B。所以”固定长度”方案也必须有一个累积缓冲,攒够 256B 才切成一条消息,不能假设 read 一次给满。这是三种方案共同的隐藏前提:任何方案都不能假设一次 read = 一条消息

方案二:特殊分隔符

在每个消息末尾加上特殊分隔符(如 \r\n),接收方一直读到分隔符:

消息1\r\n消息2\r\n
接收端读到 \r\n → 一条消息结束

HTTP 的 \r\n 分隔、Redis 的 RESP 协议 \r\n 都是这个思路。问题:消息体中不能出现分隔符(需转义)。

设问:分隔符方案最大的坑在哪? 在于”消息体里出现了分隔符”。文本协议(HTTP 请求行、RESP 的 bulk string)靠”约定 + 转义”规避;二进制数据里 0x0D 0x0A 随机出现,纯分隔符就不可靠。所以对二进制协议,分隔符方案往往退化成一个特例——RESP 的 bulk string 其实是在分隔符前再带一个长度字段,本质已经是”方案三”了。

方案三:消息头 + 长度字段(最通用)

[魔数 2B][长度 4B][消息体 NB]

先读 6B 消息头 → 知道消息体长度 → 再精确读取消息体。

Protobuf、自定义二进制协议、Netty 推荐方案都用这个模式。

💭 思考:为什么“消息头+长度字段”能成为最通用的方案?——它把“这条消息有多长”这个关键信息,用固定字节数提前声明出来。接收方先读定长头、拿到 body 长度,就能精确知道“还要攒多少字节才是一条完整消息”,既不靠猜、也不靠碰分隔符。前两种方案一个假设长度已知、一个假设分隔符不冲突,都只在特定场景成立;而“长度字段”不依赖消息内容,任何二进制/文本都能用,所以成了自定义协议的默认选择。

Netty 的 LengthFieldBasedFrameDecoder

new LengthFieldBasedFrameDecoder(
    1024 * 1024,  // maxFrameLength: 最大帧长
    4,             // lengthFieldOffset: 长度字段在消息头中的偏移
    4,             // lengthFieldLength: 长度字段自身占用字节
    0,             // lengthAdjustment: 长度与消息体的修正值
    0              // initialBytesToStrip: 跳过多少字节头
);

一条 Netty 配置解决整个自定义协议的粘包/拆包——不需要手动写缓冲池、半包缓存、超时逻辑。Netty 内部维护一个缓冲区,自动等待数据到达完整再交付下游 Handler。

💭 思考:Netty 一个 Decoder 就解决了粘包,它到底替你做了什么?——就是前面反复强调的那件事:维护一个“累积缓冲区”,把每次 read 到的字节先攒起来,等凑够一条完整帧(先读够长度头、再读够 body)才交给下游。你不需要自己写“半包缓存 + 超时 + 拼包”这套脏活累活,只需声明“长度字段在哪、多长、怎么修”几个参数。这也印证了:粘包没有黑魔法,解法永远是“攒够一条再交付”。

总结

方案优点缺点适用
固定长度极简单浪费固定大小消息
分隔符可读性好需转义HTTP/Redis RESP
消息头+长度灵活通用实现复杂自定义二进制协议

章末提问

1. “TCP 粘包是什么?为什么 UDP 没有粘包?”

回答思路:粘包本质是”字节流无消息边界”,不是 TCP 丢数据或出错。UDP 是数据报协议(SOCK_DGRAM),每个报文独立成包,recvfrom 一次收到一个完整数据报,天然有边界;TCP 是流(SOCK_STREAM),内核只按序攒字节,read 拿到的是”当前有多少字节”而非”几条消息”。一句话:粘包不是缺陷,是 TCP 面向字节流 + UDP 面向报文的模型差异

2. “长度字段方案里,如果连长度字段本身都被拆包了(只收到半个长度字段)怎么办?”

回答思路:这就是”半包”问题,接收端必须维护累积缓冲。不能假设一次读能拿到完整 4 字节长度——要先读够定长头(可能跨多次 read),拿到 body 长度后再继续攒,直到凑够完整消息才解码。Netty 的 ByteToMessageDecoder 内部就是靠 cumulation 累积缓冲解决这个,LengthFieldBasedFrameDecoder 是它的一个现成子类。

3. “LengthFieldBasedFrameDecoder 的 lengthAdjustment 和 initialBytesToStrip 分别是什么含义?举个例子。”

回答思路:lengthFieldLength=4 表示长度字段占 4 字节;lengthFieldOffset 是长度字段在帧头的偏移;lengthAdjustment 是”长度字段的值”与”实际消息体长度”的修正量——例如协议里长度字段算的是从长度字段之后到帧尾的总长(包含其他头),就要用 lengthAdjustment 扣掉那部分;initialBytesToStrip 是解码后跳过多少字节头不传给下游,比如只关心 body 就把魔数+长度这 6 字节头剥掉。

4. “固定长度方案消息超长、分隔符方案消息体里出现 \r\n,分别怎么解决?”

回答思路:固定长度方案超长 → 要么调大 N(牺牲小消息的带宽),要么拆分成”分片 + 重组”,复杂度陡增;分隔符出现冲突 → 转义(如 \\r\\n 表示字面量)或”分隔符 + 长度字段”混合(Redis bulk string 就是这个思路)。两者都说明:当消息长度不可预知时,最终都会收敛到方案三

5. “关闭 Nagle 算法(TCP_NODELAY)能不能消除粘包?”

回答思路:不能。Nagle 只影响发送时机——它把小包合并延迟发送,会让”粘”看起来更明显,但关掉它只是改变字节到达的时序,并没有改变”字节流无边界”的本质。粘包/拆包由 TCP 模型决定,只能靠应用层协议划界解决,任何发送端的 socket 选项都治不了本。


Share this post on:

Previous Post
为什么四次挥手中间两次不能合并
Next Post
TCP拥塞控制——从慢启动到BBR的演进