TCP 的”字节流”指它只保证字节可靠、有序到达,不保留任何一次写入的边界;UDP 的”面向报文”指应用交下来的每一份数据都作为独立数据报收发,边界天然存在。所谓粘包、拆包,不是 TCP 的缺陷,而是应用层没有自己定义消息边界的必然结果。

问题的本质:边界由谁负责

设想应用连续发出两条消息 hello 和 world。

走 UDP 时,两次发送对应两个数据报,接收端每调用一次 recvfrom() 就取走一个完整报文,不会读到 helloworld,也不会读到半个报文——UDP 接收队列里的一个元素就是一个数据报。

走 TCP 时,两次 send() 只是把字节拷进内核发送缓冲区。这些字节何时发出、合并成几个报文段、对端一次 recv() 能拿到多少,由 MSS、滑动窗口、拥塞控制和 Nagle 算法等共同决定,和应用的写入次数没有对应关系。接收端可能一次读到 helloworld(粘包),也可能分三次读到 hel、lowor、ld(拆包)。

应用视角:  [hello] [world]          两条消息
TCP 视角: h e l l o w o r l d 一串字节,无边界
UDP 视角: [hello] [world] 两个独立数据报

所以严格说 TCP 世界里根本没有”包”,只有流。粘包是应用层观察到的现象,病根在于:消息边界这件事,TCP 不管,只能应用自己管。另外注意,UDP 保留边界不代表可以随意发大包——报文过大仍会在 IP 层分片,接收缓冲区太小还会截断,且 UDP 不保证可靠与顺序。

应用层如何自己定界

思路只有一个:让接收方能判断一条消息到哪里结束。常见三种方案。

固定长度

约定每条消息都是 N 字节,读满 N 字节即一条。实现最简单,但短消息要填充、长消息要拆分,只适合格式高度固定的场景。

分隔符

消息之间插入特殊标记,如 \n、\r\n。接收方扫描缓冲区,遇到分隔符就切出一条。文本协议常用此法,但要处理两个细节:消息体本身含分隔符时需转义;分隔符可能被拆到两次读取之间,解析逻辑不能假设一次 recv() 拿到完整分隔符。

长度头(工程主流)

头部固定放长度字段,标明后续包体字节数:

| 魔数 | 版本 | 类型 | 4 字节长度 | 消息体 ... |

接收方先读定长头部,解析出包体长度,再读满指定字节;不足则继续等,多读的字节留在缓冲区作为下一条消息的开头。Netty、Dubbo、各类 RPC 与 IM 协议基本都走这条路。落地时有几个硬要求:长度字段统一用网络字节序;必须限制最大包体长度,否则对端传一个超大长度值就能撑爆内存;要正确处理半包缓存与连接中途关闭。

实践要点

  • 只要底层是 TCP,协议编解码就是必选项,不是可选项。自定义协议优先选长度头方案。
  • send() 返回成功仅表示数据进了内核缓冲区,不代表已发出,更不代表对端按同样粒度接收。PSH 标志、抓包看到的报文段,都不是可靠的消息边界依据。
  • Nagle 算法(发送端攒小包)与 Delayed ACK(接收端延迟确认)叠加时,连续小写入后立刻等响应的模式会被放大延迟。延迟敏感场景可在应用层合并小写入,或评估开启 TCP_NODELAY(Netty 中对应 ChannelOption.TCP_NODELAY),改动前先抓包确认。
  • UDP 适合能容忍少量丢包、追求低延迟或需要广播多播的场景:DNS 查询、音视频实时流、QUIC。TCP 适合要求可靠有序传输的场景:HTTP、RPC、文件传输。

小结

  • TCP 面向字节流:只保证可靠、有序,不保留写入边界,粘包/拆包由此产生。
  • UDP 面向报文:按数据报收发、保留边界,但不保证可靠与顺序,过大报文还会被 IP 分片。
  • 解粘包的本质是应用层自定边界:固定长度、分隔符、长度头三选一,工程上以长度头为主,需配套字节序约定、最大长度限制和半包处理。