做数据接入这些年,我越来越发现一个反直觉的事实:很多看似"古老"的技术,恰恰在最关键的生产环节里活得最滋润。FTP就是这样。一提到FTP,很多人第一反应是"这玩意儿还没淘汰?",可真到了给银行对接清算文件、给气象部门拉观测数据、给制造企业同步MES产量记录的时候,对面给你的往往就是一张写着IP、端口、用户名、密码的接入说明,末尾加一句"文件放在/pub/目录下,自己拉"。
于是我花了两周时间,从零做了一个FTP数据采集模块,支撑了后来十几个数据源的稳定接入。这篇东西不是FTP入门科普,而是一份完整的实战记录:包括协议工作机制、增量同步设计、Python实现细节、断点续传、踩坑实录,以及让采集模块从"能跑"变成"可运维"的全套方案。适合正在接第一个FTP数据源的同学,也适合想把采集代码从脚本改造成正式模块的开发者。
1. FTP采集为什么至今没退休:还在用它扛大梁的场景
先说一个现实场景。我之前做某个金融机构的行情数据接入,对方的对接文档里赫然写着"FTP服务器地址: 端口:21,用户名:xxx"。我当时愣了几秒,后来发现这完全不是个例。证券行情文件、海关回执、电商平台商家的对账单、制造业的设备产量数据、气象观测站的逐时资料、广电系统的节目单文件……只要涉及跨机构批量数据交换,FTP仍然是大规模存在的事实标准。
原因是多方面的。FTP协议足够老、足够简单,几乎所有操作系统都自带客户端和服务端,部署成本极低;在内网环境下,它的安全隐患没有想象中那么突出,很多传统企业根本不会为了文件交换单独搭建一套ESB或者对象存储;更重要的是,很多对端系统是多年前建成的,改造他们比我们自己写个采集模块麻烦得多。所以做数据采集的人,绕不开FTP。
理解FTP,关键在于理解它的"双通道"机制。一条是控制通道,用来发命令,比如USER、PASS、CWD、LIST、RETR;另一条是数据通道,真正传文件内容。这个设计是几十年前为了兼顾"命令交互"和"大数据块传输"做的经典折中,但也正是它导致了今天所有折腾人的问题——数据通道到底怎么建立?
主动模式(Active Mode)是服务端主动连回客户端的某个端口,这在今天的NAT和防火墙环境下基本没法用;被动模式(Passive Mode)是客户端主动连服务端开放的一个临时数据端口,穿透性更好,绝大多数现代采集模块都优先使用被动模式。但被动模式同样有坑,尤其当FTP服务端部署在多网卡或负载均衡后面时,它返回给你的数据端口IP可能是内网地址,客户端根本连不通。这个我后面单独用一节细说。
如果你所在的环境可以选型,我强烈建议优先问对方是否支持FTPS(FTP over TLS)或者SFTP。很多数据供应商其实自己也没意识到可以开通更安全的通道,多问一嘴,可能有意外收获。但本篇的主线还是在FTP本身。
2. 采集模块的技术决策:动手写代码前先想清楚这几件事
很多人接到"做个FTP采集"这个任务后,第一反应都是打开编辑器写一个ftplib的demo,能连上、能下载、完事。我在第一版也是这么干的,但后来踩了大跟头。现在我的习惯是,先列一张需求清单,把模块边界划清楚再动手。
2.1 需求清单:什么才是这个模块的"必备项"
| 需求项 | 说明 | 优先级 |
|---|---|---|
| 连接管理 | 支持IP+端口、超时时间配置、断线自动重连 | P0 |
| 目录遍历 | 支持递归遍历远端目录,区分文件和文件夹 | P0 |
| 文件下载 | 二进制模式下载,支持REST断点续传 | P0 |
| 增量同步 | 只拉取新增或变更过的文件 | P1 |
| 编码兼容 | 远端文件名可能是GBK编码,需要兼容 | P1 |
| 日志与重试 | 任务失败按策略自动恢复 | P1 |
| 告警 | 多次失败或文件迟到时通知到人 | P2 |
| 并发控制 | 多文件、多数据源并行拉取 | P2 |
| 幂等落盘 | 重复拉取不产生脏数据,下游可安全消费 | P1 |
这张表看起来规矩,其实是我被生产事故教育出来的。第一版脚本只有三个函数:连接、遍历、下载,连日志都没有。上线第三个月,对端FTP服务器做了一次迁移,连接超时从原来的30秒变成直接拒连,我的采集进程卡在服务器上整整两天没人发现——因为脚本在while True里无限重试,但没日志没告警。从那时候我才真正意识到:FTP采集模块的复杂度根本不在"下载文件"这个动作上,而在对整个生命周期的管理。
2.2 增量同步:四种方案怎么选
增量同步是采集模块的核心需求,你要解决的核心问题是"远端有那么多文件,怎么知道哪些是新的"。
- 按"文件名+大小+修改时间"对比:本地维护一个已采集清单,远端文件的三个属性完全一致就跳过。
- 按修改时间:只拉取远端mtime晚于上次执行时刻的文件。简单,但有个隐患——如果对端传输过程中文件mtime会变,容易漏拉或重拉。
- 按文件名规则:很多数据供应商约定每天固定命名,比如
data_20240412.csv,直接用日期正则匹配即可,最轻量。 - 按内容哈希:对远端文件做MD5或SHA256对比,最准确,但代价也最大,需要把整个文件拉一遍才能算hash,一般只用于核对,不做增量判断。
我在实际项目里用的组合策略是:先按文件名规则过滤,再用"大小+修改时间"确认是否需要重新拉取。这里要特别注意:如果文件在服务端还在写入中(对端一边生成一边往FTP传),文件大小和mtime会一直变化,容易把半截文件拉下来。稳妥做法是,看到文件mtime是最近几分钟内的,先跳过,等下一个采集周期再处理。宁可多等一个cycle,不要拉回一个坏文件。
2.3 模块分层设计:让FTP源可以被替换
后来我把采集模块梳理成四层,这个结构我强烈建议照抄:
- 连接层:负责建立、保持、重连FTP连接,屏蔽协议细节,向上提供
connect()/disconnect()/ensure_connected()。 - 目录层:负责递归遍历远端目录、解析文件列表(兼容MLSD和LIST两种格式)、过滤目录项。
- 采集层:负责增量判断、断点续传、并发调度,只依赖目录层给出的文件元数据,不直接碰socket。
- 落地层:负责下载临时文件、原子rename、写采集元数据、维护幂等。
这样分层最大的好处是:某天数据源从FTP换成SFTP,你只需要替换连接层和目录层,上面的增量判断和落盘逻辑完全不用动。换过一轮你就知道这个设计多值钱了。
3. Python实现的关键细节:ftplib之外还要补哪些轮子
Python标准库的ftplib提供了基础的FTP客户端能力,但就像所有标准库一样,它只给你最原始的积木。要把采集模块做稳,还得自己在上面补不少东西。
3.1 连接、编码与断线重连
基础连接人人都会:
from ftplib import FTP import socket ftp = FTP() ftp.connect(host, port, timeout=30) ftp.login(user, password) ftp.encoding = 'utf-8'重点在于encoding。很多Windows IIS的FTP服务端默认用GBK编码文件名,你如果不设置ftp.encoding = 'gbk',遍历目录时中文文件名全是乱码,正则匹配直接失效。但也不能一刀切设成GBK,因为有些Linux服务端是UTF-8。我的做法是把编码做成配置项,部署时候根据数据源实际情况填,默认utf-8,遇到中文乱码优先改成gbk试试。
断线重连比看起来复杂。FTP连接是有状态的:当前目录、登录状态、数据连接都保存在会话里。网络一抖,socket.timeout或者EOFError一抛,整条会话就废了。我第一次写重连逻辑时,只在下载函数外面包了一层try-except,断线后重新login,但忘了恢复cwd到目标目录,结果重连后下载的全是根目录下同名文件——来源还是错的。后来我把连接封装成连接池,每次使用前检查存活,断线就整体重建并重新cwd,才彻底解决。
3.2 目录遍历:MLSD优先,LIST兜底
遍历目录最简单的是ftp.nlst(path),它返回纯文件名字符串列表,但坑也最多:不区分文件和目录、有些服务端对特殊字符处理有问题、返回结果还不带任何元数据。我建议用MLSD命令,它返回标准化的文件元数据,包括类型、大小、mtime,而且能直接区分文件和目录:
def list_ftp_dir(ftp, path): items = [] try: for entry in ftp.mlsd(path): items.append(entry) except: # 服务端不支持MLSD,降级到LIST解析 lines = [] ftp.retrlines('LIST ' + path, lines.append) items = parse_list(lines) return items注意,MLSD不是所有服务端都支持,报错后要降级到LIST。而LIST的输出格式因服务端而异:Linux vsftpd输出的是Unix长列表格式,Windows IIS输出的是类似DOS的格式。解析时至少要兼容这两种,否则换一个数据源就崩。我在解析函数里分别处理了这两类特征。
3.3 二进制下载与断点续传
下载文件必须用二进制模式retrbinary,不要用retrlines——那是给文本用的,会把二进制文件读坏。断点续传的关键是rest参数:
ftp.retrbinary('RETR ' + remote_filename, handler, rest=local_filesize)rest参数告诉服务端从远端文件的哪个字节开始传。本地临时文件已经下载了多大,就从多大开始拉。但这里有个我实际踩过的坑:不是所有服务端都正确实现REST。我遇到过一台服务端,执行REST后数据流偏移没有被正确设置,导致续传后本地文件中间多了一段重复数据。
所以我的续传逻辑比教科书版本更谨慎:
- 先用
SIZE命令查远端文件大小; - 如果远端大小小于本地已下载大小,说明源文件被覆盖或变更过,直接丢弃本地临时文件,重新拉;
- 如果远端大小大于本地已下载大小,才基于本地大小续传;
- 续传完成后,最终大小必须等于SIZE返回的远端大小,否则判失败。
这样下来,续传的可靠性才真正靠谱。
3.4 原子落盘:避免污染下游消费
采集模块的最终产物是本地文件,但这个文件从"下载中"到"可消费"之间必须有个安全过渡。我始终坚持"先写临时文件,再原子rename":
tmp_path = local_path + '.part' final_path = local_path # 下载完成后 os.replace(tmp_path, final_path)os.replace在Linux和Windows上都是原子操作,下游进程扫描目录时,永远只会看到完整的文件,不会看到半截文件。写采集元数据表也放在rename之后,这样即使进程在rename之前崩溃,重新扫描时会发现正式文件不存在,于是重新拉取,整个过程是幂等的。
4. 踩坑实录:被动模式、NAT、保活与那些不听话的对端
如果前面的章节是"怎么设计",这一章就是"实际生产环境里它到底怎么折腾你"。
4.1 被动模式与NAT的恩怨
被动模式下,客户端发PASV命令,服务端会返回一个IP和端口,告诉客户端"你连这个数据地址"。问题来了:FTP服务端如果部署在多网卡机器上,或者挂在负载均衡/端口映射后面,它返回的PASV地址经常是内网IP(比如10.0.0.x)或者配置错误的公网IP。你客户端在另一个网段,直接卡死在"等待数据连接"。
我遇到的最典型场景:供应商的FTP服务器在一台双网卡机器上,一张网卡面向办公网,另一张面向业务网。PASV返回的是业务网IP,而我们的采集服务器只能到办公网——数据通道怎么也建不起来。
解决办法比较底层:重写ftplib的makepasv方法,拿到PASV响应的IP后判断,如果是内网保留地址,就替换为控制连接的目标IP继续连数据端口。Python的ftplib默认不自动做这个替换,手动改造后可靠性大幅提升。
from ftplib import FTP class FtpPatched(FTP): def makepasv(self): host, port = super().makepasv() # 如果返回的是内网IP,替换为控制连接的目标主机 if host.startswith(('10.', '192.168.')): host = self.host return host, port这个patch虽然只有几行,但救过我好几次。你要是碰到"FTP能登录,但LIST就卡住"的诡异问题,优先怀疑PASV地址。
4.2 保活:服务端悄悄踢人
很多FTP服务端有闲置超时配置,常见的vsftpd默认五分钟内没有命令就断开连接。如果采集任务里有长时间计算、或者你不小心用了单线程同步方式处理,卡在本地处理逻辑上超过五分钟,再回去下文件就收到425 Can't open data connection。
最直接的保活方案:定义装饰器或者统一的连接调用入口,距离上次命令超过两分钟就发一条NOOP心跳。NOOP是一条空操作命令,服务端不会做任何事,但能刷新闲置计时器。
我当年遇到这个坑时,现象特别具有迷惑性:脚本运行了半小时后,第一个文件正常,第二个文件开始报TimeoutError,然后所有文件报错,但重启脚本又好了。排查了很久才意识到是闲置超时——不是因为"脚本写错了",而是因为"脚本太安静了"。
4.3 大文件中断续传:别信REST一定靠谱
小文件下载失败直接整个重下就行,几十MB也无所谓。但如果你经常面对几GB的日志包,断点续传就是刚需。REST命令本身很简单,但服务端对它的支持程度天差地别。
我前面提到过一个坑:某服务端执行REST后数据偏移不对,续传后本地文件多了一段重复数据。我的排查链路是这样的:
- 拉一个测试大文件,故意中断;
- 用rest续传;
- 对比本地文件大小和远端SIZE返回的大小——发现本地大小偏大;
- 拿某个已知内容的文件做对比,发现从rest偏移位置开始,多了一段和前面一模一样的字节。
原因是对端把数据流偏移计算错了,导致从开头重新返回了部分数据。从那以后我加了一条硬规则:续传完成后必须校验文件大小等于SIZE值,不等就删掉本地临时文件重新全量拉取。宁可多花一次全量下载的时间,也不要拿一个损坏文件去污染下游的数据仓库。
4.4 并发采集:不要在一个连接上做多线程
FTP控制通道是串行协议的,多个线程往同一个FTP连接上发命令,命令响应对立刻乱套。我见过有同事图省事,用ThreadPoolExecutor把一个目录下所有文件的下载任务丢进线程池,共用一个FTP连接,结果数据文件互相串——最后拉下来的文件内容张冠李戴。
正确做法是每个下载任务建立独立连接,一个连接内只做串行操作。如果你的并发度很高,还要注意对端服务器的承受能力。我有个经验值:同一个FTP源,并发连接数控制在3到5之间比较稳妥,太激进会把对端带宽打满,对方网管会来找你。
另外,如果采集模块做了双机部署,两个节点同时去拉同一个数据源,要么做分布式锁,要么做任务分散分配。我经历过两边同时拉同一个正在写入的文件,结果双方都只拉到一半,然后互相争抢FTP连接,把对端搞得极不稳定。后来的方案是引入中心化调度队列,每个FTP源同一时刻只允许一个采集节点执行。
5. 从"能跑"到"可运维":日志、重试、告警与幂等闭环
采集模块能不能长期稳定运行,拼的不是下载逻辑,而是运维配套。一个没有日志、没有重试、没有告警的采集程序,本质上就是在给未来埋雷。
5.1 结构化日志:不要print,要JSON
第一版采集脚本用的是print,输出全靠肉眼盯终端。后来改成logging,仍然是人肉解析。最终方案是结构化日志,每条记录输出为JSON格式,包含执行时间、FTP源标识、目录路径、文件数量、新增数、跳过数、失败数、总耗时、状态码。这样无论是ELK还是简单的grep,都能快速定位问题。
{"ts": "2024-05-12T09:30:01Z", "source": "weather_ftp", "dir": "/pub/surface", "files": 150, "new": 12, "skip": 137, "fail": 1, "cost": 32.5, "status": "partial"}5.2 重试策略:区分临时失败和永久失败
网络抖动是常态,重试是必需的。但重试不能无脑,要区分失败类型:
- 临时失败(连接超时、EOFError、425、426):可以重试;
- 永久失败(530登录失败、550文件不存在):重试一百次也白搭,应该立即告警。
我采用的策略是指数退避:第一次失败等待10秒,第二次30秒,第三次90秒,最多重试5次。每次重试前都要重置连接池——我见过不少重试代码是把旧的坏连接继续用下去,结果越重试越乱。重试成本极高,所以务必先重建连接、重新cwd,再执行任务。
5.3 幂等落盘与元数据表
采集状态建议落库而不是只存内存。我用SQLite就够,数据源多了换MySQL也行。核心是四张表:采集任务表、文件采集表、错误记录表、运行日志表。重点在文件采集表,唯一键设计为数据源标识 + 远端路径 + 文件名 + 文件大小。这样即使同一文件被多个周期扫描到,也只会采集一次,保证幂等。
幂等还有一个反向收益:谁都不怕重复执行。我把整个采集任务设计成可手动触发、可重跑的,出了问题,直接对昨天的重新执行一次,不会产生重复数据。这个特性在排查线上故障时省了无数时间。
5.4 告警分级与防报警疲劳
告警做不好,等于没做。我建议分三级:
| 级别 | 触发条件 | 通知方式 |
|---|---|---|
| INFO | 单文件失败,已自动重试成功 | 不打扰,看日志 |
| WARNING | 单文件失败且最终失败 | 汇总到日报 |
| ERROR | 任务大面积失败(超过N个文件)或文件迟到 | 即时消息/邮件 |
这里"文件迟到"是容易被忽略的监控点。很多FTP数据源是有业务节奏的,比如每天上午9点必须看到日终文件。做一个定时探针:到了9点半文件还没出现,不管有没有采集失败,都算异常。这种"该来没来"的告警,往往比"拉取失败"的告警更早暴露问题——因为很多情况下是上游根本没上传,压根没有拉取动作。
6. 我的几个体会:FTP采集模块能做十年,靠的是把边界想清楚
整套模块做完,回头看其实FTP本身并不难,难的是边界情况和对端的不确定性。我最大的体会是,永远不要高估对端系统的稳定性。很多FTP服务端不是专门为自动化接入搭的,可能是某台老服务器上顺手装的服务,超时、编码、并发、REST支持都没有严格验证过。你的采集模块必须做得比对端更宽容、更健壮:所有超时、重试次数、并发数、编码都做成配置项,一段生产环境里跑得不顺,第一件事就是调参数而不是改代码。
另一个体会是,切换生产前一定要先跑历史存量数据。我吃过亏:增量逻辑在测试环境里测了几天,目录里永远只有两三个新文件,一切正常。结果切到生产,发现远端目录有几万个历史文件,第一次全量拉取直接把对方服务器带宽打满,数据通道一片超时。后来加了个enable_backfill开关,默认关闭,需要人工确认后才开启历史补拉。现在这个开关成了每个新数据源接入的标准流程。
如果你问我在这个协议上还要投入多少,我的建议是:业务上继续用就继续做好维护,但新项目选型尽量走SFTP或者对象存储。可只要你还得跟FTP打交道,记住那条原则就够了——把连接、解析、采集、落盘、运维完全解耦,任何一层都能单独替换。做到这一步,这个模块的生命力会远远超出你的预期。