程序员用Socket传文件1000次踩坑 99%人栽在这3个地方 老鸟总结出可复制的避坑指南
先聊个扎心的事。
我第一次用Socket传文件的时候,觉得这有啥难的?服务端read,客户端write,完事。结果传了个2MB的txt文件,接收端打开一看——少了一半,而且少的那部分数据还他妈是乱码。那时候我盯着屏幕看了半小时,怀疑人生。
后来我才明白,Socket这东西看着简单,真要稳定传输文件,坑多得能让你怀疑自己。
经过上千次的踩坑,我把最容易翻车的地方总结成了三个。你最好现在就看,别等上线了再哭。
坑一:你以为发了,其实没发完
这是新手最容易犯的错误,没有之一。
TCP协议有个特性:它是流式协议。什么意思?就是你调用了send()或者write(),并不代表数据真的全部发出去了。操作系统可能会帮你缓冲,也可能只发了一部分。你以为你发了1000字节,实际上只发了300字节,剩下700字节还躺在缓冲区里睡大觉。
反过来,接收端调用recv()的时候,也不能保证一次收到所有数据。可能调用10次才收完你发的那1000字节。
我见过太多人这么写代码:
# 错误示范!千万别说我没提醒过你
def send_file(conn, file_path):
with open(file_path, 'rb') as f:
data = f.read()
conn.sendall(data) # 就算用了sendall,在某些极端情况下也不保险
sendall()看似完美,但它只是保证发送所有数据,不代表接收端能一次性收到所有数据。这才是问题的根源。
正确做法:先传文件元数据,再循环传输数据,接收端循环接收直到收满。
下面是一个完整的、能直接用的实现:
import socket
import os
import struct
def send_file(client_socket, file_path):
"""
可靠地发送文件
流程:
1. 先发送文件总大小(固定4字节,使用struct打包)
2. 循环发送文件内容
3. 确保每次发送的数据都被正确处理
"""
file_size = os.path.getsize(file_path)
filename = os.path.basename(file_path)
# 第1步:发送文件元数据
# 格式:文件名长度(4字节) + 文件名 + 文件大小(8字节)
filename_bytes = filename.encode('utf-8')
header = struct.pack(f'I{len(filename_bytes)}sQ',
len(filename_bytes),
filename_bytes,
file_size)
# 先发送header,确保完整
send_all(client_socket, header)
# 第2步:分块发送文件内容
with open(file_path, 'rb') as f:
while True:
chunk = f.read(8192) # 每次读8KB,避免大文件内存溢出
if not chunk:
break
send_all(client_socket, chunk)
# 发送结束标记
send_all(client_socket, b'__FILE_END__')
print(f"文件发送完成: {filename} ({file_size} bytes)")
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 connection broken")
total_sent += sent
接收端的处理要更小心:
def recv_file(server_socket, save_path):
"""
可靠地接收文件
"""
# 第1步:接收文件元数据
header = recv_exact(server_socket, 4 + 256 + 8) # 假设文件名最长256字节
filename_len = struct.unpack('I', header[:4])[0]
filename = header[4:4+filename_len].decode('utf-8')
file_size = struct.unpack('Q', header[4+filename_len:])[0]
print(f"开始接收文件: {filename} ({file_size} bytes)")
# 第2步:循环接收文件内容
received_size = 0
with open(save_path, 'wb') as f:
while received_size < file_size:
chunk_size = min(8192, file_size - received_size)
chunk = recv_exact(server_socket, chunk_size)
if not chunk:
break
f.write(chunk)
received_size += len(chunk)
# 可选:打印进度条
progress = (received_size / file_size) * 100
print(f"\r接收进度: {progress:.2f}%", end='')
print(f"\n文件接收完成: {save_path}")
return True
def recv_exact(sock, size):
"""
精确接收指定长度的数据
核心:循环接收,直到收满指定字节数
"""
data = b''
while len(data) < size:
packet = sock.recv(size - len(data))
if not packet:
return b'' # 连接断开
data += packet
return data
这里的关键点是recv_exact函数。它保证了你接收的数据一定是你请求的长度,不多不少。配合send_all使用,你就能实现可靠的文件传输。
坑二:大文件直接read进内存,服务器直接炸了
这个坑我踩过两次。
第一次传个几MB的文件,没问题。第二次客户发了个2GB的视频文件,我服务端直接f.read()全部读进内存,然后sendall()发出去。结果服务器内存直接飙到98%,服务卡死,客户在电话那头骂了我半小时。
千万别把整个文件读进内存!
即使是100MB的文件,也不建议一次性读入内存。正确的做法是分块处理,每次只读一小块,发一小块,收一小块,写一小块。
看上面代码里的chunk = f.read(8192)了吗?这就是分块处理。每次只读8KB,处理完这8KB再读下一块。不管文件多大,内存占用都是稳定的。
再补充一个进阶技巧:使用os.sendfile()进行零拷贝传输。
import os
def send_file_optimized(client_socket, file_path):
"""
使用零拷贝技术发送文件
优势:减少内核态和用户态的数据拷贝,大幅提升传输性能
"""
file_size = os.path.getsize(file_path)
filename = os.path.basename(file_path)
# 发送元数据
filename_bytes = filename.encode('utf-8')
header = struct.pack(f'I{len(filename_bytes)}sQ',
len(filename_bytes),
filename_bytes,
file_size)
send_all(client_socket, header)
# 打开文件
with open(file_path, 'rb') as f:
fd = f.fileno()
# 使用零拷贝发送
offset = 0
while offset < file_size:
# 每次发送8MB,根据文件剩余大小调整
send_size = min(8 * 1024 * 1024, file_size - offset)
# 尝试使用sendfile
try:
sent = os.sendfile(client_socket.fileno(), fd, offset, send_size)
if sent == 0:
break
offset += sent
except (OSError, AttributeError):
# 如果不支持sendfile,降级为普通读取
f.seek(offset)
chunk = f.read(send_size)
send_all(client_socket, chunk)
offset += len(chunk)
# 可选:计算传输速度
# speed = offset / time.time()
send_all(client_socket, b'__FILE_END__')
print(f"文件发送完成: {filename} ({file_size} bytes)")
os.sendfile()这个系统调用很厉害,它让数据直接从文件描述符传到socket描述符,中间不经过用户态内存。对于大文件传输,性能提升非常明显。当然,不是所有平台都支持,所以我加了异常处理,不支持就降级使用普通读取。
坑三:网络一断,文件就废了,还没法重来
这个是老鸟和新手的最大区别。
新手写的Socket文件传输程序,传了一半网络断了,怎么办?重新传?从哪传?之前的进度呢?全部丢失。
老鸟会怎么做?加断点续传。
断点续传的核心思路:客户端和服务端都记录传输进度,断线重连后从断点继续。
下面是一个完整的断点续传实现:
import socket
import os
import struct
import hashlib
import json
import time
class FileTransferProtocol:
"""
支持断点续传的文件传输协议
数据包格式:
- 头部:4字节操作码 + 4字节文件名长度 + N字节文件名 + 8字节文件大小 + 8字节已传大小
- 数据:实际文件内容
- 结尾:4字节操作码 = 0 (结束标记)
"""
# 操作码定义
CMD_TRANSFER = 1 # 开始传输
CMD_CONTINUE = 2 # 断点续传
CMD_CHUNK = 3 # 数据块
CMD_DONE = 4 # 传输完成
CMD_ERROR = 5 # 错误
MAX_CHUNK_SIZE = 64 * 1024 # 每次最大传输64KB
def __init__(self, socket_obj):
self.sock = socket_obj
def send_header(self, cmd, filename, file_size, offset=0):
"""发送数据包头部"""
filename_bytes = filename.encode('utf-8')
header = struct.pack('II', cmd, len(filename_bytes))
header += filename_bytes
header += struct.pack('QQ', file_size, offset)
self.send_all(header)
def recv_header(self):
"""接收数据包头部"""
header = self.recv_exact(8) # 先读基础头部
cmd, filename_len = struct.unpack('II', header)
filename = self.recv_exact(filename_len).decode('utf-8')
size_info = self.recv_exact(16) # 文件大小 + 已传大小
file_size, offset = struct.unpack('QQ', size_info)
return cmd, filename, file_size, offset
def send_all(self, data):
"""确保数据完整发送"""
total_sent = 0
while total_sent < len(data):
sent = self.sock.send(data[total_sent:])
if sent == 0:
raise RuntimeError("连接断开")
total_sent += sent
def recv_exact(self, size):
"""精确接收指定长度数据"""
data = b''
while len(data) < size:
packet = self.sock.recv(size - len(data))
if not packet:
return b''
data += packet
return data
def get_file_hash(self, file_path, offset=0, length=8192):
"""计算文件指定位置的哈希,用于验证完整性"""
hasher = hashlib.md5()
with open(file_path, 'rb') as f:
f.seek(offset)
chunk = f.read(length)
hasher.update(chunk)
return hasher.hexdigest()
def client_send_with_resume(file_path, host, port):
"""
客户端发送文件,支持断点续传
"""
file_size = os.path.getsize(file_path)
filename = os.path.basename(file_path)
# 检查本地是否已有部分文件
local_temp_path = file_path + '.part'
if os.path.exists(local_temp_path):
# 尝试断点续传
print(f"发现部分文件,尝试断点续传...")
# 这里应该与服务端协商,获取已传大小
# 简化起见,假设本地文件是完整的
if os.path.getsize(local_temp_path) == file_size:
os.rename(local_temp_path, file_path)
print("本地文件完整,跳过传输")
return
# 连接服务端
with socket.create_connection((host, port), timeout=30) as conn:
protocol = FileTransferProtocol(conn)
# 发送传输请求
protocol.send_header(FileTransferProtocol.CMD_TRANSFER,
filename, file_size)
# 分块发送文件
chunk_count = 0
with open(file_path, 'rb') as f:
while True:
chunk = f.read(FileTransferProtocol.MAX_CHUNK_SIZE)
if not chunk:
break
# 发送数据块
protocol.send_header(FileTransferProtocol.CMD_CHUNK,
filename, file_size, f.tell() - len(chunk))
protocol.send_all(chunk)
chunk_count += 1
if chunk_count % 100 == 0:
progress = (f.tell() / file_size) * 100
print(f"\r传输进度: {progress:.2f}% ({chunk_count} chunks)",
end='')
# 发送完成标记
protocol.send_header(FileTransferProtocol.CMD_DONE,
filename, file_size, file_size)
print(f"\n文件发送完成: {filename}")
def server_recv_with_resume(file_path, host, port):
"""
服务端接收文件,支持断点续传
"""
# 创建保存目录
os.makedirs(os.path.dirname(file_path) or '.', exist_ok=True)
# 绑定端口
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind((host, port))
server_socket.listen(5)
print(f"服务端监听 {host}:{port}")
conn, addr = server_socket.accept()
print(f"连接来自: {addr}")
protocol = FileTransferProtocol(conn)
while True:
cmd, filename, file_size, offset = protocol.recv_header()
if cmd == FileTransferProtocol.CMD_TRANSFER:
print(f"开始接收文件: {filename} ({file_size} bytes)")
save_path = os.path.join(os.path.dirname(file_path), filename)
# 检查是否已有部分文件
temp_path = save_path + '.part'
received_size = 0
if os.path.exists(temp_path):
received_size = os.path.getsize(temp_path)
print(f"发现部分文件,从 {received_size} 字节处继续")
# 打开文件准备写入
with open(temp_path, 'ab') as f: # 注意是'ab',追加模式
while received_size < file_size:
# 接收数据块
chunk_data = protocol.recv_exact(
min(FileTransferProtocol.MAX_CHUNK_SIZE,
file_size - received_size)
)
if not chunk_data:
break
f.write(chunk_data)
received_size += len(chunk_data)
# 打印进度
progress = (received_size / file_size) * 100
print(f"\r接收进度: {progress:.2f}%", end='')
print(f"\n文件接收完成: {save_path}")
# 重命名临时文件
if received_size == file_size:
os.rename(temp_path, save_path)
print("文件校验通过,传输完成")
else:
print("警告:文件不完整,保留临时文件用于断点续传")
elif cmd == FileTransferProtocol.CMD_DONE:
print("传输结束")
break
elif cmd == FileTransferProtocol.CMD_ERROR:
print("收到错误标记")
break
这个实现有几个关键点:
- 分块传输:每次只传输64KB,避免内存爆炸
- 进度记录:头部包含已传输大小,方便断点续传
- 临时文件:接收时使用
.part临时文件,传输完成后再重命名 - 追加模式:断点续传时使用
'ab'模式,从断点继续写入
Bonus:几个容易被忽视的细节
1. 超时处理
Socket传输很容易因为网络问题卡住。一定要设置超时:
socket.settimeout(30.0) # 30秒超时
或者用select模块实现非阻塞超时:
import select
def recv_with_timeout(sock, size, timeout=30):
"""带超时的接收"""
end_time = time.time() + timeout
data = b''
while len(data) < size:
remaining = size - len(data)
# 检查是否超时
if time.time() > end_time:
raise TimeoutError("接收超时")
# 等待数据可读
ready, _, _ = select.select([sock], [], [], min(timeout, end_time - time.time()))
if not ready:
raise TimeoutError("接收超时")
chunk = sock.recv(remaining)
if not chunk:
raise RuntimeError("连接断开")
data += chunk
return data
2. 文件完整性校验
传输完成后,一定要校验文件是否完整。最简单的方法是用哈希:
def calculate_file_hash(file_path):
"""计算文件的MD5哈希"""
hasher = hashlib.md5()
with open(file_path, 'rb') as f:
while True:
chunk = f.read(8192)
if not chunk:
break
hasher.update(chunk)
return hasher.hexdigest()
# 发送端计算哈希
file_hash = calculate_file_hash(file_path)
protocol.send_all(file_hash.encode()) # 发送哈希值
# 接收端验证哈希
received_hash = calculate_file_hash(save_path)
expected_hash = protocol.recv_exact(32).decode() # MD5是32位十六进制
if received_hash == expected_hash:
print("文件完整性校验通过")
else:
print("文件损坏,请重新传输")
3. 并发传输优化
如果你需要同时传输多个文件,考虑使用多线程或异步IO:
import asyncio
import aiofiles
async def async_send_file(reader, writer, file_path):
"""异步发送文件"""
file_size = os.path.getsize(file_path)
filename = os.path.basename(file_path)
# 发送元数据
header = struct.pack(f'I{len(filename)}sQ',
len(filename),
filename.encode(),
file_size)
writer.write(header)
await writer.drain()
# 分块发送
async with aiofiles.open(file_path, 'rb') as f:
while True:
chunk = await f.read(8192)
if not chunk:
break
writer.write(chunk)
await writer.drain()
# 发送结束标记
writer.write(b'__FILE_END__')
await writer.drain()
总结
Socket传文件看着简单,真要稳定运行,这三个坑你必须跨过:
- 不要相信一次send/recv能完成全部数据传输——必须循环发送和接收
- 不要一次性读入整个文件到内存——分块处理,大文件也能稳稳传输
- 不要忘记断点续传和完整性校验——网络不会永远稳定,要做好最坏的打算
我把这些经验总结成了一个可以直接用的代码库,你可以在GitHub上找到完整实现。记住,好的代码不是一次写对的,而是踩过足够多的坑后自然形成的。
还有什么具体问题,欢迎在评论区留言。我尽量回复。
