Java 应用把文件推送到 SFTP 服务器,这个需求在企业系统里出现的频率高得惊人。银行对账文件、保险保单回传、物流面单下发、报表定时归档,几乎每个对接外部机构的项目都会碰到。我做过不下十个类似需求,从早期用 JSch 手撸代码,到后来换成更现代的 SSH 库,中间踩过的坑能写满两页纸。这篇文章就把"Java 上传文件至 SFTP 服务器"这件事彻底拆开讲清楚,从协议原理、库选型、代码实现、异常处理到性能调优,全部基于真实项目经验。不管你是第一次接这个需求的新手,还是想优化现有实现的老手,都能从里面找到能直接抄作业的东西。
1. 先搞清楚 SFTP 到底是什么,别和 FTP 混为一谈
1.1 SFTP 与 FTP、FTPS 的本质区别
很多人一上来就写代码,结果连协议都没分清,后面排查问题全靠猜。SFTP 的全称是 SSH File Transfer Protocol,注意它虽然叫"File Transfer Protocol",但它和传统的 FTP 完全是两套东西。FTP 是独立协议,用 21 端口做控制、20 端口做数据传输,明文传输,密码和文件内容在网络上是裸奔的。FTPS 是在 FTP 基础上套了一层 TLS 加密,算是给 FTP 打了补丁。而 SFTP 是跑在 SSH 协议之上的子系统,默认走 22 端口,整个会话都在加密通道里,认证、传输、目录操作全部加密。
这个区别带来的直接影响是:SFTP 只需要一个端口,防火墙配置简单;而 FTP 的被动模式需要开放一段端口范围,运维经常为此头疼。另外 SFTP 的认证方式除了密码,还支持公钥认证,安全性高一个档次。我在实际项目里,凡是对方机构稍微有点安全意识的,基本都要求用 SFTP 加公钥认证,密码认证都算降级方案。
| 对比项 | FTP | FTPS | SFTP |
|---|---|---|---|
| 底层协议 | 独立协议 | FTP + TLS | SSH 子系统 |
| 默认端口 | 21(控制)+ 20(数据) | 21 + 数据端口 | 22 |
| 加密方式 | 无 | TLS | SSH 全通道加密 |
| 认证方式 | 密码 | 密码/证书 | 密码/公钥 |
| 防火墙友好度 | 差(被动模式需端口段) | 差 | 好(单端口) |
| 断点续传 | 支持 | 支持 | 支持 |
1.2 为什么企业对接偏爱 SFTP
从对接方的角度看,SFTP 几乎是"零配置成本"的安全方案。对方给你一个主机地址、一个端口、一个账号,再配一把公钥,你就能连了。不需要像 FTPS 那样折腾证书链,也不需要像 FTP 那样协调端口开放。而且 SFTP 天然支持目录遍历、文件重命名、权限设置这些操作,对于"上传到临时目录再原子重命名"这种常见模式支持得很好。
我在一个对账系统里就吃过亏:早期图省事用了 FTP,结果对方安全审计不通过,要求整改。整改成本不只是换协议,还包括重新协调网络策略、重新做认证对接、重新跑一遍联调测试。所以我的建议很明确:新项目一律上 SFTP,别碰 FTP,哪怕对方说"我们 FTP 也能用"。
1.3 SFTP 上传的典型业务场景
说几个我真实遇到过的场景,帮你判断自己的需求属于哪一类。第一类是定时批量上传,比如每天凌晨把前一天的交易明细打包上传,特点是文件大、频率低、对可靠性要求高,失败要能重试。第二类是实时单文件推送,比如用户下单后立刻把订单文件推给对方,特点是频率高、文件小、对延迟敏感。第三类是大文件分片上传,比如视频、镜像包,特点是单个文件几个 G,必须考虑内存占用和断点续传。
不同场景对代码的要求完全不同。批量上传可以容忍几秒的建连开销,实时推送就得考虑连接池或者长连接复用,大文件上传则必须用流式处理,绝对不能把整个文件读进内存。后面讲代码实现时我会分别说明。
2. Java 侧的技术选型:JSch、SSHJ 还是 Apache Commons VFS
2.1 三个主流库的横向对比
Java 生态里做 SFTP 上传,绕不开三个库:JSch、SSHJ、Apache Commons VFS。JSch 是元老级选手,2002 年就有了,几乎所有的老项目都在用它,但它有个致命问题——原作者早就停止维护了,最后一个正式版本停在 0.1.55,后来社区出了个 fork 叫 mwiede/jsch 在续命。SSHJ 是后起之秀,API 设计更现代,异常体系更清晰,维护也活跃。Commons VFS 是个抽象层,底层可以挂 JSch 或 SSHJ,适合需要统一文件访问接口的场景,但单纯做 SFTP 上传有点重。
| 维度 | JSch | SSHJ | Commons VFS |
|---|---|---|---|
| 维护状态 | 原版停更,有社区 fork | 活跃 | 活跃 |
| API 友好度 | 一般,偏底层 | 好,链式调用 | 抽象层,学习成本高 |
| 异常体系 | 粗糙,多为 JSchException | 细分清晰 | 统一异常 |
| 大文件支持 | 需手动流式处理 | 原生流式 | 支持 |
| 依赖体积 | 小 | 中等 | 较大 |
| 推荐场景 | 老项目维护 | 新项目首选 | 多协议统一访问 |
我的选型结论很直接:新项目用 SSHJ,老项目如果 JSch 跑得稳就别动。SSHJ 的 API 写起来舒服太多,比如上传一个文件就是sftp.put(localPath, remotePath)一行,异常也能精确捕获到是认证失败还是网络超时。JSch 的代码则要写一堆 ChannelSftp 的样板,异常还经常糊成一团。
2.2 依赖引入与版本选择
用 Maven 的话,SSHJ 的依赖这样引:
<dependency> <groupId>com.hierynomus</groupId> <artifactId>sshj</artifactId> <version>0.38.0</version> </dependency>版本选择上有个坑要提醒:SSHJ 0.38.0 之后对某些加密算法的默认支持有调整,如果对方服务器是老旧的 SSH 服务,可能会握手失败。遇到这种情况不要急着降版本,先看日志里报的是哪个算法协商失败,然后在配置里显式开启对应算法。我遇到过一次对方只支持ssh-rsa签名算法,而新版 SSHJ 默认禁用了它,加上一行配置就解决了。
如果非要用 JSch(比如公司安全扫描要求用特定 fork),依赖这样引:
<dependency> <groupId>com.github.mwiede</groupId> <artifactId>jsch</artifactId> <version>0.2.20</version> </dependency>注意 groupId 是com.github.mwiede,不是原来的com.jcraft,这是社区维护的 fork,修了不少原版的 bug,还支持了新的加密算法。
2.3 选型时容易忽略的隐性成本
选库不能只看 API 好不好写,还要看几个隐性成本。第一是加密算法兼容性,不同库对算法套件的默认支持不一样,对接老服务器时经常要调。第二是连接复用能力,实时推送场景下每次上传都新建连接开销太大,要看库是否支持连接池或者长连接。第三是日志可观测性,出问题时能不能打出足够详细的握手和传输日志,直接决定排查效率。
SSHJ 在这三点上表现都不错,它内置了详细的日志开关,把com.hierynomus.sshj的日志级别调到 DEBUG 就能看到完整的握手过程。JSch 的日志要靠设置JSch.setLogger()自己实现,稍微麻烦点。这些细节在选型文档里通常不会写,但真出问题时就是救命稻草。
3. 核心实现:从建连到上传的完整代码拆解
3.1 基于 SSHJ 的标准上传实现
先上一段我在生产环境用了两年的 SSHJ 上传代码,结构清晰,异常处理到位:
import net.schmizz.sshj.SSHClient; import net.schmizz.sshj.sftp.SFTPClient; import net.schmizz.sshj.transport.verification.PromiscuousVerifier; import net.schmizz.sshj.userauth.keyprovider.KeyProvider; import java.io.File; import java.io.IOException; import java.util.concurrent.TimeUnit; public class SftpUploader { private final String host; private final int port; private final String username; private final String privateKeyPath; private final int connectTimeoutMs; public SftpUploader(String host, int port, String username, String privateKeyPath, int connectTimeoutMs) { this.host = host; this.port = port; this.username = username; this.privateKeyPath = privateKeyPath; this.connectTimeoutMs = connectTimeoutMs; } public void upload(String localFilePath, String remoteDir, String remoteFileName) throws IOException { SSHClient ssh = new SSHClient(); try { ssh.addHostKeyVerifier(new PromiscuousVerifier()); ssh.setConnectTimeout(connectTimeoutMs); ssh.setTimeout((int) TimeUnit.SECONDS.toMillis(30)); ssh.connect(host, port); KeyProvider keyProvider = ssh.loadKeys(privateKeyPath); ssh.authPublickey(username, keyProvider); try (SFTPClient sftp = ssh.newSFTPClient()) { sftp.put(new File(localFilePath).getAbsolutePath(), remoteDir + "/" + remoteFileName); } } finally { try { ssh.disconnect(); } catch (IOException ignored) { } } } }这段代码有几个关键点值得展开。PromiscuousVerifier是"信任所有主机密钥"的验证器,生产环境严格来说应该用KnownHosts做校验,但很多对接场景对方不提供主机指纹,只能用宽松模式。这是个安全权衡,我在下面注意事项里会细说。setConnectTimeout和setTimeout分别控制建连超时和读写超时,两个都要设,只设一个容易在特定环节卡死。
3.2 密码认证与公钥认证的写法差异
公钥认证是首选,但有些对接方只给密码,代码要能兼容。SSHJ 里密码认证就一行:
ssh.authPassword(username, password);公钥认证稍微复杂点,如果私钥有密码保护,loadKeys的时候要传 passphrase:
KeyProvider keyProvider = ssh.loadKeys(privateKeyPath, passphrase); ssh.authPublickey(username, keyProvider);这里有个坑:私钥格式。SSHJ 支持 OpenSSH 格式和 PEM 格式,但新版 OpenSSH 默认生成的私钥是OPENSSH PRIVATE KEY格式,老版本 SSHJ 可能不认。如果报"unsupported key format",要么用ssh-keygen -p -m PEM转成 PEM 格式,要么升级 SSHJ 版本。我在一个项目里就因为对方给的私钥是新格式,折腾了半天才发现是格式问题。
3.3 大文件上传的流式处理
前面那段代码用sftp.put(File, String),SSHJ 内部会做流式读取,不会把整个文件读进内存,所以大文件也能扛。但如果你想更精细地控制,比如显示上传进度、做分片校验,可以用RemoteFile手动写:
try (SFTPClient sftp = ssh.newSFTPClient(); RemoteFile remoteFile = sftp.open(remotePath, EnumSet.of(OpenMode.WRITE, OpenMode.CREAT, OpenMode.TRUNC)); FileInputStream fis = new FileInputStream(localFile)) { byte[] buffer = new byte[8192]; int read; long total = 0; while ((read = fis.read(buffer)) != -1) { remoteFile.write(total, buffer, 0, read); total += read; // 这里可以回调进度 } }缓冲区大小 8192 字节是个经验值,太小系统调用频繁,太大内存占用高。实测 8K 到 64K 之间性能差异不大,8K 足够稳。注意OpenMode.TRUNC一定要加,否则同名文件上传会变成覆盖写但不清空尾部,导致文件内容错乱——这个坑我踩过一次,排查了半天才发现是少了 TRUNC。
3.4 上传到临时目录再原子重命名
这是企业对接里的标准做法,强烈建议采用。直接往目标文件名上传,如果传到一半断了,对方可能读到一个残缺文件。正确姿势是先传到xxx.tmp,传完再rename成正式文件名。SFTP 的 rename 在同一文件系统内是原子操作,对方要么看到完整文件,要么看不到,不会读到半截。
String tmpPath = remoteDir + "/" + remoteFileName + ".tmp"; String finalPath = remoteDir + "/" + remoteFileName; sftp.put(localFilePath, tmpPath); sftp.rename(tmpPath, finalPath);这个模式在银行、保险这类对数据完整性要求极高的场景里几乎是强制要求。我在一个保单回传项目里,对方明确要求必须用临时文件加重命名,否则拒收。
4. 实操中的坑与排查手册
4.1 认证失败的五大原因
认证失败是最高频的问题,报错通常是Auth fail或者Auth cancel,但原因五花八门。我整理了一张速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| Auth fail,密码认证 | 密码错误或账号被锁 | 用命令行 sftp 客户端验证同一账号 |
| Auth fail,公钥认证 | 公钥未配置到对方 authorized_keys | 让对方确认公钥已追加 |
| Auth fail,公钥认证 | 私钥格式不兼容 | 转 PEM 格式或升级库版本 |
| Auth fail,公钥认证 | 私钥有 passphrase 但没传 | 检查 loadKeys 是否传了 passphrase |
| Auth cancel | 认证方式不被服务器接受 | 确认服务器允许 publickey 或 password |
排查认证问题最有效的方法是先用命令行验证。在服务器上执行sftp -P 端口 -i 私钥 用户名@主机,如果命令行能连上而 Java 连不上,问题一定在代码或库配置;如果命令行也连不上,那就是账号或网络问题,找对方运维。这个二分法能省掉大量瞎猜的时间。
4.2 连接超时与网络策略问题
connect timed out这个报错,九成是网络不通。可能是防火墙没放行端口,可能是对方做了 IP 白名单,也可能是 DNS 解析到了错误的地址。排查顺序建议是:先telnet 主机 端口看端口通不通,不通就找网络组;通了但 Java 还超时,检查是不是代理配置干扰,或者 JVM 的networkaddress.cache.ttl导致 DNS 缓存了旧地址。
还有一种隐蔽情况:建连成功但上传卡住。这通常是setTimeout没设或者设得太短。SSHJ 的setTimeout是 socket 读写超时,如果对方服务器处理慢,超时设太短会频繁中断。我的经验值是建连超时 10 秒,读写超时 30 秒,大文件场景读写超时放宽到 120 秒。
4.3 中文文件名乱码问题
SFTP 协议本身对文件名编码没有强制规定,不同服务器实现不一样。有的用 UTF-8,有的用 GBK,还有的用 ISO-8859-1。上传中文文件名时如果对方看到乱码,基本就是编码不一致。SSHJ 默认用 UTF-8,如果对方是 GBK,需要在建连后设置:
ssh.getConnection().getTransport().setCharset("GBK");不过更稳妥的做法是避免中文文件名。我在项目里一律用"业务标识+时间戳+序号"的英文数字组合命名,中文只放在文件内容里。这样既避开了编码坑,也方便对方系统做文件匹配。如果业务方坚持要中文名,那就得和对方确认编码,并在联调时重点验证。
4.4 上传成功但文件大小为 0 或内容不全
这个现象很吓人,明明代码没报错,对方却收到空文件。常见原因有三个:一是没加OpenMode.TRUNC,覆盖写时旧内容残留导致大小对不上;二是流没正确关闭,缓冲区没 flush;三是用了异步上传但没等完成就断开连接。第三个坑最隐蔽,SSHJ 的put是同步的,但如果你自己包了线程池又没join,主线程提前disconnect就会截断传输。
排查这类问题,我的习惯是上传后立刻做一次sftp.stat(remotePath)拿远端文件大小,和本地文件大小比对,不一致就抛异常重试。这个校验成本极低,但能拦住绝大多数静默失败。
4.5 并发上传时的连接管理
实时推送场景下,如果每个文件都新建 SSH 连接,开销很大——一次完整握手包括 TCP 三次握手、SSH 协议协商、密钥交换、认证,轻松几百毫秒。高频场景下必须复用连接。SSHJ 的SSHClient不是线程安全的,不能多线程共享,正确做法是用连接池,每个线程借一个连接用完归还。
简单点可以用Apache Commons Pool2包一层,池里放已认证的SSHClient实例。要注意的是 SSH 连接有空闲超时,对方服务器可能主动断开,所以借出连接时要先做个轻量探活,比如ssh.isConnected()加一次sftp.ls("."),失败就重建。我在一个每秒几十个文件的推送场景里用了连接池,平均上传耗时从 400 毫秒降到 80 毫秒,效果立竿见影。
5. 生产级加固:重试、监控与安全
5.1 失败重试的正确姿势
网络抖动、对方服务器重启、临时限流,这些都会导致上传失败,重试是必须的。但重试不能无脑循环,要有策略。我的做法是指数退避加重试上限:第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,最多重试 3 次。这样既能扛过短暂抖动,又不会在对方彻底挂掉时疯狂打请求。
重试还要区分错误类型。认证失败、文件不存在这类错误重试没意义,直接失败告警;连接超时、传输中断这类才值得重试。SSHJ 的异常体系能区分,UserAuthException不重试,TransportException和IOException重试。这个区分很重要,我见过有系统对所有异常都重试三次,结果账号密码错了也重试,白白浪费时间还触发对方的风控。
5.2 上传过程的监控埋点
生产系统不能只靠日志,要有监控。我通常埋这几个点:上传耗时(区分建连、认证、传输三段)、文件大小、成功失败计数、重试次数。耗时数据能帮你发现性能退化,比如某天开始建连耗时从 50 毫秒涨到 500 毫秒,可能是网络链路变了。失败计数配合告警,能在对方系统出问题时第一时间知道。
埋点方式看你们的技术栈,用 Micrometer 的话就是几个Timer和Counter。关键是把建连和传输分开计时,否则出问题时不知道慢在哪一段。我在一个项目里就是靠这个拆分,发现瓶颈在认证阶段,最后定位到对方服务器负载过高导致密钥交换慢。
5.3 主机密钥校验的安全权衡
前面代码里用了PromiscuousVerifier,它信任所有主机密钥,这有中间人攻击风险。严格做法是用KnownHosts,把对方主机指纹提前录入。但现实是很多对接方不提供指纹,或者服务器重装后指纹变了也不通知,用严格校验会导致连接失败。
我的折中方案是:首次对接用宽松模式,联调稳定后切换到严格模式并固化指纹。如果对方明确无法提供指纹,那就在网络层做补偿,比如限制出口 IP、走专线。安全是个系统工程,不能只靠一个校验器。这个权衡要在项目文档里写清楚,让后续维护的人知道为什么这么选。
5.4 敏感信息管理
主机地址、账号、私钥路径这些不能硬编码在代码里,也不能明文放配置文件提交到代码库。标准做法是放配置中心或者环境变量,私钥文件权限设成 600,只有运行账号能读。我见过有项目把私钥内容直接写在 YAML 里提交到 Git,这是重大安全事故。
如果条件允许,私钥应该放在密钥管理服务里,运行时动态获取。退一步至少要做配置加密,用 Jasypt 之类的工具加密敏感配置项。这些措施看起来麻烦,但一旦泄露,对方系统的数据就全暴露了,代价远大于前期投入。
6. 几个容易被问到的细节问题
6.1 上传后如何确认对方真的收到了
SFTP 的put返回成功只代表数据写到了对方文件系统,不代表对方业务系统处理了。要确认端到端成功,通常靠两种方式:一是对方提供一个回执文件目录,你上传后轮询等回执;二是对方提供回调接口。纯 SFTP 场景下第一种更常见。轮询要有超时和最大次数,别无限等。
6.2 目录不存在怎么办
sftp.put不会自动创建远程目录,目录不存在会报No such file。稳妥做法是上传前先sftp.mkdirs(remoteDir),SSHJ 的mkdirs会递归创建,已存在也不报错。但要注意权限,有些服务器限制只能在指定目录下创建,越界会失败。
6.3 如何限制上传速度
有些场景要限速,避免占满带宽影响其他业务。SSHJ 本身没有限速 API,得自己在流式写入的循环里做。简单做法是每写 N 字节Thread.sleep一下,粗糙但有效。精确限速可以用令牌桶算法,Guava 的RateLimiter就能用。不过限速会增加上传耗时,要评估业务能否接受。
6.4 Windows 服务器路径分隔符问题
如果本地是 Windows,File.getAbsolutePath()返回的路径带反斜杠,传给 SFTP 客户端一般没问题,因为 SSHJ 会处理本地路径。但如果你自己拼远程路径用了File.separator,在 Windows 上会拼出反斜杠,远程服务器可能不认。远程路径一律用正斜杠/手动拼,别用File.separator。
7. 写在最后的实操体会
这套东西我在不同项目里迭代了好几轮,最大的体会是:SFTP 上传的难点从来不在"上传"这个动作本身,而在连接管理、异常处理和边界情况。一行put谁都会写,但要让它在生产环境稳定跑一年不出事,靠的是超时设置、重试策略、临时文件重命名、大小校验这些"外围"功夫。
还有一个建议:联调阶段一定要模拟异常。手动把网线拔了、把对方服务停了、传一个超大文件、传一个零字节文件,看看你的代码表现如何。我在每个项目上线前都会做这轮"破坏性测试",十次有八次能发现没考虑到的情况。这些测试花不了多少时间,但能避免上线后半夜被电话叫醒。
最后分享一个小技巧:把上传逻辑封装成一个独立的工具类或者服务,对外只暴露"上传本地文件到远程目录"这一个方法,内部把建连、认证、临时文件、重命名、校验、重试全包掉。业务代码调用时不用关心任何 SFTP 细节,这样既好维护,也好测试。我现在的项目里这个类叫SftpTransferService,用了三年没大改过,新增场景只是加配置,核心逻辑一直稳。