用Ruby的Net::FTP做文件同步或定时上传的开发者,多半碰到过这样的场景:脚本白天跑得好好的,一到网络抖动的高峰期就抛出connection reset或者超时异常,整个传输任务直接挂掉。FTP协议本身是双通道设计,控制连接和数据连接是分开的,而数据连接恰恰是最脆弱的一环——每次传输文件都要重新建立一次,失败概率自然高。本文就来聊聊如何给Net::FTP的数据连接建立过程加上一套可靠的重试机制。

一、为什么数据连接总是失败
要理解重试怎么做,得先弄清楚失败从哪来。FTP采用控制连接加数据连接的双通道架构,控制连接贯穿整个会话,而数据连接在每次执行retrfile、putbinaryfile这类传输命令时临时建立。RFC 959定义了主动(PORT)和被动(PASV)两种数据连接模式:主动模式下服务器反向连接客户端,几乎会被所有现代防火墙拦下;被动模式下客户端主动连服务器开放的高位端口,是现在的默认选择。
数据连接建立失败的常见原因有几类。第一是超时:服务器在高负载时响应PASV命令慢,或者开放的端口暂时不可达,客户端等不到握手完成。第二是服务器返回421或类似错误,表示暂时无法打开数据连接。第三是网络层面的瞬时故障,比如路由抖动、NAT表项过期,这类问题往往过几秒就自愈了。第四是被动端口范围被防火墙限制,服务器给出的端口恰好落在被封锁的区间。
关键点在于:这些失败大多具有瞬时性,重试一次往往就能成功。而Net::FTP默认的行为是直接把异常抛给调用方,没有任何恢复逻辑。下面是一段典型的失败代码:
require 'net/ftp'
ftp = Net::FTP.new
ftp.connect('ftp.ippipp.com', 21)
ftp.login('user', 'password')
ftp.passive = true
# 网络抖动时这里会直接抛出 Errno::ETIMEDOUT 或 EOFError
ftp.getbinaryfile('remote_data.csv', 'local_data.csv')
ftp.close
这段代码在理想环境下没问题,但一旦数据端口握手失败,整个进程就退出,之前下载到一半的文件也可能残留在磁盘上。对定时任务来说,这意味着要等下一个周期才能重跑,数据时效性大打折扣。
二、基于异常捕获的重试封装
最直接的重试思路是把传输操作包一层异常捕获,遇到可重试的异常就等待一段时间再试。Ruby标准库里的Net::FTP在数据连接失败时会抛出Errno::ETIMEDOUT、Errno::ECONNREFUSED、EOFError以及Net::FTPPermError(对应4xx响应)等异常。我们需要捕获的是前几种网络类异常,而像530登录失败这种业务错误重试也没有意义,应该让它直接冒出去。
重试的等待策略推荐用指数退避:第一次失败等1秒,第二次等2秒,第三次等4秒,避免在服务器高压时雪上加霜。同时要设置最大重试次数,防止无限循环。下面是一个完整的封装实现:
require 'net/ftp'
require 'logger'
class FtpRetryClient
RETRIABLE_ERRORS = [
Errno::ETIMEDOUT,
Errno::ECONNREFUSED,
Errno::EHOSTUNREACH,
Errno::ENETUNREACH,
EOFError,
IOError,
Net::FTPPermError,
Net::FTPTempError
].freeze
def initialize(host, username, password, options = {})
@host = host
@username = username
@password = password
@max_retries = options.fetch(:max_retries, 3)
@base_delay = options.fetch(:base_delay, 1)
@logger = options.fetch(:logger, Logger.new(STDOUT))
end
# 带重试的传输操作
def with_retry(operation_label)
attempts = 0
begin
attempts += 1
yield
rescue *RETRIABLE_ERRORS => e
if attempts > @max_retries
@logger.error("#{operation_label} 失败,已达最大重试次数: #{e.class} #{e.message}")
raise
end
delay = @base_delay * (2 ** (attempts - 1))
@logger.warn("#{operation_label} 第#{attempts}次失败(#{e.class}),#{delay}秒后重试")
sleep delay
retry
end
end
def download(remote_path, local_path)
with_retry("下载 #{remote_path}") do
connect_and_login do |ftp|
ftp.getbinaryfile(remote_path, local_path)
end
end
end
private
def connect_and_login
ftp = Net::FTP.new
ftp.connect(@host, 21)
ftp.login(@username, @password)
ftp.passive = true
# 设置较短的开放超时,避免单次卡太久
ftp.open_timeout = 15
ftp.read_timeout = 60
begin
yield ftp
ensure
ftp.close rescue nil
end
end
end
# 使用示例
client = FtpRetryClient.new('ftp.ippipp.com', 'user', 'password')
client.download('reports/sales.csv', './sales.csv')
这个实现有几个细节值得注意。connect_and_login放在重试块内部,意味着连接级失败也会触发重连,而不只是传输级重试——因为控制连接断开时数据连接必然无法建立,必须整体重来。ensure里关闭连接保证了失败后不会有半开的socket泄漏。异常列表里包含Net::FTPPermError时要小心:530密码错误这类永久性错误重试是浪费,如果你的服务器经常返回这种错误,应该把它从列表里去掉,改为只捕获明确的4xx临时错误。
三、进阶技巧与工程化建议
除了基础的异常重试,还有几个实践层面的改进点能显著提升稳定性。第一是主动模式与被动模式的自动切换:某些老旧的FTP服务器配置的被动端口区间正好被客户端网络封禁,这时切换到主动模式反而能通。可以在重试逻辑中记录当前模式,每失败两次切换一次:
def connect_and_login
ftp = Net::FTP.new
ftp.connect(@host, 21)
ftp.login(@username, @password)
# 根据当前重试次数动态切换模式
ftp.passive = (@mode_index.even?)
yield ftp
ensure
ftp.close rescue nil
end
def download(remote_path, local_path)
attempts = 0
begin
attempts += 1
@mode_index = (attempts / 2).to_i
connect_and_login { |ftp| ftp.getbinaryfile(remote_path, local_path) }
rescue *RETRIABLE_ERRORS => e
raise if attempts > @max_retries
sleep @base_delay * (2 ** (attempts - 1))
retry
end
end
第二是超时参数的合理设置。Net::FTP继承自Net::Protocol,支持open_timeout和read_timeout两个参数。前者控制TCP连接建立的时间上限,后者控制读取数据的等待上限。数据连接建立阶段卡住,往往就是open_timeout没设置导致的无限等待,务必显式配置。
第三是大文件的断点处理。重试虽然能重新建立连接,但默认会从头开始传。可以在每次重试前检查本地已下载的字节数,利用retrbinary的回调配合REST命令实现断点续传,避免大文件反复重传浪费带宽。同时建议把下载先写到临时文件,成功后再原子重命名,防止半成品文件被下游系统误读。
最后提醒一点:如果对传输可靠性要求很高,与其在FTP上不断打补丁,不如评估迁移到SFTP或FTPS。FTP的明文传输和双通道设计在今天的网络环境下天然吃亏,Net::SFTP这类库基于单一SSH通道,连接管理的复杂度低得多。不过对于无法改动既有服务器的场景,本文的重试机制配合指数退避和模式切换,已经足以应对绝大多数瞬时故障了。
Ruby Net::FTP数据连接重试FTP超时处理修改时间:2026-09-10 19:34:42