简介:基于TCP协议的文件传输服务器项目,由开发者使用Visual Studio 2015编写完成,面向网络编程初学者、课程设计人员以及需要实现可靠文件传输功能的开发者。项目以MFC对话框作为交互界面,展示了从服务器监听、客户端连接、预传文件名与文件大小,到创建目标文件、接收数据并确认完成的完整流程。压缩包共42个文件,主要包含cpp/h/rc源码、sln/vcxproj工程配置、res资源文件,以及exe可执行程序、pdb调试信息、ipch预编译缓存和log日志等,整体67.33MB,可直接打开工程查看细节或运行程序体验效果。已有1520人浏览学习。源码中涵盖了Socket套接字编程、路径与权限检查、文件打开模式设置、TCP确认重传机制和四次挥手等关键知识点,同时MFC界面设计也体现了网络逻辑与图形化操作相结合的方法。这些内容对深入理解TCP通信原理、练习C++网络编程、开展文件传输类课设或毕设都有实际参考价值。
1. TCP文件传输服务器:为什么自己写一个比直接用FTP更可控
当你需要在两台机器之间搬一个几个GB的日志包、模型权重或者数据库备份时,第一反应往往是开个FTP或者扔到网盘。但FTP在跨网段、弱网环境下经常出现连接被重置、断点续传要另配软件、传输过程没有任何进度反馈的问题,而这些恰恰是TCP文件传输服务器能解决的。所谓TCP文件传输服务器,就是基于TCP协议自己实现一套文件收发服务:客户端连上来,按约定好的帧格式把文件名、长度、内容分块发过去,服务端落盘并回执。它的优势在于你能精确控制每个tcp连接的缓冲、重传和校验行为,也能把传输逻辑嵌进自动化脚本里,而不是依赖一个带图形界面的FTP工具。
这篇笔记适合两类人:一类是刚接触tcp socket编程,想搞明白文件传输到底怎么设计报文格式的初学者;另一类是已经在用FTP但被性能或稳定性折磨的运维和开发,想换成可控的自研传输通道。我会从TCP协议栈的行为讲起,直接给出一套能跑通的最小实现,再聊参数调优和那些让人翻车的坑。最后你会得到一份可以直接抄走的、支持断点续传的服务器骨架。
2. 传输模型与协议设计:从TCP协议栈到自定义帧格式
2.1 TCP和UDP在文件传输上的本质差别
做文件传输时,很多人会纠结udp和tcp协议的区别。UDP的优势是延迟低、开销小,但它不保证数据顺序,也不保证送达,丢包后应用层要自己做重传和排序,复杂度瞬间上升。TCP则把可靠性做进了协议栈里:序号、确认应答、超时重传、流量控制和拥塞控制都由内核处理。对文件传输来说,可靠性远比微小的延迟重要,所以生产环境里绝大多数方案都基于TCP。
但TCP的可靠性也带来一个副作用:它是一个“字节流”协议,不维护消息边界。你一次write发送的内容,对端read时可能被拆成多次取走,也可能把两次write的内容合并成一次返回,这就是经典的粘包和半包问题。所以自研TCP文件传输服务器,第一件事不是写收发循环,而是设计好帧格式,让接收方能从字节流里正确切出每一笔完整的传输请求。
2.2 一个够用的自定义帧格式
我常用的帧格式是“固定头 + 变长负载”。固定头用二进制结构体,包含魔数、版本、命令类型、负载长度和可选校验值。魔数用来快速识别新连接是否在说我们的协议,避免把乱七八糟的字节流当合法请求解析;负载长度则精确告诉接收方要读多少字节才算一个完整的帧。
命令类型至少要有“上传文件头”“上传数据块”“上传结束”“下载请求”“下载数据块”“下载结束”“错误回执”这七种。上传文件头里包含文件名、文件大小、每块大小;下载请求则只需文件名。把命令类型放在帧头,接收端就能通过一个switch分支决定后续解析逻辑,而不是靠猜。
这里给出Python的struct定义,后续所有收发代码都基于它:
import struct # 帧头格式:魔数(2字节) + 版本(1字节) + 命令(1字节) + 负载长度(4字节) # 大端序,避免不同平台字节序问题 HEADER_FMT = "!HBBI" HEADER_LEN = struct.calcsize(HEADER_FMT) MAGIC = 0x5A5A def build_frame(cmd, payload: bytes) -> bytes: header = struct.pack(HEADER_FMT, MAGIC, 1, cmd, len(payload)) return header + payload def parse_frame(data: bytes): # 返回 (cmd, payload) 或 None(数据不足一个完整帧头) if len(data) < HEADER_LEN: return None magic, version, cmd, length = struct.unpack(HEADER_FMT, data[:HEADER_LEN]) if magic != MAGIC: raise ValueError(f"bad magic: {hex(magic)}") return cmd, data[HEADER_LEN:HEADER_LEN + length]说明一下参数:!表示网络字节序(大端),H是2字节无符号整数,B是1字节无符号整数,I是4字节无符号整数。魔数0x5A5A是随便选的,只要不和常见二进制格式撞车就行。版本字段保留是为了以后协议升级时不至于连帧头都推倒重来。负载长度用4字节,意味着单帧最大能表示4GB,实际传输中我们会把文件切成小块,所以不会真用到那么大。
2.3 粘包与半包的拆包策略
有了帧头,接收端就必须维护一个接收缓冲区。每次从socket读到数据,先追加进缓冲区,然后循环尝试解析:如果缓冲区长度大于等于帧头长度,就解析帧头,拿到负载长度;再判断缓冲区中是否已经包含完整的负载,如果不够就等下一次read;如果够,就切出这一帧交给业务逻辑,剩下的字节继续循环解析。这个“先攒后拆”的模式是TCP拆包的标准做法。
实际代码里,我会给recv指定一个合理的单次读取长度,比如64KB。不要一次读太少,否则频繁的系统调用会拉低吞吐;也不要一次读太大,因为应用层缓冲区通常是堆上分配的一块固定内存,超过后反而触发拷贝开销。拆包时要注意,帧头里的负载长度必须是可信的,否则恶意客户端就能用超大长度声明把服务端拖死,所以服务端要限制单帧最大长度,超过直接断开。
2.4 为什么不用现成协议库
有现成的协议为什么要自己画帧?常见做法是直接上HTTP或者WebSocket。HTTP的文件上传走multipart或application/octet-stream,优点是穿透性好,缺点是传输大文件时没有进度与块级校验,断点续传要依赖Range头配合服务端实现,复杂度也不低。WebSocket则偏实时双向通信,不适合做大批量文件搬运。
自研TCP文件传输服务器的核心收益是:每一块数据的确认时机、重传粒度、校验方式都完全透明。比如你可以把文件切成1MB的块,每块带CRC32或MD5,接收方每收到一块就回执一个确认帧,发送方只重发失败的那一块,而不像TCP底层失败时整条连接都受影响。这种设计对弱网传输非常友好,也是这个标题值得投入的原因。
3. 用Python写一个最小可用的TCP文件传输服务器:多客户端与收发流程
3.1 服务端架构:记住连接状态
文件传输和普通Echo服务最大的区别是服务端要保存“会话状态”:当前连接的客户端正在传哪个文件、已经写了多少字节、按什么块大小收。如果每个连接只用一个线程处理,直接用dict以socket对象为key存状态就行。python的socket文件对象本身可哈希,正好当key。
多客户端处理我一般用threading模块起线程,每个连接一个线程。Python的GIL对文件传输这种I/O密集场景影响不大,因为瓶颈在磁盘和网络,不在CPU计算。如果要更高的并发,后续可以换asyncio或用selectors做事件驱动,但线程模型最容易看懂、也最好排查问题。生产环境里几百个并发连接用线程完全够,别一上来就上异步。
状态机设计如下:每个连接有phase字段,初始为IDLE;收到上传文件头后变为RECEIVING,并记录目标文件句柄和剩余字节;收到数据块时校验块序号并写入文件,文件写完后回到IDLE。客户端断开时,如果状态还在RECEIVING,要清理未完成的临时文件。
3.2 服务端核心代码
下面这段代码实现了上传文件的完整流程,下载逻辑代码对称,就不再重复贴。注意每一步的异常处理都很关键。
import os import socket import threading import struct HEADER_FMT = "!HBBI" HEADER_LEN = struct.calcsize(HEADER_FMT) MAGIC = 0x5A5A CMD_UPLOAD_HEAD = 1 CMD_UPLOAD_DATA = 2 CMD_UPLOAD_END = 3 CMD_DOWNLOAD_REQ = 4 CMD_DOWNLOAD_DATA = 5 CMD_DOWNLOAD_END = 6 CMD_ERROR = 7 MAX_FRAME_SIZE = 8 * 1024 * 1024 # 单帧最大8MB STORE_DIR = "./received_files" def recv_exact(conn, n): """从连接中读取n字节,拆包时用""" buf = b"" while len(buf) < n: chunk = conn.recv(n - len(buf)) if not chunk: raise ConnectionError("socket closed") buf += chunk return buf def recv_frame(conn): """阻塞式读一个完整帧,返回(cmd, payload)""" header = recv_exact(conn, HEADER_LEN) magic, version, cmd, length = struct.unpack(HEADER_FMT, header) if magic != MAGIC: raise ValueError("bad magic") payload = recv_exact(conn, length) return cmd, payload def handle_upload(conn, payload, state): # payload: 文件名字段(2字节长度) + 文件名 + 文件大小(8字节) + 块大小(4字节) name_len = struct.unpack("!H", payload[:2])[0] filename = payload[2:2 + name_len].decode("utf-8", errors="ignore") file_size, block_size = struct.unpack("!QI", payload[2 + name_len:2 + name_len + 12]) save_path = os.path.join(STORE_DIR, os.path.basename(filename)) fh = open(save_path, "wb") state.update({"phase": "RECEIVING", "fh": fh, "remaining": file_size, "block_size": block_size, "filename": filename}) def handle_upload_data(conn, payload, state): # payload前4字节为块序号,后续为文件内容 seq = struct.unpack("!I", payload[:4])[0] data = payload[4:] fh = state["fh"] fh.write(data) state["remaining"] -= len(data) # 回执一个确认帧,客户端可据此知道本块已落盘 ack_payload = struct.pack("!I", seq) conn.sendall(build_frame(CMD_UPLOAD_HEAD, ack_payload)) # 借用一下命令字段 def handle_upload_end(conn, state): if state.get("fh"): state["fh"].close() state["remaining"] = 0 state["phase"] = "IDLE" print(f"upload done: {state['filename']}") def client_thread(conn, addr): print(f"tcp连接已建立: {addr}") state = {} try: while True: cmd, payload = recv_frame(conn) if cmd == CMD_UPLOAD_HEAD: handle_upload(conn, payload, state) elif cmd == CMD_UPLOAD_DATA: handle_upload_data(conn, payload, state) elif cmd == CMD_UPLOAD_END: handle_upload_end(conn, state) break except Exception as e: print(f"连接异常: {addr} {e}") if state.get("fh"): state["fh"].close() finally: conn.close() def main(): os.makedirs(STORE_DIR, exist_ok=True) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 7890)) # tcp端口号按需修改 server.listen(128) print("TCP文件传输服务器已启动,端口7890") while True: conn, addr = server.accept() threading.Thread(target=client_thread, args=(conn, addr), daemon=True).start() if __name__ == "__main__": main()这段代码的逻辑说明:recv_exact解决半包,它循环读取直到读满指定字节数,保证拆包的基础。recv_frame先读帧头,再根据帧头里的长度读负载,把读帧这件事封装成阻塞调用,让业务代码不用自己维护缓冲区。handle_upload解析文件头后立即创建文件对象并在state里记录剩余字节数。handle_upload_data模拟了块确认,发送方可以根据确认帧知道哪些块发送成功。连接异常时关闭文件句柄,避免残留脏数据。
参数说明:监听端口7890只是示例,生产环境建议选高位端口,避开常见的21、80、443,减少被扫描的概率。listen(128)表示内核中未完成握手队列的长度,并发量大的话可以调大。recv_exact里每次recv的缓冲大小来自n - len(buf),这个值会随剩余量变化,但不会超过单帧最大长度。
3.3 客户端核心代码
客户端逻辑比服务端简单,就是建连、发帧、读回执,但要特别注意大文件的分块读取。不要一次把整个文件读进内存,否则几GB的文件直接把客户端内存打爆。正确做法是固定一个chunk_size,比如1MB,用read循环读,每个chunk封装成一帧发送,然后等待确认。
import socket import sys import os import struct HOST = "127.0.0.1" PORT = 7890 CHUNK_SIZE = 1024 * 1024 # 1MB def recv_exact(conn, n): buf = b"" while len(buf) < n: chunk = conn.recv(n - len(buf)) if not chunk: raise ConnectionError("socket closed") buf += chunk return buf def send_upload(conn, filepath): filename = os.path.basename(filepath) file_size = os.path.getsize(filepath) name_bytes = filename.encode("utf-8") head_payload = struct.pack("!H", len(name_bytes)) + name_bytes + \ struct.pack("!QI", file_size, CHUNK_SIZE) conn.sendall(build_frame(CMD_UPLOAD_HEAD, head_payload)) seq = 0 with open(filepath, "rb") as f: while True: data = f.read(CHUNK_SIZE) if not data: break frame = build_frame(CMD_UPLOAD_DATA, struct.pack("!I", seq) + data) conn.sendall(frame) # 等待服务端确认,防止客户端发送过快把内核缓冲塞满 cmd, ack_payload = recv_frame(conn) if cmd != CMD_UPLOAD_HEAD: raise RuntimeError("unexpected ack") seq += 1 conn.sendall(build_frame(CMD_UPLOAD_END, b"")) print(f"发送完成,共 {seq} 块,{file_size} 字节") def main(): if len(sys.argv) < 2: print("用法: client.py <文件路径>") return with socket.create_connection((HOST, PORT), timeout=30) as conn: send_upload(conn, sys.argv[1]) if __name__ == "__main__": main()逻辑说明:客户端逐块读文件,每块带序号。序号的作用是让服务端能检测块丢失或乱序,虽然TCP保证顺序,但业务层的序号也能帮助后续的断点续传实现。每次发送后recv_frame等待确认,这是一种简单的流控,避免数据大量堆积在socket缓冲区导致内存无谓占用。
参数说明:timeout=30表示建立连接的超时时间,超过就抛异常。CHUNK_SIZE是1MB,实际调优时建议测试4KB到4MB之间的不同值。块太小,帧头占比高,吞吐上不去;块太大,单帧内存开销高且出错重传的粒度粗。常见做法是先用1MB起步,弱网环境降到128KB,局域网可以升到4MB。
4. Windows/Linux下的TCP参数调优:端口号、缓冲区、Nagle与netsh int tcp
4.1 端口号与连接状态排查
服务器监听端口选好后,经常遇到的问题就是端口被占用。用netstat -ano | findstr 7890(Windows)或ss -lntp | grep 7890(Linux)查看端口状态。如果出现大量TIME_WAIT连接,说明客户端频繁短连接关闭,这时候要么让连接复用,要么调整系统参数。
TIME_WAIT多不是坏事,它刚好好处是保证旧连接的数据不会串到新连接上。但量大时会占用本地端口和少量内核资源。常见做法是在客户端开启SO_REUSEADDR,并在服务端设置SO_REUSEADDR避免重启时端口被残留连接卡住。
4.2 TCP缓冲区大小怎么设
TCP发送和接收缓冲区的大小直接影响吞吐。缓冲区太小,吞吐受限于窗口;缓冲区太大,内存浪费且可能增加延迟。Linux下默认值通常在几十KB到几百KB,大文件传输时建议调大。用socket.setsockopt设置:
# 服务端与客户端都要设置,单位是字节 s.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1024 * 1024) # 发送缓冲1MB s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024 * 1024) # 接收缓冲1MB但Linux有个规则:内核会把你设置的值乘以2,并且不小于一个下限。你设1MB,实际可能变成2MB。所以更靠谱的做法是设置完之后用getsockopt读回来确认实际值,避免误判。Windows下设置逻辑类似,但底层实现略有差异,以实际读回为准。
4.3 Nagle算法与延迟确认的矛盾
Nagle算法会把小包合并成大包发送,减少网络中的小报文数量,但对需要低延迟的场景是灾难。文件传输如果频繁发送小块,Nagle会等一下后续数据凑包,而对端的延迟确认机制又会等待数据填满才回ACK,双方互相等待可能造成几十毫秒甚至更久的“确认延迟”。
对文件传输这种需要高吞吐、但不需要低延迟交互的场景,可以关闭Nagle:
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)这样每个sendall都会立即发送,配合我们前面设计的1MB块大小,实际上不会产生大量小包,所以关闭Nagle几乎没有副作用。如果你用Windows并跑大文件传输,还可以尝试一条内核参数:
netsh int tcp set global timestamps=enabled这条命令的作用是开启TCP时间戳选项,在长肥网络(高带宽、高延迟)下提供更精确的RTT采样,帮助拥塞控制算法更好地估算窗口。注意它只影响新的tcp连接,已经建立的连接不受影响,所以改完参数后要重启传输进程或系统。netsh int tcp show global可以确认当前状态。
在Linux下对等操作是调整net.ipv4.tcp_timestamps,默认是开启的,一般不用动。重点是tcp_sack、tcp_window_scaling要保持开启,否则大窗口传输会受限。用sysctl net.ipv4.tcp_sack查看。
4.4 最大段大小和窗口缩放
TCP握手时会协商最大段大小(MSS),通常由网卡MTU决定。以太网MTU是1500字节,去掉IP和TCP头,MSS常见为1460字节。应用层即使一次write 1MB,协议栈也会按MSS切片。如果MTU配置成9000(巨型帧),吞吐会明显提升,但需要交换机支持。这个在跨机房或云环境里通常不可控,所以应用层最大的收益还是来自调整缓冲区和关闭Nagle。
窗口缩放(window scaling)是TCP扩展选项,用于支持大于64KB的窗口。Linux默认开启,Windows在netsh int tcp set global autotuninglevel=normal下也会自动开启。如果传输吞吐上不去,可以用netsh int tcp show global检查接收窗口自动调谐级别,千万别设成disabled。
4.5 传输参数对吞吐的影响测试方法
调优不能靠感觉。我的做法是写一个只测吞吐的小脚本:客户端发固定大小的数据块,服务端只读取不落盘,比较不同参数组合下的速率。用time命令计时,或者直接打印每秒字节数。测试时先测本机回环(127.0.0.1),再测局域网,最后测跨网段,逐层定位瓶颈。如果本机回环就慢,问题在协议栈或代码;如果回环快但局域网慢,问题在网络设备或网卡配置。
一个容易被忽略的坑是CPU频率和网卡中断绑核。在大量tcp连接下,多队列网卡会把中断分散到多个CPU,但如果只开单队列,所有中断都打在一个核上,吞吐会被锁死。Linux下用ethtool -L eth0 combined 4调整队列数,Windows下一般在网卡高级属性里改。这些不是必须,但值得了解。
5. TCP文件传输常见的5个坑:粘包、半包、断线、防火墙与踩坑记录
5.1 粘包导致文件头解析错误
现象:服务端收到的文件名变成乱码,或者文件大小字段巨大,直接内存异常。
原因:客户端连续发送文件头和数据块,TCP把两次write的数据合并成一个包到达,服务端没有按帧边界切分,而是把文件头后面的数据块内容误当作文件身的一部分。
解决:必须按“固定头 + 负载长度”的协议拆包。如果用的是阻塞式recv_exact,要求每次读够帧头长度,再读负载长度,就不会粘包。如果自己维护缓冲区,一定要循环解析,不能假设一次recv正好是一个完整帧。
5.2 半包导致连接假死
现象:客户端发了文件头,服务端一直卡在recv不返回,看起来像死锁。
原因:文件头帧总长度超过一次recv返回的字节数,服务端只读到半个帧头,然后继续尝试解析,但帧头不完整,抛异常或进入等待。如果代码里没有用recv_exact,就会出现这个问题。
解决:统一使用recv_exact读固定长度,不要用单个recv硬解析。卡住时用netstat -an看连接状态,如果Recv-Q一直有数据但应用不消费,基本就是半包处理没写对。顺带检查是否因为忘记调用recv读取剩余数据,导致服务端缓冲区被占满,客户端发送被阻塞。
5.3 客户端发送过快导致内存暴涨
现象:客户端已经全部发送完毕,服务端还在慢慢写盘,客户端进程内存占用却持续上升。
原因:这是发送端没有做块级确认导致的。发送方不断sendall,内核socket发送缓冲区满后,应用层数据继续堆积在用户态内存里,我们代码里用的是文件流,本来不会全部读入内存,但如果被迫重试发送,逻辑没写好就可能把整个待发送数据缓存下来。
解决:采用每块确认机制。客户端每发送一块就等服务端回一个ACK再发下一块。这个做法虽然让RTT成为吞吐上限,但能精确控制内存占用。要提速就改用滑动窗口:允许同时在途N个块,N根据RTT和带宽估算,比如局域网可以放4个,跨网段放16个。
5.4 防火墙或安全组把连接静默丢弃
现象:本机能连,局域网能连,换到云服务器上就永远连接超时。
原因:云平台安全组默认只放行少数端口,或者系统防火墙拦截了非标准端口。TCP握手SYN包被丢弃,客户端表现为超时,而不是连接被拒绝。
解决:先看防火墙规则,Windows下用netsh advfirewall firewall add rule name="tcp file" dir=in action=allow protocol=TCP localport=7890,Linux下用firewall-cmd --add-port=7890/tcp或iptables放行。云服务器还要在控制台的安全组入方向增加对应端口规则。验证是否被防火墙拦截,可以在服务端用tcpdump -i any port 7890看是否有SYN到达,如果没到就是安全组/云策略问题,到了但没响应就是服务端进程问题。
5.5 服务端重启后大量端口处于TIME_WAIT
现象:服务端崩溃后立即重启,却报Address already in use。
原因:之前接受过大量短连接,连接关闭后进入TIME_WAIT状态,默认持续60秒(Linux)或240秒(Windows),并占用监听端口。
解决:服务端监听socket设置SO_REUSEADDR后可以立即复用端口。但注意这只对监听socket有效,已建立的连接不可复用。如果确实想缩短TIME_WAIT,Linux下可调整net.ipv4.tcp_fin_timeout=30,Windows下没这么细的公开参数,不建议改。另外客户端应尽量复用同一个连接传多个文件,而不是每次新建tcp连接,这样也减少了TIME_WAIT。
补充一个真事:有次我遇到客户端和服务端都在内网,但传输10%后速度从500MB/s掉到10MB/s,查了半天发现是服务端写盘时用了默认文件打开方式,没有带O_DIRECT,导致页缓存反复换入换出。这个问题和TCP无关,但属于文件传输服务器里最容易忽略的一个坑:先把磁盘I/O搞明白,再去碰网络参数。写文件时建议用buffering参数调大Python文件对象的缓冲,或者直接以二进制模式顺序写,避免随机写。
6. 让传输更可靠:断点续传与校验的进阶实现
断点续传是文件传输服务器最常被要求的进阶功能。实现思路不复杂:客户端在发送文件头时附带上一次传输的上下文,服务端根据上下文决定是从头写还是从偏移量继续写。关键是要设计一个可恢复的会话记录。
我建议服务端为每个传输任务生成一个唯一ID,并把已完成的块序号记录在一个临时元数据文件里。客户端因为断网或人为取消而中断时,重新发起传输并带上任务ID和最后确认的序号。服务端则根据项目状态跳过已经完整收到的块,只接受序号连续的后续数据。这样即使传输过程中TCP协议栈报错,也不需要重传整个文件。
校验方面,单靠TCP的校验码不够。TCP的CRC校验只覆盖每个报文段,无法发现应用层数据被应用程序错误处理导致的损坏(比如写盘时的指针错位)。常见做法是每块附带CRC32,服务端每收一块就校验一次。整个文件完成后,再对文件整体计算一次SHA256,客户端发送时也计算一次,双方比对。CRC32快但碰撞概率略高,SHA256慢但安全,两者结合正好。
import hashlib import zlib def calc_crc32(data: bytes) -> int: return zlib.crc32(data) & 0xffffffff def calc_sha256_file(filepath, chunk_size=1024*1024): h = hashlib.sha256() with open(filepath, "rb") as f: while True: chunk = f.read(chunk_size) if not chunk: break h.update(chunk) return h.hexdigest()参数说明:chunk_size在计算文件大文件SHA256时建议和传输块大小一致,减少内存分配次数。如果你用CRC32做块校验,注意zlib.crc32返回的是无符号32位整数,在Python里可能显示为负数,要按位与0xffffffff转换成无符号值。发到网络时用struct.pack("!I", crc)即可。
验证传输是否正确的第一步,不是直接跑业务,而是用dd或pv传输一个固定内容的文件,然后比对两边的SHA256。我会用以下命令快速验证:
# 生成一个1GB随机文件用于测试 dd if=/dev/urandom of=/tmp/test.bin bs=1M count=1024 # 计算原始文件校验值 sha256sum /tmp/test.bin # 传输完成后对接收到的文件再算一次 sha256sum /tmp/received/test.bin两边输出一致,说明传输链路没问题。如果实验网络环境,跑10G以上大文件时注意硬盘剩余空间和文件系统上限。
断点续传还有一个容易翻车的细节:文件名撞车。如果两个任务上传到同一个目录且文件名相同,服务端必须区分是覆盖还是续传。我的做法是在元数据里记录原始文件名和当前临时文件名,比如{task_id}.part,只有全部传输完成后才重命名为正式文件名。这样即使传输中断,临时文件也不会被误用,下次续传时直接追加到part文件上。
关于传输效率,还有一个经验:千万别在应用层再做一层数据压缩。如果文件本身是压缩包、视频或模型权重,压缩率很低且白白消耗CPU。TCP协议栈核心追求的是把数据尽快搬完,压缩交给业务方决定。如果文件是文本或JSON日志,可以在客户端预先压缩,服务端解压后落盘,但这已经超出TCP文件传输服务器的范畴。
最后分享一个我的教训:有一次我把TCP的SO_RCVBUF设得很大,却忘了同步调整内存限制,导致服务器在高并发时内存被打满。从那以后,调整参数时我只看实际生效值和系统当前空闲内存,不盲目堆数字。文件传输服务器表面上是网络应用,真正决定上限的往往是磁盘I/O、内存分配和内核参数这三者的配合。你把帧格式、拆包逻辑、块确认和断点续传做扎实,无论是传日志、传模型还是传备份,都能应付自如。
希望这篇笔记能帮你在自研TCP文件传输服务器的路上少走几步弯路。先从最小可跑通的版本开始,再逐步加上校验和断点续传,坑踩完一遍后,你会发现它远比想象中可靠。
本文还有配套的精品资源,点击获取