☰
TCP文件传输服务器:协议设计、断点续传与性能调优
2026/10/8 2:17:11 网站建设 项目流程

简介:这是一套基于Visual Studio 2015开发的TCP文件传输服务器完整工程,面向网络编程初学者及C/S架构学习者,演示了利用TCP协议在客户端与服务器之间高效、可靠传输文件的实现思路。项目核心包括Socket连接创建、文件名与文件大小预传输、文件内容接收及确认机制,并通过TCP确认与重传策略保障数据完整性;同时涉及路径处理、错误检查、文件打开模式设置等实操细节,后续还可扩展断点续传、多线程并发、加密传输等功能。资源共42个文件,压缩包67.33MB,包含C++源码(.cpp/.h)、VS工程文件(.sln/.vcxproj)、资源脚本(.rc)、编译生成的exe程序及调试符号(.pdb)等,可直接打开工程阅读源码或运行体验。已有1520人学习下载,适合想深入理解Socket编程、TCP粘包处理或文件传输流程的开发者参考。

1. TCP文件传输服务器:为什么不用 HTTP 硬扛大文件

生产环境里往另一台机器传几个 GB 的日志包,最省事的想法是开个 HTTP 服务拿浏览器下,但真到传输阶段就开始闹幺蛾子:传到一半断线没有续传、网盘限速到几十 KB、临时机器上连 Python 环境都得现场装。反观直接基于 socket 写的 TCP 文件传输服务器,反而更稳——它要解决的就一件事:把二进制流可靠地从 A 搬到 B,有分块、有确认、有重发,传输进度可控。这套资源把服务端、客户端、协议头定义和测试脚本都整理好了,适合做内网传输、日志收集、或者想真正搞懂 TCP 可靠传输原理的开发与运维。

2. 先把传输协议定下来:包格式、ACK 与重传策略

2.1 包格式设计:魔数、偏移量与校验字段缺一不可

TCP 本身是流协议,它不是按“包”给你数据的,recv 拿到的字节流天然没有边界。裸写 socket 传文件最容易翻车的点就在这:接收方不知道一段数据到哪结束、属于文件的哪个位置。这个资源给的方案是在每个文件块前面加一个固定长度的二进制头部,把文件块切分成“头部 + 负载”的帧结构。

头部用 Python 的 struct 定义,字段如下:

import struct # ! 表示网络字节序(大端),H=uint16,I=uint32 # 协议头:魔数(2) + 块长度(4) + 偏移量(4) + 校验值(2) = 12 字节 HEADER = struct.Struct('!HIIH') MAGIC = 0xAB12 # 魔数,用于快速判断是否为本协议的包 END_MAGIC = 0xABFF # 结束包魔数,客户端传完最后一帧后发送
字段类型位数作用
magicH16 bit帧类型标识,能过滤脏数据
chunk_lenI32 bit本帧负载的字节数,上限 4 GB
offsetI32 bit该块在文件中的起始偏移量
crcH16 bit负载的 MD5 前 2 字节,用于块级校验

魔数看起来多余,但实际很管用。如果你把 TCP 端口误接到了别的客户端,或者对方发来一堆乱码,靠 magic 判断能在前 2 字节就丢弃整个帧,不用硬解析。chunk_len 解决边界问题,告诉接收方“这个帧的负载读多少字节才算完”。offset 是给断点续传留的口子,也是服务端 seek 写文件的依据。crc 用负载 MD5 的前 2 字节,碰撞概率不算低,但对付网络传输的随机错位足够,要更稳就在生产上改成完整 16 字节 MD5,代价是每块多 14 字节的头部开销。

2.2 可靠传输的两个锚点:ACK 确认与超时重传

TCP 底层有 ACK 和重传机制,但在文件传输这个场景里,你还要一层应用层确认。原因很直接:TCP 的 ACK 只说明“数据进了对端内核缓冲区”,不代表你的业务逻辑判断过这段数据完整、校验通过、正确落盘了。像磁盘写满、进程处理不过来这种问题,TCP 完全感知不到。

这个资源的做法是逐块确认。客户端每发完一个帧,必须等服务端回一个 2 字节的结果:OK表示该块校验通过并已写入,NG表示校验失败需要重发。这一来一回看着多了一次 RTT,但对于大文件传输,吞吐的关键在流水线并发而不是单块确认,逐块确认换来的正确性完全值得。

超时重传放在客户端一侧。socket 连接设一个timeout,一旦超过阈值没收到响应,就重发当前块。重发不是无线循环,资源里约定的是最多重试 3 次,3 次之后直接报错退出。重传超时设多少有讲究,局域网推荐 3 秒,跨公网建议 10 秒起步,设太短容易在丢包抖动时误判,设太长又会拖慢失败反馈。

2.3 三个核心参数:Buffer Size、接收窗口与重传超时

块大小(Buffer Size)是文件传输里最关键的参数。它影响两层东西:一是 TCP 包大小是否匹配路径 MTU,二是应用层确认频率。资源里默认给的是 64 KB,也就是BLOCK = 64 * 1024,这个值是我反复试下来最稳的起点。

参数推荐值影响
BLOCK64 KB太小编码开销高,太大单块重传代价大
发送缓冲区256 KB ~ 1 MB小于块大小会阻塞 sendall
接收缓冲区256 KB ~ 1 MB影响 TCP 窗口扩张上限
重传超时局域网 3s / 跨网 10s设太短误判,设太长拖慢反馈

如果走跨互联网传输,把块调到 128 KB 或 256 KB 也能跑,但前提是发送缓冲区必须跟着调,否则 sendall 会把一次逻辑发送拆成多次系统调用,反而触发 Nagle 合并。TCP 窗口本身有系统级上限,之后第 6 章会讲怎么用sysctl去调。总之,这组参数是先保证“单块传输时间 < 超时时间”的原则定出来的,改的时候别只动一个字段。

3. 服务端实现:从 Accept 循环到文件落盘的完整代码

3.1 服务端主循环:接收线程与解析

资源里的服务端用单线程模型先把功能跑通,主循环 accept 后直接处理当前连接,逻辑清晰、方便调试。生产上要支持多客户端并发,后面把handle_conn丢进线程池即可。下面是服务端核心代码:

# server.py —— 单线程版,先保证功能正确 import os import socket import struct import hashlib HEADER = struct.Struct('!HIIH') MAGIC = 0xAB12 END_MAGIC = 0xABFF def recv_until(sock, length): """循环接收,直到读满 length 字节,解决半包问题""" data = b'' while len(data) < length: part = sock.recv(length - len(data)) if part == b'': raise ConnectionError('对端关闭连接') data += part return data def handle_conn(conn, final_path): tmp_path = final_path + '.part' ok = False with open(tmp_path, 'wb') as tmp, conn: while True: try: head = recv_until(conn, HEADER.size) except (ConnectionError, socket.timeout): break magic, chunk_len, offset, crc = HEADER.unpack(head) if magic == END_MAGIC: # 结束包:客户端已全部确认,对比文件总大小 if offset == tmp.tell(): conn.sendall(b'OK') ok = True else: conn.sendall(b'NG') break if magic != MAGIC: # 未知数据,直接断开,避免把垃圾写入文件 break chunk = recv_until(conn, chunk_len) if hashlib.md5(chunk).digest()[:2] != crc: conn.sendall(b'NG') continue tmp.seek(offset) tmp.write(chunk) conn.sendall(b'OK') if ok: os.replace(tmp_path, final_path) def main(): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(('0.0.0.0', 9000)) srv.listen(8) print('server listening on 9000') while True: conn, addr = srv.accept() print('accept:', addr) handle_conn(conn, './recv.bin') if __name__ == '__main__': main()

这段代码的关键点是recv_until函数:它用循环保证了无论接收缓冲区里是一个块、半个块还是三四个块,都能完整取出一帧再解析。tmp.seek(offset)让每个块写到文件的正确位置,顺序乱着来也能正确落盘。文件先写.part临时文件,全部确认完成后再用os.replace原子重命名,期间任何断线都不会污染最终文件,这是我从生产环境里学到的习惯。

3.2 客户端实现:分块发送与 ACK 等待

客户端逻辑是服务端的镜像:读取本地文件、按块切分、逐块发送并等待OK。注意客户端要处理一个服务端容易忽略的细节:sendall之后必须recv,如果直接关连接,服务端可能还在处理上一块的数据,最终文件大小对不上。

# client.py import hashlib import os import socket import struct HEADER = struct.Struct('!HIIH') MAGIC = 0xAB12 END_MAGIC = 0xABFF BLOCK = 64 * 1024 def recv_until(sock, length): data = b'' while len(data) < length: part = sock.recv(length - len(data)) if part == b'': raise ConnectionError('连接中断') data += part return data def send_file(ip, port, filepath): total = os.path.getsize(filepath) sent = 0 with socket.create_connection((ip, port), timeout=10) as sock: with open(filepath, 'rb') as f: offset = 0 while True: chunk = f.read(BLOCK) if not chunk: break crc = hashlib.md5(chunk).digest()[:2] header = HEADER.pack(MAGIC, len(chunk), offset, crc) sock.sendall(header + chunk) resp = recv_until(sock, 2) if resp != b'OK': raise RuntimeError(f'offset={offset} 校验不通过') offset += len(chunk) sent += len(chunk) print(f'progress: {sent}/{total}') # 发送结束包,offset 字段携带文件总大小 end = HEADER.pack(END_MAGIC, 0, total, 0) sock.sendall(end) resp = recv_until(sock, 2) if resp != b'OK': raise RuntimeError('结束包未确认') if __name__ == '__main__': send_file('127.0.0.1', 9000, './test.bin')

HEADER.pack(MAGIC, len(chunk), offset, crc)按大端序把 12 字节头部拼好,紧接负载一起sendall,保证了头部和负载在同一个 TCP 段里发出去。这里的超时设的是 10 秒,局域网内足够,跨公网则按延迟调。打印进度的sent/total是给用户看的,真正决定进度可靠性的,是每一块收到OK之后才递增的offset。

3.3 并发权衡:多线程、多进程与 epoll 的选择

单线程版本代码短、易排查,适合第一步跑通。但实际生产里常有多台机器同时推文件的情况,这时就得考虑并发。按这个资源的使用场景,最优先推荐的是线程池:连接数不多(几十以内),每个连接又是阻塞读写,线程池能把代码改动压缩到最小,把handle_conn丢进ThreadPoolExecutor就行。

如果单机连接数上千,那才是 epoll 的舞台。Python 里用asyncio重写收发循环,把 recv_until 改成 await,本质是把非阻塞 socket 的事件轮询交给内核。但这里有个血泪教训:异步模型下别在协程里做磁盘同步写,尤其大文件落盘时必须用独立的写线程或loop.run_in_executor,否则一个慢磁盘会把整个事件循环拖死。换句话说,连接少就线程池,连接多且每个连接数据量大,先想想磁盘 IO 是不是瓶颈再上异步。

4. TCP 文件传输避坑:粘包、半包、Nagle 与 TIME_WAIT

4.1 粘包:一次 recv 读到多个数据块

现象:服务端连续收到两个文件块的内容,recv一次返回的长度超过预期,解析第一帧后发现缓冲区里还残留下一帧的数据,顺序一乱文件就整个废掉。

原因:TCP 是字节流协议,不保留应用层的消息边界。发端两次sendall的数据很可能一次到达接收端内核缓冲区,recv一次全取出来,这就是常说的粘包。

解决:不要裸 recv,严格按照“先读固定长度头部,再按头部声明长度读取负载”来消费数据。这个资源里recv_until的意义就在这:它始终从缓冲区取走当前帧需要的字节数,多出来的数据留在 socket 内核缓冲区,下一轮循环继续读。粘包在业务上不是 bug,处理好了它反而能减少系统调用次数。

4.2 半包:recv 返回长度不够一个块

现象:客户端发了 64 KB 的块,服务端recv(64 * 1024)只返回了 20 KB,后面再读才拿到剩余部分,如果直接按一次 recv 结果解析,头部残缺、长度对不上。

原因:TCP 报文段会被路径 MTU、内核缓冲区大小和拥塞窗口限制拆成多个小段传输。即使数据总量不大,对端也可能分多次到达。这是 TCP 的常态,不是异常。

解决:所有读取都必须包一层循环,直到读满所需长度。代码里recv_until的while len(data) < length就是标准解法。我见过不少人在半包这个问题上临时加sleep(0.1)去碰运气,这是典型的玄学调试,局域网里可能碰巧能过,一上公网立刻现原形,千万别这么写。

4.3 Nagle 算法:小包传输忽然变慢

现象:文件传输后期,客户端发送速度突然掉到几十 KB/s,服务端的 CPU 和网络带宽都没占满,看起来像是连接被卡住了。

原因:TCP 默认开启 Nagle 算法,它会合并小包再发送,要求“前一个包 ACK 后才能发下一个小包”。如果对端同时开了 Delayed ACK,小包传输就会产生 200 ms 级别的等待,吞吐直接崩。

解决:对传输类 socket 设置TCP_NODELAY关闭 Nagle。注意一点:本资源每个块是头部加负载一次性sendall,块大小 64 KB 远超小包阈值,Nagle 影响不大;但如果你把块切小,或者将来做交互式命令通道,就一定要加conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)。这个开关只在需要低延迟时开,大块传输场景开了也感知不到区别。

4.4 TIME_WAIT:服务端重启后 bind 失败

现象:服务端 Ctrl+C 杀掉进程后立刻重启,报Address already in use,等几十秒又能重启成功,气得想砸键盘。

原因:客户端主动断开连接时,服务端侧会进入 TIME_WAIT 状态,持续约 2 个 MSL(通常 60 秒)。这段时间内 socket 的四元组还占着,默认不允许重新 bind 同样的端口。

解决:服务端在 bind 前加srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)。这个选项告诉内核允许重用处于 TIME_WAIT 状态的端口。代码里已经写上了。生产环境重启服务是家常便饭,不写这一行的服务端程序我建议直接不验收。

4.5 校验失败后重发:必须有重试上限

现象:某一块 CRC 校验不过,客户端收到NG后重发,服务端又返回NG,双方陷入无限重发,日志刷屏,文件卡在一个进度上不动。

原因:如果传输途经的链路存在固定干扰或 MTU 黑洞,同一块数据每次到达都损坏,重发多少次结果都一样。没有重试上限的循环本质上是个死循环。

解决:客户端对单块重试设上限,资源里约定 3 次,超过直接抛异常退出。同时服务端在连续NG超过 5 次时也主动断开连接,不再响应重发。把重试和断连策略写进协议文档比写在代码注释里更重要,这是能让运维半夜少接电话的那种细节。

5. 断点续传与 MD5 校验:大文件传输的必要扩展

5.1 断点续传:用已确认块号恢复传输

几十 GB 的文件传一半断网,从头再来谁也受不了。断点续传的核心思路很简单:客户端把“已收到 OK 的块偏移量”持久化到本地,重连服务端后,从该偏移量继续读文件,而不是从头开始。

实现上要注意一个细节:服务端启动时要检查.part临时文件是否存在,存在就取其大小作为初始偏移量。代码如下方示意:

# 服务端续传:从已有 .part 文件大小继续写 resume_offset = 0 if os.path.exists(tmp_path): resume_offset = os.path.getsize(tmp_path) with open(tmp_path, 'ab') as tmp: tmp.seek(resume_offset)

服务端打开文件时要改成'ab'模式并seek到现有偏移量,客户端则维护一个本地状态文件记录已确认偏移量。断线重连后,客户端发送第一帧时把起始偏移量告诉服务端,双方都定位到同一个点继续。这里最隐蔽的坑是:文件读取要跳过已确认的块,还是顺序读再让服务端按 offset 覆盖写?我一般选后者——继续顺序读,服务端继续seek(offset)覆盖写,省去客户端随机读的复杂度,逻辑也更容易证明正确。

5.2 校验策略:MD5 放分块层,传输结束再做全量校验

块级校验解决的是“单块数据在传输过程中是否被破坏”,但有两个盲区:一是 2 字节 CRC 的碰撞概率,二是服务端磁盘写入时段的静默损坏,这种损坏可能让每块分别校验都通过,最终文件却和源文件不一致。

所以这个资源推荐的策略是双层校验:传输过程中逐块用完整 MD5 校验,传输结束后再对两端整个文件做一次全量 MD5 比对。全量比对代码很短:

# 在源机器和接收机器上分别执行,对比输出的 md5 值 md5sum test.bin

全量 MD5 适合作为最终裁决,但不要替代块级校验。因为全量校验只能告诉你“文件坏了”,不能告诉你“坏在哪一块”,网络传输该不该重发、从哪个偏移量重发,还得靠块级信息。顺序是先块级校验保证边界正确,再全量校验消除碰撞风险,两条腿走路。

5.3 原子落盘:临时文件加重命名,避免半成品被读取

生产中流传的文件经常被下游程序监听,如果接收方把数据直接写最终文件,写着写着下游就打开了,读到的必然是不完整的文件。这个资源对每个接收任务维护一个.part文件,所有块都确认完成后再os.replace成最终名字。

os.replace在同一个文件系统内是原子操作,进程崩溃也不会出现“目标文件写到一半”的状态。这个模式同时解决了另一个问题:断点续传时.part文件本身就是断点偏移量的记录载体,不需要额外设计状态文件。实际部署中我会再加一条规矩:服务端对超过 24 小时没有更新的.part文件做清理回收,不然磁盘会被半成品占满,这是运维最容易忽略的存储风险。

6. 验证与调优:把吞吐和数据完整性一起测出来

6.1 三个能直接跑的验证手段

拿到这份资源后,我习惯按三步验证:先测小文件确认协议正确,再测大文件确认吞吐,最后做断连测试确认重传和原子落盘生效。

# 生成一个 1GB 测试文件 dd if=/dev/urandom of=test.bin bs=1M count=1024 # 终端 A:启动服务端 python3 server.py # 终端 B:启动客户端 python3 client.py 127.0.0.1 9000 ./test.bin # 传输完成后对比完整性 md5sum test.bin recv.bin

小文件用dd if=/dev/zero生成全零文件测不出校验逻辑,一定要用/dev/urandom。全零文件块之间没有差异,CRC 碰撞概率会异常升高,误导你低估校验开销。断连测试就是在客户端传输过程中用Ctrl+C杀掉客户端再重跑续传命令,重点观察.part文件是否正确保留、服务端是否从断点继续。

6.2 内核参数与 Buffer Size 调优

传输速度上不去,先别怀疑代码,九成是内核 TCP 缓冲区上限压着。Linux 默认的发送和接收缓冲区不大,传大文件要放开:

# 查看当前上限 sysctl net.core.wmem_max net.core.rmem_max # 临时放开到 8MB(重启后失效) sysctl -w net.core.wmem_max=8388608 sysctl -w net.core.rmem_max=8388608

内核参数放开后,再把应用层的 socket 缓冲区调上去:sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 262144)。块大小从 64 KB 提到 256 KB 时,这两层必须同步调,否则 sendall 内部会被拆成多次小发送,先触发半包再触发 Nagle,传输速度不升反降。调整的顺序一定是:先确认内核上限,再调 socket 缓冲,最后调块大小。

6.3 一个值得养成的验证习惯

从那以后,我每次上线这类 TCP 传输工具,都会强制走一遍完整流程:生成随机大文件、启动服务端、跑客户端、传输后立即md5sum对比、然后主动断连一次验证.part续传。整套操作一分钟不到,但能同时把粘包处理、校验逻辑、原子落盘三条链路全测到位。文件传输这类工具,功能看着简单,真正偷懒一次,生产环境就会用一次数据损坏来教育你。希望这套步骤和这份资源里的实现,能帮你把坑都提前踩平。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询