ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FTP上传进度条与断点续传实战避坑

FTP上传进度条与断点续传实战避坑

简介:这是一份面向C#初学者的FTP文件上传示例工程,聚焦在WinForms界面中实现带实时进度反馈的上传流程。示例围绕连接登录、目录切换、分块发送与进度计算展开,并将FTP操作封装为可复用的帮助类,便于读者理解上传回调、已传字节与总字节的比例换算,以及异常与连接释放等处理思路。压缩包共35个文件,以cs源代码、resx资源、config配置和sln/csproj工程文件为主,另含exe、pdb等编译产物,整体约78KB,结构紧凑,适合直接打开解决方案对照学习。目前已有869人学习下载,可用于课堂示例、课程设计或小型工具开发中的上传模块参考,帮助开发者把无进度提示的FTP上传改造成可视化交互,并在此基础上扩展重试、错误提示和分块上传等策略。

1. 带进度条的 FTP 上传:能传完与看得见,是两件事

去年帮一家做生产报表的小团队收拾过一个上传脚本:需求只有一句“每天把生成的 xlsx 推到文件服务器”,代码十几行就写完了,跑起来却天天有人问“传完没有”。原因很简单——卡在 90% 和卡在 9%,在运维眼里长得一模一样。FTP 文件上传下载这套协议几十年没大改,麻烦的从来不是STOR这条命令,而是“进度”这个数字到底代表什么:是本地读完了、内核缓冲区收下了,还是对端真的落盘了?这份 FTP 上传实例(带进度条)拆开看就三件事:控制连接怎么建、进度怎么算、断了怎么接着传。适合手上有台 FTP 服务器、要写上传脚本、又不想为这点事上重型调度框架的后端和运维同学。

2. FTP 上传的两条连接:控制通道、数据通道与模式选择

2.1 控制连接和数据连接,一次上传要开两次门

FTP 跟绝大多数应用层协议不一样的地方在于:它用两条 TCP 连接干活。控制连接常年挂在 21 端口上,从头到尾只跑文本命令和三位数响应码,USER、PASS、CWD、TYPE、PASV、STOR都走这条线;真正搬字节的数据连接是每次传输临时开一条,传完就关。所以你在日志里看到“登录成功但一上传就超时”,八成不是账号问题,而是第二条连接根本没建起来——控制通道通了只证明 21 端口通,不代表数据端口通。

这个结构带来的第一个实操后果是:任何一次上传的耗时都包含“建数据连接”的开销。如果你按文件循环上传几千个小文件,每个文件都新开一条数据连接,握手成本会比传数据本身还大。常见做法是把小文件打成 zip 再传,或者在服务端支持的情况下用一条连接连续发多个STOR——后者省的是控制连接的登录开销,数据连接照样一次一开,别指望能复用。

第二个后果是超时参数得设两层。connect(timeout=)管的是控制连接建立,数据连接的 socket 超时继承自同一个值,但当数据连接卡在“已建立、对端不读”的状态时,表现是storbinary长时间不返回,而不是抛异常。这一点在第 5 章会具体讲。

2.2 主动模式与被动模式:看谁在 NAT 后面

数据连接由谁发起,就是主动与被动的全部区别。

维度主动模式(PORT)被动模式(PASV)
谁发起数据连接服务端回连客户端客户端连服务端
客户端要求必须有服务端可达的地址和放行端口无特殊要求,能出网即可
服务端要求能出站到客户端高位端口开放一段固定的被动端口
典型翻车点客户端在 NAT 后,服务端连的是内网地址服务端防火墙没放行被动端口段

判断方法很朴素:客户端在办公网、家里、容器里,一律用被动模式;只有服务端和客户端在同一段可信内网、且客户端防火墙全关的调试场景,主动模式才可能更省事。ftplib默认就是被动模式(set_pasv(True)),这个默认值是对的,别去改。

被动模式真正要盯的是服务端的端口段配置。服务端每接受一次PASV,会在某个范围内随机开一个端口并在响应里告诉客户端“来连我这个 IP 和端口”。如果中间有防火墙只放行了 21,那么控制连接一切正常、LIST也可能偶然成功(有的服务端会把目录列表走控制连接缓存),但一上传就报425 Can't open data connection。这时候要做的不是改代码,是让服务端管理员把被动端口段固定下来并放行,同时把对外通告的地址配成公网/映射后的地址,否则客户端会拿到一个内网 IP 去连,白等。

2.3 建连的最小可用骨架

先把手动建连这段跑通,后面所有代码都挂在这上面。这段不长,但每一行都有存在的理由。

import ftplib HOST = "192.0.2.10" # 文件服务器地址 PORT = 21 USER = "uploader" PWD = "change-me" ftp = ftplib.FTP() ftp.encoding = "utf-8" # 老版 Windows FTP 服务端常见需要改成 "gbk" ftp.connect(HOST, PORT, timeout=15) # 不给 timeout,卡死时会一直挂着 ftp.set_pasv(True) # 被动模式,NAT/容器环境基本只能用这个 ftp.login(USER, PWD) ftp.voidcmd("TYPE I") # 显式声明二进制,避免行尾被改写 print(ftp.getwelcome()) # 220 欢迎语,排错第一现场

逻辑说明:connect只建立控制连接并读走 220 欢迎语;login走USER/PASS,正常返回 230;voidcmd("TYPE I")把传输类型钉死在二进制。这一行的作用被严重低估——FTP 默认是 ASCII 模式,服务端在 ASCII 模式下会做行尾转换,传二进制文件直接损坏,而且SIZE命令在很多服务端上会直接返回550 SIZE not allowed in ASCII mode。

参数说明:timeout=15是控制连接的 socket 超时,建议 10 到 30 秒之间,太短会在网络抖动时误杀,太长会让卡死场景排查变成盲等。encoding决定命令和响应里中文的编解码方式,Python 3.9 之后默认是 UTF-8,但服务端如果没开UTF8特性(FEAT命令能查到),中文文件名会往返乱码,这时候要么改成gbk试探,要么干脆用 ASCII 文件名加一张映射表,后者更省心。

提示:排错时先把ftp.getwelcome()和每一步的响应码打出来。220是欢迎、331是等口令、230是登录成功、425是数据连接开不了、550是权限或路径问题,这四个码能覆盖九成的连不上场景。

3. 进度条的实现原理:把 socket 的 send 变成可计数事件

3.1 storbinary 的回调在什么时候被触发

ftplib.FTP.storbinary(cmd, fp, blocksize=8192, callback=None, rest=None)的内部循环大致是这样:先发TYPE I,然后发PASV拿地址、连数据 socket、发STOR 文件名,接着进入一个while:从文件对象fp里读blocksize字节,sendall到数据 socket,然后立刻调用callback(buf),直到读空,关闭写方向,最后读响应拿 226。

关键点有三个。第一,callback收到的是刚发出去的那一块原始字节,不是累计值,也不是百分比,累计要你自己加。第二,回调发生在sendall返回之后,而sendall返回只代表数据进了本地内核发送缓冲区,不代表对端应用已经落盘——所以进度条走到 100% 只是“本地交出去了”,真正的完成信号是最后的 226 响应。第三,blocksize默认 8192 太小:同样传 1 GB,8 KB 要循环十几万次,每次一次系统调用加一次 Python 回调,开销肉眼可见。

blocksize的经验值:局域网 64 KB 到 256 KB,跨公网 32 KB 到 64 KB。调到 1 MB 以上在丢包环境里反而会拖慢,重传窗口撑大了恢复代价。

3.2 手写一个限频进度条

自己写的好处是零依赖、显示内容可控,坏处是所有边界都得自己兜。

import os, sys, time import ftplib def upload_with_progress(ftp, local_path, remote_path, blocksize=256 * 1024): total = os.path.getsize(local_path) # 总大小必须取本地文件,别信远端 sent = 0 start = time.monotonic() last_paint = [0.0] def cb(chunk): nonlocal sent sent += len(chunk) now = time.monotonic() # 限频到 10Hz,回调里做 IO 会直接拖慢传输 if now - last_paint[0] < 0.1 and sent < total: return last_paint[0] = now ratio = sent / total if total else 1.0 speed = sent / max(now - start, 1e-6) / 1024 / 1024 bar = "#" * int(ratio * 28) sys.stdout.write( f"\r[{bar:<28}] {ratio * 100:6.2f}% " f"{sent / 1048576:8.2f}/{total / 1048576:.2f} MB {speed:5.2f} MB/s" ) sys.stdout.flush() with open(local_path, "rb") as fp: ftp.storbinary(f"STOR {remote_path}", fp, blocksize=blocksize, callback=cb) sys.stdout.write("\n") return sent

逻辑说明:闭包里用nonlocal sent累加;last_paint用列表是为了在闭包里可变;限频判断里带and sent < total,保证最后一块一定会重绘到 100%,否则进度条会停在 99.6% 那种尴尬位置。速度用单调时钟算,别用time.time(),系统时间被校准时会算出负速度。

参数说明:blocksize=256*1024是局域网起步值,公网建议降到 65536;remote_path是相对当前工作目录的路径,含子目录的话要么先ftp.cwd(),要么直接写绝对路径(注意部分服务端对绝对路径解释成“账号根目录下的路径”,不是系统根);total来自本地os.path.getsize,文件在传输过程中被别的进程改写会导致比例失真,生产脚本里应该先读文件头算一遍哈希再传,传完比对。

3.3 tqdm 版本与显示口径的取舍

项目里已经有 tqdm 的话,没必要重造轮子:

from tqdm import tqdm def upload_tqdm(ftp, local_path, remote_path, blocksize=256 * 1024): total = os.path.getsize(local_path) with open(local_path, "rb") as fp, \ tqdm(total=total, unit="B", unit_scale=True, desc=local_path.split("/")[-1], ncols=90) as bar: def cb(chunk): bar.update(len(chunk)) ftp.storbinary(f"STOR {remote_path}", fp, blocksize=blocksize, callback=cb)

total必须精确等于文件字节数,否则 tqdm 会一直显示“未完成”或者提前跳满;unit_scale=True让它自动换算 KB/MB;ncols固定宽度,避免在 CI 日志里被拉成几千列。tqdm 自带 ETA,但它的 ETA 是按最近速率外推的全量估计,长传期间会来回跳,不要把它当 SLA 承诺。

两种实现的显示口径其实不一样,这点容易忽略:tqdm.update(len(chunk))累加的是回调次数乘以块大小,跟手写版本等价,都表示“已提交到本地 socket 的字节数”。想显示真正的“对端已确认字节”,FTP 协议里没有这个粒度——只能传完看 226,或者事后用SIZE对账。写监控的时候把这条口径写进注释,能省掉后面一轮扯皮。

4. 断点续传与失败重试:REST/APPE 的边界条件

4.1 SIZE 与 MDTM:先问清楚远端有什么

断点续传的前提是能读出远端文件已有的字节数。SIZE返回字节数,MDTM返回 UTC 修改时间,两个都是 RFC 3659 扩展命令,主流服务端都支持,但不是所有。

返回值/异常含义处理方式
整数远端存在,大小已知与本地大小比较,决定跳过、续传还是覆盖
550且提示 not a regular file远端是同名目录中止,人工确认
550且提示 SIZE not allowed当前是 ASCII 模式先voidcmd("TYPE I")再问
error_perm其他文本服务端不支持或路径不存在当作“远端不存在”,从 0 开始
网络异常控制连接抖动重连后重问,不要直接重传

SIZE有个很坑的前置条件:多数服务端在 ASCII 模式下会直接拒绝,因为 ASCII 模式下字节数没有意义。所以任何续传逻辑的第一行都应该是voidcmd("TYPE I"),而不是直接size()。

4.2 REST 续传的代码骨架与降级路径

storbinary带rest参数时会先发REST <offset>,再发STOR,告诉服务端“从第 offset 字节开始写”。前提是本地文件对象也已经seek到同一位置,两边偏移必须严格相等,差一个字节就是文件损坏。

import os, ftplib def remote_size(ftp, path): ftp.voidcmd("TYPE I") # 必须先进二进制模式,否则部分服务端拒绝 SIZE try: return ftp.size(path) # 不存在时返回 None 或抛 error_perm except ftplib.error_perm: return None def resume_upload(ftp, local_path, remote_path, blocksize=256 * 1024): lsize = os.path.getsize(local_path) rsize = remote_size(ftp, remote_path) if rsize is None: rsize = 0 # 远端没有,从头传 if rsize > lsize: raise RuntimeError(f"远端更大({rsize}>{lsize}),先确认再覆盖") if rsize == lsize: return "skipped" with open(local_path, "rb") as fp: fp.seek(rsize) # 本地偏移与 REST 偏移必须一致 if rsize == 0: ftp.storbinary(f"STOR {remote_path}", fp, blocksize=blocksize) else: try: ftp.storbinary(f"STOR {remote_path}", fp, blocksize=blocksize, rest=rsize) except ftplib.error_perm as e: # 服务端不支持 REST + STOR,退化为整传 print(f"REST 不被支持({e}),改为覆盖上传") fp.seek(0) ftp.storbinary(f"STOR {remote_path}", fp, blocksize=blocksize) return "resumed" if rsize else "uploaded"

逻辑说明:先问远端大小,再比本地大小,三种分支各自处理。rest=rsize触发的REST是紧贴下一个传输命令生效的一次性指令,执行完就失效,所以每次续传都得重新给。降级分支很关键:REST用于STOR并不是所有服务端都实现,有的会返回502或504,有的干脆忽略REST从头写——忽略的那种最危险,因为不报错,你以为续上了,实际文件被从头覆盖,最后大小对不上才被发现。

参数说明:blocksize同前;rest单位是字节,不能超过远端文件大小,也不建议设在文件末尾(等于追加空内容);如果服务端支持APPE而不支持REST,可以改成每次追加一个分片、自己维护偏移,代价是逻辑复杂度翻倍,收益只在超大文件上才明显。

4.3 传完之后的核对

传完必须核对,别信进度条。最轻的核对是重新SIZE一次,比对是否等于本地文件大小;上一档是比MDTM与本地 mtime,能抓到“同名但内容是旧版本”的情况;最狠的是下载回来算哈希,一般只在合规场景做,因为等于把流量翻倍。

def verify(ftp, local_path, remote_path): lsize = os.path.getsize(local_path) rsize = remote_size(ftp, remote_path) if rsize != lsize: raise RuntimeError(f"大小不一致 local={lsize} remote={rsize}") return True

MDTM返回的是 UTC 时间,格式YYYYMMDDHHMMSS,跟本地 mtime 比对前要统一时区,否则会稳定地差 8 小时——这种偏差不会报错,只会让你误判“文件没更新”。

5. 避坑与排查:五类高频翻车现场

5.1 登录成功但一上传就 425

现象:控制连接秒连、LIST偶尔能出结果,一执行STOR就停十几秒然后抛425 Can't open data connection,或者干脆超时无响应。

原因:被动模式的数据端口被中间防火墙拦了,或者服务端在 NAT 后面把内网地址当 PASV 响应发回来,客户端拿着一个不可达的 IP 去连。也有一种情况是服务端被动端口段被并发连接占满。

解决:先让服务端把被动端口段固定成一个区间并在防火墙上放行,同时配置对外通告地址;客户端侧确认set_pasv(True)。临时验证可以用ftp.sendcmd("PASV")看返回的 IP:port 是不是可达地址,一眼就能判断是“地址错”还是“端口不通”。

5.2 550 权限拒绝与路径歧义

现象:本地调试全通过,换到生产账号就报550 Permission denied或553 Could not create file。

原因:FTP 账号有“根目录”概念,登录后PWD显示的/往往是映射出来的虚拟根,不是系统根。用绝对路径/data/upload/x.csv时,服务端可能把它解释成<账号根>/data/upload/x.csv,而那个目录不存在。另一种是 Windows 服务端上目录存在但账号没有写权限。

解决:登录后先ftp.pwd()打印当前目录,再用ftp.cwd()逐级进入目标目录,最后用相对文件名执行STOR。这条路径最不容易出歧义。文件名里带空格、#、&时也要留神,命令是纯文本拼接的,服务端解析规则各不相同,统一重命名成[A-Za-z0-9._-]之外的字符全部替换,能省掉大量玄学问题。

5.3 中文文件名变成问号

现象:上传成功,但远端列目录一看是????.xlsx或者乱码;再上传同名文件时报550 No such file,因为客户端拼出来的名字和服务端存的名字编码不一致。

原因:控制连接上的文件名编码没有协商。客户端按 UTF-8 发,服务端按 GBK 解,往返一次就废了。FEAT里有UTF8说明服务端支持,没有就只能猜。

解决:先ftp.sendcmd("FEAT")看特性列表。支持就保持ftp.encoding = "utf-8";不支持就试gbk,两边都试不通就别硬扛,改用纯 ASCII 文件名加一张本地映射表,把原始名写在同目录的manifest.json里。文件名一旦开始出现乱码,别在编码上继续试错,成本远高于换命名规则。

5.4 进度条瞬间 100% 却等了十分钟

现象:进度条一秒跳满,之后程序卡住不动,最后报超时;或者进度条长时间停在某个百分比,日志里没有任何输出。

原因:callback统计的是“已提交到本地内核缓冲区”的字节,网络慢的时候内核缓冲区会先吃下一大截,进度条自然虚高。反过来卡住则通常是对端不读导致sendall阻塞,而timeout设成了None。

解决:把“进度 100%”和“传输完成”分成两个状态打印,完成信号只认storbinary正常返回(内部已读走 226)。connect时给一个明确超时,并在上传前给数据 socket 开 keepalive:

import socket ftp.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)

再配合blocksize从 8 KB 提到 256 KB,回调频率降下来,虚高的观感也会好很多。

5.5 续传完了文件却打不开

现象:断点续传显示成功,远端大小也和本地一致,但文件解压报错、视频播到一半花屏。

原因:三种可能。一是REST之前没进二进制模式,偏移量按 ASCII 规则算的,差了几个字节;二是服务端不支持REST+STOR但静默从头写,中间那段时间你以为是追加;三是本地文件在两次上传之间被重新生成过,前后根本不是同一个文件。

解决:REST前强制TYPE I;续传前比对SIZE和MDTM,时间戳变了就不续传,直接整传;续传完成后除了比大小,对关键文件再算一次本地哈希写进日志,下次出问题能立刻定位是不是内容层面的损坏。血泪经验是:续传功能上线前,拿一个 200 MB 以上的文件手工掐三次网,每次都比对哈希,别只测“能续上”。

6. 进阶:把上传封装成一个能重试的任务对象

散着写的上传脚本最后都会长成一坨,我的做法是先落一个UploadTask数据类,把local、remote、blocksize、retries全塞进去,再配一个带退避的执行器。重试只对“可重试异常”生效——socket.timeout、ftplib.error_temp(4xx 响应)可以重试,error_perm(5xx)重试多少次都是白搭,直接抛出去让人看。

import time, socket, ftplib RETRYABLE = (socket.timeout, ConnectionResetError, ftplib.error_temp) def run_task(ftp_factory, task, retries=3): for attempt in range(1, retries + 1): ftp = ftp_factory() # 每次重试都新建连接,不复用坏掉的会话 try: return resume_upload(ftp, task["local"], task["remote"]) except RETRYABLE as e: if attempt == retries: raise time.sleep(2 ** attempt) # 指数退避,避开服务端瞬时限流 finally: try: ftp.quit() except Exception: ftp.close()

几处细节值得单独说。重试必须新建连接,因为失败的会话里控制连接状态是未知的,继续用可能读到上一条命令的残留响应,出现“命令与响应错位”这种最难查的故障。quit()发QUIT等 221,close()只关 socket,finally里先quit再兜底close,是为了让服务端及时回收会话——很多服务端的最大连接数很小,漏关几次就把自己锁在门外。

需要加密的话,把FTP换成FTP_TLS,并且一定要在登录后调prot_p():

from ftplib import FTP_TLS ftps = FTP_TLS(timeout=30) ftps.connect(host, 21) ftps.login(user, pwd) ftps.prot_p() # 不加这行,只有口令是加密的,文件内容仍是明文 ftps.set_pasv(True)

prot_p()之后数据连接也走 TLS,代价是吞吐下降一到三成,内网大文件传输要算这笔账。自签证书环境还得传一个宽松的ssl_context,否则握手直接失败,报错信息藏在ssl.SSLError里,很容易被误判成密码错误。

从那以后我每次写上传脚本,都强制把三件事写成文件头的常量:BLOCKSIZE、TIMEOUT、NEED_VERIFY,谁改谁在提交信息里写理由。进度条只是给人看的,SIZE对账才是给系统看的——这条规矩救过我两次,一次是服务端磁盘满导致写入截断,另一次是REST被静默忽略。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进