TCP拆包与粘包:从字节流特性到应用层协议设计

TCP拆包与粘包:从字节流特性到应用层协议设计

“TCP is a stream-oriented protocol, not a message-oriented protocol.”

在实现 RPC 协议或处理网络通信时,我们常听到的术语就是“拆包”(Packet Splitting)和“粘包”(Packet Aggregation)。这两个现象往往让初学者困惑:明明我发送的是两条独立的消息,为什么接收端收到的是一坨混乱的数据?或者为什么我发了一个大包,对面却分了几次才收全?

本文将探究这背后的底层机制,并演示应用层协议如何设计来屏蔽这些底层细节。

背景知识:MTU 与 MSS #

要理解拆包和粘包,首先需要理解网络传输中的两个关键限制:MTUMSS

MTU(Maximum Transmission Unit) #

The maximum transmission unit (MTU) is the size of the largest protocol data unit (PDU) that can be communicated in a single network layer transaction. —— from Wiki

简单来说,MTU 是数据链路层(如以太网)对上层(通常是 IP 层)载荷的最大限制。 一般来说,以太网的 MTU = 1500 bytes

如果 (IP Header + Transport Header + Data) > MTU,那么 IP 层就必须进行分片(Fragmentation)。

MSS(Maximum Segment Size) #

The maximum segment size (MSS) is a parameter of the options field of the TCP header that specifies the largest amount of data, specified in bytes, that a computer or communications device can receive in a single TCP segment. —— from Wiki

MSS 是 TCP 层 为了避免 IP 层分片而计算出的最大载荷大小。它代表了 TCP 报文中 Payload 的最大值。 MSS = MTU - IP Header Length - TCP Header Length

现象解析 #

为什么会出现“拆包”? #

拆包指的是应用层作为一个整体发送的数据,在底层被拆分成了多个 TCP 段发送。

最直接的原因就是数据量过大。如果应用层一次写入的数据字节数超过了 MSS,TCP 协议栈就必须将其拆分为多个段(Segment)进行传输。

为什么会出现“粘包”? #

粘包指的是发送端多次发送的小段数据,在接收端被合并成了一个大的数据块读取到。

原因主要有二:

  1. Nagle 算法:为了改善网络传输效率,TCP 默认启用了 Nagle 算法。简单来说,如果你连续发送很小的数据块,TCP 不会立即发送,而是会在本地缓冲区积攒一下,等攒够了或者收到上一个包的 ACK 后,再一次性打包发出去。
  2. 接收端缓冲:TCP 接收端拥有自己的缓冲区。如果应用层读取不及时,后续的 TCP 段也会被堆积在这个缓冲区中。当应用层去读取时,一次性就读到了多个逻辑上独立的数据包。

核心本质 #

无论是拆包还是粘包,其本质归结为一句话:

TCP 是面向字节流的协议,没有“消息边界”的概念。

TCP 只负责把一串字节流无差错地传送到对面,它不理解“这 100 个字节是一个请求,后面 50 个字节是另一个请求”。所谓的“包”和“界限”,完全是应用层为了便于处理业务逻辑而强加上去的语义。

解决方案 #

既然 TCP 不管边界,那应用层就必须自己来划清界限。常见的解决方案有两种:

1. 特殊分隔符(如 HTTP) #

对于文本协议,最常见的是使用特殊字符作为边界。 例如 HTTP/1.1,使用 \r\n\r\n (CRLF CRLF) 来区分 Header 和 Body,或者区分连续的请求。

2. Length-Prefixed 协议(二进制协议) #

对于二进制协议,最通用的做法是在消息头部固定位置声明消息体的长度(Length-Prefixed)。

接收端的逻辑变得非常严谨:

  1. 先读取固定长度的头部(Header)。
  2. 解析头部,获取消息体长度(Body Length)。
  3. 根据 Body Length,精确读取指定字节数的 Body。

协议设计示例(Golang) #

Talk is cheap, show me your Code. 下面我们通过 Golang 代码来展示一个完整的二进制协议实现。

1. 协议常量与结构体定义

首先,我们需要定义协议的常量(如版本号、操作码)以及头部长度的计算规则。

package proto

import (
	"bufio"
	"encoding/binary"
	"errors"
)

const (
	// OpRequest Operation Request
	OpRequest uint16 = iota + 1
	// OpResponse Operation Response
	OpResponse
)

const (
	// Ver1 Version 1
	Ver1 uint16 = 1
)

var (
	// ErrProtoHeaderLen .
	ErrProtoHeaderLen = errors.New("not matched proto header len")
	// ErrEmptyReader .
	ErrEmptyReader = errors.New("empty reader")
)

const (
	// Protocol Header Size Definition
	_packSize      uint16 = 4
	_headerSize    uint16 = 2
	_verSize       uint16 = 2
	_opSize        uint16 = 2
	_seqSize       uint16 = 2
	_rawHeaderSize uint16 = _packSize + _headerSize + _verSize + _opSize + _seqSize

	// Offsets
	_packOffset   uint16 = 0
	_headerOffset        = _packOffset + _packSize
	_verOffset           = _headerOffset + _headerSize
	_opOffset            = _verOffset + _verSize
	_seqOffset           = _opOffset + _opSize
)

// Proto 定义了协议的数据结构
type Proto struct {
	Ver  uint16
	Op   uint16 // Type of Proto
	Seq  uint16 // Seq of message
	Body []byte // Body of Proto
}

2. 发送消息(WriteTCP)

发送端的逻辑比较简单:计算整个包的长度,将 Header 各字段按大端序(BigEndian)写入 Buffer,最后追加 Body 并写入 TCP 连接。

// WriteTCP 将 Proto 对象序列化并写入 buffer
func (p *Proto) WriteTCP(wr *bufio.Writer) (err error) {
	var (
		buf     = make([]byte, _rawHeaderSize)
		packLen int
	)

	// 计算包总长 = 头长 + Body长
	packLen = int(_rawHeaderSize) + len(p.Body)
    
	// 依次写入各个字段 (BigEndian)
	binary.BigEndian.PutUint32(buf[_packOffset:], uint32(packLen))
	binary.BigEndian.PutUint16(buf[_headerOffset:], _rawHeaderSize)
	binary.BigEndian.PutUint16(buf[_verOffset:], p.Ver)
	binary.BigEndian.PutUint16(buf[_opOffset:], p.Op)
	binary.BigEndian.PutUint16(buf[_seqOffset:], p.Seq)

	// 写 Header
	if _, err = wr.Write(buf); err != nil {
		return
	}

	// 写 Body
	if p.Body != nil {
		_, err = wr.Write(p.Body)
	}

	return
}

3. 接收消息(ReadTCP)

接收端是解决“拆包粘包”的主战场。我们需要严格按照 ReadNBytes 的语义,先读满头部,解析出 Body 长度,再读满 Body。

// ReadTCP 从 buffer 中读取并反序列化出 Proto 对象
func (p *Proto) ReadTCP(rr *bufio.Reader) (err error) {
	var (
		bodyLen   int
		headerLen uint16
		packLen   int
		buf       []byte
	)

	// 1. 严格读取头部固定长度的字节
	if buf, err = ReadNBytes(rr, int(_rawHeaderSize)); err != nil {
		return
	}

	// 2. 解析 Header,获取包总长
	packLen = int(binary.BigEndian.Uint32(buf[_packOffset:_headerOffset]))
	headerLen = binary.BigEndian.Uint16(buf[_headerOffset:_verOffset])
	p.Ver = binary.BigEndian.Uint16(buf[_verOffset:_opOffset])
	p.Op = binary.BigEndian.Uint16(buf[_opOffset:_seqOffset])
	p.Seq = binary.BigEndian.Uint16(buf[_seqOffset:])

	if headerLen != _rawHeaderSize {
		return ErrProtoHeaderLen
	}

	// 3. 根据包总长和头长,计算 Body 长度
	if bodyLen = packLen - int(headerLen); bodyLen > 0 {
		// 4. 严格读取 Body 长度的字节
		p.Body, err = ReadNBytes(rr, bodyLen)
	} else {
		p.Body = nil
	}

	return
}

// ReadNBytes 保证从 reader 中读取 n 个字节,解决拆包问题
func ReadNBytes(rr *bufio.Reader, N int) ([]byte, error) {
	if rr == nil {
		return nil, ErrEmptyReader
	}

	var (
		buf = make([]byte, N)
		err error
	)
	for i := 0; i < N; i++ {
        // ReadByte 会阻塞直到读到一个字节
		if buf[i], err = rr.ReadByte(); err != nil {
			return nil, err
		}
	}

	return buf, err
}

4. 实际使用

在业务代码中,我们就可以非常轻松地处理连接了,完全不用担心底层的粘包问题。

func (s *Server) handleConn(conn net.Conn) {
	rr := bufio.NewReader(conn)
	wr := bufio.NewWriter(conn)
	
    var (
		precv = proto.New()
		psend = proto.New()
	)

	// 此时 ReadTCP 会自动处理好边界,返回一个完整的 Request
	if err := precv.ReadTCP(rr); err != nil {
		// 处理错误...
		return
    }

    // 业务处理...
    
    // 发送响应
	psend.Body = []byte("response data...") 
	psend.WriteTCP(wr) 
	wr.Flush() // 记得 Flush
}

总结 #

TCP 的拆包与粘包现象,本质上是“流式传输”与“消息式处理”之间的语义鸿沟。

  • TCP 关注网络传输的效率与可靠性(流)。
  • 应用层 关注业务逻辑的完整性与独立性(消息)。

通过在应用层协议中显式定义“长度”字段,我们能够在流之上重建出可靠的消息边界,这是所有基于 RPC 的中间件(如 Dubbo、gRPC、Thrift 等)底层通信模块的共同基石。

访问量 访客数