TCP 粘包与拆包 · 完全图解

为什么 TCP 会“粘”?UDP 为什么不粘?三种主流解决方案与代码实战

字节流 vs 数据报 Nagle 算法 MSS / MTU 应用层协议设计

①先说清楚:什么是粘包、拆包

根本前提:TCP 是「字节流」协议,没有消息边界。发送端调用 3 次 write,接收端不保证分 3 次 read 到同样的数据。TCP 只保证字节顺序正确、不丢不重,至于哪些字节算“一条消息”,它不关心。

粘包(Sticky)

发送端多次写入的小报文,被合并成一个大包发出 / 被接收端一次读到,多条消息“粘”在一起。

拆包(Split)

一条消息太大,被拆成多个 TCP 段发送,接收端需要多次读取才能拼出完整消息。

发送端写 3 次,接收端读到的可能是各种组合 发送端 write() msg1 msg2 msg3 接收端 read() msg1+msg2+msg3(粘包:一次全收到) 另一种情况 msg1 msg2 msg1 + 半个 msg2 剩下半个 msg2 (拆包:一条消息被切成两次读到) TCP 只保证:字节不丢、不重、顺序正确 TCP 不保证:一次 write 对应一次 read(没有消息边界)

②为什么会粘 / 会拆:四个真实原因

现象成因说明
粘包Nagle 算法为减少小包,TCP 会攒够 MSS 或等到前一个包的 ACK 才发送,把多次小写入合并。
粘包发送缓冲区合并写入速度快于发送速度,多个小消息在发送缓冲区里被拼成一个 TCP 段。
粘包接收端读取不及时接收缓冲区里积压了多条消息,应用层一次 read 把它们全读走。
拆包超过 MSS / MTU消息大于 MSS(通常 1460 字节),必须分段发送。
拆包拥塞窗口限制即使没超 MSS,拥塞窗口也可能让数据分批发出。
Nagle 算法一句话:在收到前一个数据的 ACK 前,最多只允许一个未被确认的小包在网络中。它把连续的零碎小写入攒起来一起发,显著提升网络利用率——但代价就是粘包。实时性要求高的场景可用 TCP_NODELAY 关掉它。
MSS 与消息大小的关系:超过就被拆 应用层消息:3000 字节 TCP 段 1:1460 字节(MSS) TCP 段 2:1460 字节 段 3:80 字节 MTU(1500) − IP头(20) − TCP头(20) = MSS(1460)

③UDP 为什么不粘包

UDP 是「数据报」协议,保留消息边界。发送端发一个数据报,接收端就收到一个完整的数据报——要么收到完整一条,要么整条丢失,不会出现“半个”。
对比项TCP(字节流)UDP(数据报)
消息边界无,需要应用层自己界定有,一次发送 = 一次接收
粘包/拆包会不会
可靠性可靠(重传、排序、流控)不可靠,可能丢包乱序
是否有序有序不保证顺序
但别误会:UDP 不粘包是“天然的”,不是因为它更优秀——代价是丢包、乱序、无流控都要应用层自己扛。用 UDP 做可靠传输(如 QUIC)时,消息边界和可靠性都得自己实现。

④三种主流解决方案(面试标准答案)

核心思路都一样:由应用层协议来定义“消息边界”。TCP 不管,那就你自己管。

方案一:固定长度

每条消息都补齐到固定长度(如 100 字节),接收端每次读固定长度。

优点

实现最简单,解析零成本。

缺点

浪费带宽(短消息要补 0),且不灵活。

方案二:分隔符

每条消息末尾加特殊分隔符(如 \n、\r\n)。接收端按分隔符切分。HTTP 头部、Redis 协议(RESP)都是这个思路。

优点

简单直观,文本协议友好,人类可读可调试。

缺点

消息体内若出现分隔符需转义;还需逐字节扫描寻找分隔符。

方案三:长度字段(最常用、最推荐)

工业界主流:在消息头部放一个定长的长度字段,标明消息体有多少字节。接收端先读固定字节数的头,解析出长度,再按长度读取消息体。
常见形态:4 字节长度 + 消息体(如 Netty 的 LengthFieldBasedFrameDecoder、gRPC、Thrift、Dubbo 都是这类)。
长度字段协议:先读头定长,再按长度读体 长度 4 字节 消息体(payload,长度由头部指定) = 1024 正好 1024 字节 接收端循环: 读 4 字节 → 得到 len → 再读满 len 字节 → 一条完整消息 → 重复
方案边界定义典型应用推荐度
固定长度按固定字节切简单硬件协议低
分隔符按 \n 等切分HTTP 头部、Redis RESP中(文本协议)
长度字段头部声明消息体长度gRPC、Dubbo、Netty、Thrift高(二进制协议首选)

⑤代码实战:长度字段法解决粘包

下面用 Go 演示“4 字节长度 + 消息体”的读写。关键点:读必须用 io.ReadFull,不能用 Read(Read 可能读不满就返回,这正是踩坑点)。

// ============ 发送端:写入 4 字节长度 + 消息体 ============
func writeMsg(w io.Writer, payload []byte) error {
    // 4 字节大端表示长度
    header := make([]byte, 4)
    binary.BigEndian.PutUint32(header, uint32(len(payload)))
    if _, err := w.Write(header); err != nil {
        return err
    }
    _, err := w.Write(payload)   // 写消息体
    return err
}

// ============ 接收端:先读头,再按长度读体 ============
func readMsg(r io.Reader) ([]byte, error) {
    header := make([]byte, 4)
    // 关键:用 ReadFull,保证 4 字节读满才返回
    if _, err := io.ReadFull(r, header); err != nil {
        return nil, err
    }
    length := binary.BigEndian.Uint32(header)

    // 防御:限制最大长度,避免恶意超大包打爆内存
    if length > 10*1024*1024 {
        return nil, errors.New("message too large")
    }

    body := make([]byte, length)
    if _, err := io.ReadFull(r, body); err != nil {
        return nil, err
    }
    return body, nil
}
三个常见坑:
  1. 用 Read 而不是 ReadFull——Read 不保证读满,粘包就没解决。
  2. 没做最大长度限制——对端发一个 4GB 的长度字段,直接 OOM。这是安全问题。
  3. 忘了字节序——收发两端大小端不一致,长度解析出来是天文数字。

⑥面试高频追问

Q1:什么是 TCP 粘包?根本原因是什么?
发送端多次写入的数据被接收端一次读到(或多个消息粘在一起)。根本原因是 TCP 是字节流协议,不保留消息边界,加上 Nagle 算法、缓冲区合并、MSS 分段等机制共同导致。
Q2:粘包是 TCP 的“bug”吗?
不是 bug,是设计特性。TCP 的职责只是可靠、有序地传输字节流,“一条消息有多长”属于应用层语义。所以解决粘包是应用层的责任。
Q3:UDP 会不会粘包?
不会。UDP 是数据报协议,保留消息边界,一次 send 对应一次 recv。但代价是不保证可靠、有序、不丢包。
Q4:有哪些解决粘包的方法?你推荐哪个?
固定长度、分隔符、长度字段三种。推荐长度字段法(头部声明消息体长度),因为它无转义问题、无需扫描、效率高,gRPC/Dubbo/Netty 都采用;文本协议(如 HTTP、Redis)用分隔符更直观。
Q5:关掉 Nagle(TCP_NODELAY)能解决粘包吗?
不能完全解决。TCP_NODELAY 只是禁止发送端攒包,但接收端缓冲区仍可能积压多条消息被一次读走,超过 MSS 仍会被拆。真正解决只能靠应用层定义边界。
Q6:粘包和拆包需要同时处理吗?
需要。同一套“长度字段 + 循环读取”逻辑天然同时解决两者:粘包时按长度切分出多条,拆包时靠 ReadFull 读满才返回。
Q7:HTTP 是怎么解决“边界”问题的?
HTTP/1.1 用 Content-Length 头部(长度字段法)或 Transfer-Encoding: chunked(分块 + 长度前缀)来界定消息体边界;HTTP/2 则用二进制分帧,每帧自带长度字段。

✓一句话速记

TCP 是字节流、无消息边界 → 粘包拆包是必然;解决靠应用层自己定义边界 → 工业界首选「定长头部声明长度 + 消息体」,读取时用 ReadFull 保证读满,并限制最大长度防攻击。