4. 模拟粘包现象:连续发送小数据包

粘包,说白了就是接收端一次读到了多个本该分开的数据包。我刚开始调网络协议栈时,第一次遇到这现象还以为是网卡坏了——明明发送端分了三次 send,接收端却只收到一大块数据。嗯,今天我们就亲手复现这个场景,看看它到底怎么发生的。

4.1 为什么小数据包会粘在一起?

TCP 是流式协议,它不关心你的应用层边界。你调用 send() 发送的数据,会被 TCP 协议栈放进发送缓冲区,然后按自己的节奏打包成报文段发出去。接收端同样是从缓冲区里读数据,它读到的可能是一次 send 的内容,也可能是多次 send 的合并。

我个人的理解是:TCP 只保证字节顺序和可靠性,不保证消息边界。你想想看,如果发送端连续发了三个 10 字节的小包,而接收端一次 recv 了 30 字节,这就是典型的粘包。

核心原因:
  • Nagle 算法:把小包合并成大包再发送,提高网络利用率
  • 接收端缓冲区积累:应用层来不及读取,数据在缓冲区里堆在一起
  • TCP 报文段大小限制:MSS 限制了单次传输的最大长度,但小包会被合并

4.2 代码演示:发送端连续发送小数据包

我们先写一个发送端,连续发送 5 个小数据包,每个包只有 10 字节。注意,我这里故意不添加任何边界标记,就是为了让你看到粘包的效果。

# sender.py — 连续发送小数据包
import socket
import time

def main():
    host = '127.0.0.1'
    port = 8888

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((host, port))

    messages = [
        b'Hello_0001',
        b'Hello_0002',
        b'Hello_0003',
        b'Hello_0004',
        b'Hello_0005'
    ]

    print('[发送端] 开始发送 5 个小数据包...')
    for i, msg in enumerate(messages, 1):
        sock.send(msg)
        print(f'[发送端] 已发送第 {i} 个包: {msg}')
        time.sleep(0.01)  # 极短间隔,模拟连续发送

    print('[发送端] 发送完毕')
    sock.close()

if __name__ == '__main__':
    main()

这里我用了 10 毫秒的间隔。在实际项目中,如果间隔更短甚至没有间隔,粘包的概率会更高。我曾经在一个 IoT 项目里见过,传感器每 50 毫秒上报一次状态,结果接收端经常一次读到 3-4 条数据,就是因为发送间隔太短了。

4.3 代码演示:接收端观察数据合并

接收端我们故意只做一次 recv,看看能读到多少数据。

# receiver.py — 单次接收,观察粘包
import socket

def main():
    host = '127.0.0.1'
    port = 8888

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.bind((host, port))
    sock.listen(1)
    print('[接收端] 等待连接...')

    conn, addr = sock.accept()
    print(f'[接收端] 客户端已连接: {addr}')

    # 只做一次 recv,看看能读到多少
    data = conn.recv(1024)
    print(f'[接收端] 一次 recv 读到的数据: {data}')
    print(f'[接收端] 数据长度: {len(data)} 字节')

    conn.close()
    sock.close()

if __name__ == '__main__':
    main()

4.4 运行结果与日志分析

先启动接收端,再启动发送端。你猜结果会怎样?

发送端日志:

[发送端] 开始发送 5 个小数据包...
[发送端] 已发送第 1 个包: b'Hello_0001'
[发送端] 已发送第 2 个包: b'Hello_0002'
[发送端] 已发送第 3 个包: b'Hello_0003'
[发送端] 已发送第 4 个包: b'Hello_0004'
[发送端] 已发送第 5 个包: b'Hello_0005'
[发送端] 发送完毕

接收端日志:

[接收端] 等待连接...
[接收端] 客户端已连接: ('127.0.0.1', 54321)
[接收端] 一次 recv 读到的数据: b'Hello_0001Hello_0002Hello_0003Hello_0004Hello_0005'
[接收端] 数据长度: 50 字节

看到了吗?接收端一次 recv 读到了 50 字节,正好是 5 个 10 字节包的合并。这就是粘包!

小技巧: 你可以尝试把发送端的 time.sleep(0.01) 改成 time.sleep(0.5),再运行一次。大概率会看到粘包现象减轻甚至消失。因为间隔足够大时,TCP 协议栈会把每个包独立发送。

4.5 粘包发生的完整流程

为了让你更直观地理解整个过程,我画了一张流程图。它展示了从发送端 send 到接收端 recv 之间,数据是怎么被合并的。

粘包现象流程图 粘包现象:从发送到接收的完整流程 发送端应用层 包1: Hello_0001 包2: Hello_0002 包3: Hello_0003 包4: Hello_0004 包5: Hello_0005 TCP发送缓冲区 包1 包2 包3 包4 包5 被合并为连续字节流 Hello_0001Hello_0002 Hello_0003Hello_0004 Hello_0005 网络传输 一个TCP报文段 (50字节合并发送) TCP接收缓冲区 收到的也是合并后的 50字节连续流 Hello_0001Hello_0002 Hello_0003Hello_0004 Hello_0005 接收端应用层 一次 recv 读到 50字节合并数据

4.6 粘包与拆包的关系

粘包和拆包其实是同一个问题的两面。我简单列个对比表,帮你理清思路。

现象 定义 典型场景 影响
粘包 多个数据包合并成一个 连续发送小包,间隔短 接收端无法区分消息边界
拆包 一个数据包被拆成多次接收 数据包大于 MSS 或缓冲区 接收端需要拼接完整消息
注意: 粘包和拆包不是 bug,而是 TCP 流式传输的固有特性。你不能指望 TCP 帮你维护消息边界。我曾经见过一个团队,上线前才发现服务端解析逻辑完全依赖「一次 recv 就是一个完整消息」,结果压测时直接崩了。

4.7 如何判断是否发生了粘包?

在实际调试中,我一般通过以下几个特征来判断:

  1. 接收端一次 recv 的数据长度大于预期 — 比如你期望每次收到 10 字节,结果收到了 30 字节
  2. 数据内容包含多个消息头 — 如果你有自定义协议头,解析时发现连续出现多个头
  3. 日志时间戳异常 — 接收端两次 recv 的时间间隔远大于发送端两次 send 的间隔

嗯,这里要注意:不要一看到数据合并就认为是粘包。有时候是应用层逻辑写错了,比如 recv 循环没处理好。先确认发送端确实分了多次 send,再往粘包方向排查。

4.8 小结

今天我们亲手复现了粘包现象。核心就一句话:TCP 不保边界,小包连续发,接收端大概率会合并。代码很简单,但背后的原理很重要。你想想看,如果接收端依赖「一次 recv 就是一个完整消息」来写业务逻辑,那粘包就会导致解析错乱。