3. TCP粘包与拆包问题剖析

这一章我们真正开始动手写代码了。TCP Client 端,说白了就是主动发起连接的那一方。你想想看,生活中你打电话,拨号的那个人就是 Client,接电话的就是 Server。今天我们就来把这个「拨号、通话、挂断」的流程,用 Python 代码完整走一遍。

我个人习惯把 TCP Client 的生命周期分成四个阶段:创建 → 连接 → 通信 → 关闭。每个阶段都有坑,我当年刚入行时几乎全踩过一遍。嗯,咱们一个一个来拆解。

核心要点:TCP Client 的四个步骤缺一不可,顺序也不能乱。尤其是关闭阶段,很多人直接调 close() 就走人了,结果丢数据、端口被占用的教训太多了。

3.1 创建 Socket:你的网络通信句柄

创建 Socket 其实就一行代码,但这一行决定了你用什么协议族、什么传输类型。我见过不少新手直接用默认参数,结果在 IPv6 环境下跑不通,排查了半天。

import socket

# 创建一个 IPv4 的 TCP Socket
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

这里两个参数的含义:

参数 可选值 说明
AF_INET AF_INET6, AF_UNIX IPv4 地址族,最常用
SOCK_STREAM SOCK_DGRAM, SOCK_RAW 面向连接的 TCP 流式套接字
我的习惯:创建 Socket 后立刻设置 SO_REUSEADDR 选项。虽然 Client 端不像 Server 那样必须设,但我在项目中遇到过 Client 快速重连时端口被占用的诡异问题,加上这个选项能省很多麻烦。
client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

3.2 连接服务器:三次握手的发起者

创建完 Socket,下一步就是连接服务器。这一步背后是 TCP 的三次握手,但对应用层来说,你只需要提供一个 IP 和端口号。

server_address = ('192.168.1.100', 8888)
client_socket.connect(server_address)

connect() 是一个阻塞调用。什么意思?就是你的程序会停在这里,直到三次握手完成或者超时失败。我曾经在生产环境遇到过一个问题:服务器负载高,握手耗时超过默认超时时间,Client 直接抛异常退出了。

避坑指南:永远不要用默认超时!我曾经在一个物联网项目里,Client 连接云端服务器,网络偶尔抖动导致 connect 卡住 120 秒。后来我强制设置了超时时间:
client_socket.settimeout(5.0)  # 5 秒超时
try:
    client_socket.connect(server_address)
except socket.timeout:
    print("连接超时,请检查网络或服务器状态")

为什么是 5 秒?我个人经验是:局域网内 1-2 秒足够,公网环境 3-5 秒比较合理。太短容易误判,太长影响用户体验。

3.3 收发数据:核心通信逻辑

连接建立后,就到了真正的数据交换阶段。TCP 是流式协议,没有消息边界。这一点非常重要,我当年在这个坑里摔得鼻青脸肿。

3.3.1 发送数据

message = "Hello, Server!"
client_socket.send(message.encode('utf-8'))

send() 方法返回实际发送的字节数。你可能会想:「我发了 100 字节,它应该全发出去吧?」不一定。TCP 协议栈可能只发送了部分数据,剩下的需要你手动重发。这就是所谓的「部分发送」问题。

def send_all(sock, data):
    total_sent = 0
    while total_sent < len(data):
        sent = sock.send(data[total_sent:])
        if sent == 0:
            raise RuntimeError("Socket 连接断开")
        total_sent += sent
我的做法:直接用 sendall() 方法,它在内部帮我们处理了循环发送的逻辑。但要注意,sendall() 在出错时也会抛异常,需要捕获处理。

3.3.2 接收数据

接收比发送更复杂。因为 TCP 是流,你调用 recv() 时,可能收到半个消息、一个完整消息、或者多个消息粘在一起。

response = client_socket.recv(1024)
print(response.decode('utf-8'))

这里的 1024 是缓冲区大小。选多大合适?

  • 太小:频繁的系统调用,性能差
  • 太大:浪费内存,而且不一定能填满
  • 我的建议:一般消息用 4096,大文件传输用 65536(64KB)

但问题来了:你怎么知道消息什么时候收完了?这就是「粘包」问题的根源。常用的解决方案有两种:

方案 实现方式 优缺点
固定长度 每条消息固定 N 字节,不足补位 简单但浪费带宽
长度前缀 先发 4 字节表示消息长度,再发内容 高效,最常用
特殊分隔符 用 \n 或 \r\n 标记消息结束 适合文本协议,但内容中不能出现分隔符

我个人最推荐「长度前缀」方案。后面章节我们会专门实现一个完整的协议层,这里先给你看个简单示例:

def recv_exact(sock, n):
    """精确接收 n 个字节"""
    data = b''
    while len(data) < n:
        chunk = sock.recv(n - len(data))
        if not chunk:
            raise ConnectionError("连接已关闭")
        data += chunk
    return data

# 接收消息:先读 4 字节长度,再读内容
header = recv_exact(client_socket, 4)
msg_len = int.from_bytes(header, 'big')
message = recv_exact(client_socket, msg_len)

3.4 优雅关闭:别让连接断得不明不白

很多人觉得关闭连接就是调一下 close(),其实没那么简单。TCP 关闭有四次挥手,如果处理不当,会导致数据丢失或者端口长时间处于 TIME_WAIT 状态。

什么叫「优雅关闭」?就是告诉对方「我要断开了,你还有数据要发吗?」而不是直接拔网线。

# 优雅关闭的推荐步骤
client_socket.shutdown(socket.SHUT_WR)  # 告诉服务器:我不再发了
# 此时还可以接收服务器剩余的数据
remaining = client_socket.recv(4096)
client_socket.close()  # 彻底关闭

shutdown() 的参数有三种:

  • SHUT_RD:关闭读通道,不能再接收数据
  • SHUT_WR:关闭写通道,不能再发送数据
  • SHUT_RDWR:同时关闭读写,相当于 close()
我曾经踩过的坑:在一个金融交易系统中,Client 发完订单后直接 close(),没有调用 shutdown()。结果服务器还在发送确认消息,Client 这边已经关闭了读通道,导致确认消息丢失,订单状态不一致。那次事故让我养成了「先 shutdown 再 close」的习惯。

还有一个容易被忽略的点:上下文管理器。Python 的 socket 对象支持 with 语句,可以自动关闭:

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
    sock.connect(('localhost', 8888))
    sock.sendall(b'Hello')
    data = sock.recv(1024)
    # 退出 with 块时自动 close()

但注意,with 语句退出时调的是 close(),不是 shutdown()。如果你的场景需要先 shutdown 再收尾,还是得手动控制。

3.5 完整示例:一个可用的 TCP Client

把上面的知识点串起来,就是一个完整的 TCP Client 模板:

import socket
import sys

def tcp_client(host, port, message):
    try:
        # 1. 创建 Socket
        client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        client.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        client.settimeout(5.0)

        # 2. 连接服务器
        client.connect((host, port))
        print(f"已连接到 {host}:{port}")

        # 3. 发送数据
        client.sendall(message.encode('utf-8'))
        print(f"发送: {message}")

        # 4. 接收响应
        response = client.recv(4096)
        print(f"收到: {response.decode('utf-8')}")

        # 5. 优雅关闭
        client.shutdown(socket.SHUT_WR)
        # 可选:接收服务器最后的确认
        tail = client.recv(1024)
        if tail:
            print(f"尾数据: {tail.decode('utf-8')}")

    except socket.timeout:
        print("错误: 连接超时")
    except ConnectionRefusedError:
        print("错误: 连接被拒绝,服务器可能未启动")
    except Exception as e:
        print(f"错误: {e}")
    finally:
        client.close()
        print("连接已关闭")

if __name__ == '__main__':
    tcp_client('127.0.0.1', 8888, 'Hello TCP Server!')

3.6 核心流程总览

下面这张图把 TCP Client 的完整生命周期画出来了,你可以对照着代码看,会更清晰:

TCP Client 生命周期流程图 1. 创建 Socket 2. 连接服务器 3. 收发数据 sendall() / recv() 循环 4. 优雅关闭 shutdown(SHUT_WR) → recv() → close() 结束 注意事项 • 设置超时时间 • 处理部分发送 • 解决粘包问题

你看,整个流程其实不复杂。但每个环节都有细节,尤其是关闭阶段,很多人觉得「不就是关个连接吗」,结果线上出问题才后悔。嗯,记住一句话:创建要谨慎,通信要健壮,关闭要优雅。

本章小结:TCP Client 的核心就是 socket() → connect() → send()/recv() → shutdown()/close() 这四个步骤。其中,超时处理、部分发送、粘包解决、优雅关闭是四个最容易出问题的地方。后面的章节我们会基于这个基础,逐步构建一个完整的、可靠的通信框架。