Files
cs-note/hhs/NETWORK/04-传输层/05-TCP粘包与拆包.md
T
2026-05-24 11:42:38 +08:00

7.2 KiB
Raw Blame History

tags, create time
tags create time
计算机网络
TCP粘包
TCP拆包
应用层协议
2026-05-18 02:10

TCP 粘包与拆包

概述

TCP 是字节流协议(Byte Stream Protocol),没有"消息边界"的概念。这导致发送方多次 write 可能合并成一个段发出(粘包),也可能一个 write 被分成多个段到达(拆包)。这不是 Bug,这是 TCP 的设计特性——需要在应用层解决。

[!QUESTION] 为什么 HTTP/gRPC 不需要处理粘包? HTTP/1.1 用 Content-Length 或 Chunked Transfer Encoding 定义边界;gRPC/Protobuf 每个消息带长度前缀;WebSocket 有 Frame Header。它们都在 TCP 之上抽象了一层消息协议,隐式解决了这个问题。

为什么会产生粘包和拆包

粘包的成因

发送端操作:
write("HELLO")      → 进入发送缓冲区
write("WORLD")      → Nagle 算法延迟合并
read()/close()      → 触发 Fin

实际网络上的 TCP 段: "HELLOWORLD" (一次性发出)

触发条件:

条件 说明
Nagle 算法 小数据合并后统一发送以减少小包数量
接收端读取慢 发送端的多个 write 堆积在发送缓冲区
SendBuffer 累积 多个连续 write 都未触发 flush

拆包的成因

发送端操作:
write("A very long message that exceeds MTU...")  // 超过 MSS(1460B)

实际网络上的 TCP 段:
Segment 1: [Bytes 0..1459]    ← MSS = 1500 - 20(IP) - 20(TCP) = 1460
Segment 2: [Bytes 1460..end]  ← 剩余部分

接收端 read(1024):            ← 只读了前一部分
返回: "A very long m..."     ← 不完整!

触发条件:

条件 说明
数据包 > MSS 超过路径 MTU 自动分片
接收端 read 大小太小 一次只读了一部分
网络抖动 / RTT 变化 数据到达时间不均匀

四种解决策略

方法一:固定长度

每条消息固定 N 字节,接收端按 N 字节切割:

const msgLen = 1024  // 每条消息 1KB

func ReadFixedLength(conn net.Conn) ([]byte, error) {
    buf := make([]byte, msgLen)
    n, err := io.ReadFull(conn, buf)
    if err != nil {
        return nil, err
    }
    return buf[:n], nil
}
优点 缺点
实现简单 短消息浪费带宽,长消息不够用
性能最高 —

适用场景:游戏协议、内部 RPC(如 Dubbo 早期版本)

方法二:特殊分隔符

用特定字符(如 \r\n\r\n、\0)作为消息结束标志:

func ReadDelimited(conn net.Conn) ([]byte, error) {
    var buf bytes.Buffer
    for {
        b := make([]byte, 1)
        _, err := conn.Read(b)
        if err != nil {
            return nil, err
        }
        buf.Write(b)
        if bytes.HasSuffix(buf.Bytes(), []byte("\r\n\r\n")) {
            return buf.Bytes()[:buf.Len()-4], nil
        }
    }
}
优点 缺点
简单直观 消息体中不能包含分隔符
类似 HTTP 协议 —

适用场景:HTTP/1.1、SMTP、IMAP

方法三:长度前缀 ⭐(推荐)

在消息开头加上表示长度的字段,最常见的有两种变体:

方案 A:4 字节整数 + 消息体

// 最常用格式: [4-byte length][payload...]
// Go encoding/binary 处理
func WriteWithLength(conn net.Conn, data []byte) error {
    length := uint32(len(data))
    binary.Write(conn, binary.BigEndian, length) // 大端序保证跨语言兼容
    conn.Write(data)
    return nil
}

func ReadWithLength(conn net.Conn) ([]byte, error) {
    var length uint32
    binary.Read(conn, binary.BigEndian, &length)
    
    buf := make([]byte, length)
    if length > 0 {
        _, err := io.ReadFull(conn, buf)
        if err != nil {
            return nil, err
        }
    }
    return buf, nil
}
内存布局:
┌─────────────┬──────────────────────────┐
│ Length(uint32) │       Payload           │
│   4 bytes     │  variable bytes          │
│   100         │  [actual data of 100B]   │
└─────────────┴──────────────────────────┘

方案 B:protobuf 风格(L-Type-V)

┌────────┬───────┬──────────────────────────┐
│  Length │ Type  │        Value             │
│ VarInt  │ VarInt │    variable bytes        │
│ (1-10B) │(1-10B) │                        │
└────────┴───────┴──────────────────────────┘
优点 缺点
✅ 最灵活,支持变长消息 ❌ 比固定长度略复杂
✅ gRPC/Protobuf/Hessian2 都用此方式 ❌ 需要处理超大消息
✅ 跨语言天然兼容 —
✅ 无分隔符冲突问题 —

方法四:两者结合(工业级)

// 工业级协议帧结构
// ┌──────┬──────┬──────┬──────────┬──────┬──────┐
│ Magic  │Ver   │Type  │RequestID │Length│Payload │CRC  │
│ 4B     │1B    │1B    │  4B       │4B    │Var    │4B    │
│ 0xABCD│ 1.0  │REQ   │  0x0001   │1024  │data...│ CRC32│
│ EF header              │                    │ Footer  │
└──────┴──────┴──────┴──────────┴──────┴────────┘
总开销: 4+1+1+4+4+4 = 18 bytes overhead
type FrameHeader struct {
    Magic     uint32   // 魔数,防解析错误
    Version   byte     // 协议版本
    MessageType byte   // 请求/响应/心跳等
    RequestID uint32   // 关联请求响应
    Length    uint32   // Payload 长度
    Checksum  uint32   // CRC32 校验
}

各语言的内置支持

语言/框架 粘包处理 说明
Go stdlib net ❌ 零拷贝,完全手动 但 bufio.Scanner 可帮你
Go encoding/binary ✅ Binary 编解码 配合 length-prefix
Java Netty ✅ 内建 DelimiterBasedFrameDecoder、LengthFieldBasedFrameDecoder
Python asyncio ❌ 需手动 或用 asyncio.Protocol
C++ Boost.Asio ❌ 需手动 或用自定义解包器
Rust tokio ❌ 需手动 tokio_util::codec crate 支持

Nagle 算法的影响

Nagle 算法的初衷是减少小包的发送数量——它规定:如果有一个未被确认的小包,就不要发送新的小包。

// Go net.TCPConn 中关闭 Nagle
conn.(*net.TCPConn).SetNoDelay(true)  // true = 禁用 Nagle
场景 建议
HTTP/Web API 关闭 (减少首包延迟)
实时游戏/金融 关闭 (每毫秒都很重要)
大数据传输 开启 (减少小包开销)
SSH 关闭 (交互性要求高)

关联笔记