Java 后端大文件 SFTP 流式中转模块设计与实现
文件中转服务(Transfer Service,XFER)位于来源系统与目标系统之间。来源系统向 XFER 提交文件任务,XFER 从来源 SFTP 拉取源文件,经过本地暂存和校验后上传到中转 SFTP,再通知目标系统获取文件。
本文是后端工程中大文件中转模块的实现总结,讨论范围仅限于文件传输流程、临时文件隔离、并发控制、状态恢复和连接配置规范。文中所有路径、系统名称和代码均为示例,不包含真实业务数据、真实认证信息或可直接用于生产环境的完整配置。
讨论范围聚焦 XFER 内部实现,包括任务接收后的文件下载、写入本地临时目录、MD5 文件校验、上传到 XFER-SFTP、三次失败重试、将任务状态写入数据库、临时文件清理和结果通知。来源系统如何生成文件、目标系统如何消费文件,以及两个 SFTP 服务端的内部实现不在讨论范围内。
当文件达到 20GB、30GB 级别时,XFER 同时承受网络读取、磁盘写入、MD5 全量读取和网络上传。固定大小的流式缓冲区只能控制 Java 虚拟机(Java Virtual Machine,JVM)堆内存,还需要配合磁盘空间检查、IO 并发控制、未完成文件隔离和失败恢复,才能保证中转过程可控。
1. 系统角色与术语
以下缩写围绕 XFER 的中转服务视角定义,避免"远端""本地""对端"等相对称呼造成歧义。
| 缩写 | 英文 | 含义 | 主要职责 |
|---|---|---|---|
| SRC | Source System | 来源系统 | 发起下载任务、提供源文件信息、接收处理结果 |
| XFER | Transfer Service | 文件中转服务 | 下载、暂存、校验、上传和状态管理 |
| TGT | Target System | 目标系统 | 接收文件已上传完成的通知并获取文件 |
| SRC-SFTP | Upstream SFTP | 来源 SFTP 服务 | 保存源文件,供 XFER 下载 |
| XFER-SFTP | Transfer Service SFTP | 中转 SFTP 服务 | 保存中转后的文件,供 TGT 获取 |
| XFER-Temp | Transfer Service Temporary Storage | 平台临时目录 | 保存传输过程中的临时文件 |
接口和文件流向如下。
| 发起方 | 接收方 | 交互方式 | 作用 |
|---|---|---|---|
| SRC | XFER | REST API | 提交文件下载任务 |
| XFER | SRC-SFTP | SFTP lstat/get |
查询并下载源文件 |
| XFER | XFER-SFTP | SFTP mkdir/put/rename |
上传文件,校验完成后改为最终文件名 |
| XFER | SRC | REST API | 回调任务处理结果 |
| XFER | TGT | REST/RPC API | 通知目标文件已准备完成 |
| TGT | XFER-SFTP | SFTP get |
获取最终文件 |
XFER-Temp 只承担中转职责,不长期保存文件。最终文件保存在 XFER-SFTP,TGT 只能读取上传完成并改为最终名称的文件。
2. 总体架构
3. 文件传输状态
下载和上传都需要临时状态,防止未完成文件进入后续系统。
XFER-Temp 使用以下文件名:
text
# 下载中的临时文件,任何后续流程都不能读取它
package-1.0.0.zip.downloading
# 下载完成并通过大小检查后的本地临时文件
package-1.0.0.zip
XFER-SFTP 使用以下文件名:
text
# 上传中的远端临时文件,TGT 不应读取它
package-1.0.0.zip.uploading
# 上传完成并通过远端大小检查后的最终文件
package-1.0.0.zip
4. 主流程
主流程分为两层:外层负责最多 3 次重试和最终结果处理,内层负责单次文件传输。R01~R05 表示重试编排,S01~S10 表示单次传输步骤。
4.1 三次重试与最终失败处理
原始实现中的重试范围是整个文件传输阶段,而不只是 SRC-SFTP get。一次执行包含下载、MD5 文件校验、上传 XFER-SFTP 和将任务状态写入数据库;其中任一步骤抛出异常,本次执行都会失败。
| 步骤 | 动作 | 结果 |
|---|---|---|
| R01 | 执行单次文件传输 | 调用 executeTransferTask() |
| R02 | 判断本次结果 | 成功则退出循环,失败则保存异常 |
| R03 | 判断剩余次数 | 前两次失败后继续重试 |
| R04 | 重试前等待 | 第 2、3 次执行前等待 5 秒 |
| R05 | 三次全部失败 | 更新失败状态、通知 SRC,不通知 TGT |
java
private static final int MAX_RETRY_COUNT = 3;
private static final long RETRY_DELAY_SECONDS = 5L;
public void run() {
Exception lastException = null;
// R01:最多执行 3 次;retryIndex=0 表示首次执行,不是第一次重试。
for (int retryIndex = 0;
retryIndex < MAX_RETRY_COUNT;
retryIndex++) {
try {
// R04:第 2、3 次执行前等待 5 秒,避免连接异常后立即连续冲击 SFTP。
if (retryIndex > 0) {
Thread.sleep(RETRY_DELAY_SECONDS * 1000L);
}
// R01:执行一次完整传输,而不只重试 SRC-SFTP 下载方法。
String finalRemotePath = executeTransferTask();
// R02:文件上传完成并改为最终名称后退出重试循环,后续只执行结果通知。
executeNotifications(taskId, finalRemotePath);
return;
} catch (InterruptedException e) {
// 线程中断属于停止信号,恢复标记后直接结束,不继续重试。
Thread.currentThread().interrupt();
lastException = e;
break;
} catch (Exception e) {
// R02:保存最后一次异常,供最终失败状态和 SRC 回调使用。
lastException = e;
// R03:前两次失败只记录日志,循环会进入下一次执行。
log.warn(
"文件传输失败,attempt={}, maxAttempts={}",
retryIndex + 1,
MAX_RETRY_COUNT,
e
);
}
}
// R05:3 次全部失败后统一处理失败状态,只向 SRC 返回失败结果。
markTransferFailed(lastException);
notifyUpstreamFailure(taskId, lastException);
// 文件没有完成上传并改为最终名称,因此不能通知 TGT 获取文件。
}
这里的"3 次"表示最多执行 3 次,即首次执行 1 次、失败后最多再重试 2 次。只有三次文件传输都失败时,XFER 才向 SRC 返回最终失败结果。
4.2 单次文件传输步骤
| 步骤 | 动作 | 作用 | 对应章节 |
|---|---|---|---|
| S01 | 初始化 XFER-Temp | 创建并验证临时目录 | 第 5 节 |
| S02 | 校验文件名并构造本地路径 | 阻止目录边界问题,确定当前任务文件 | 第 8 节 |
| S03 | 获取传输许可 | 限制同时执行的高磁盘和网络 IO 任务数 | 第 6 节 |
| S04 | 从 SRC-SFTP 下载 | 检查磁盘空间,以 .downloading 接收文件,完成后改为正常文件名 |
第 7、9、10 节 |
| S05 | 执行 MD5 文件校验 | 检查文件是否存在并计算 MD5 值 | 第 11 节 |
| S06 | 上传到 XFER-SFTP | 以 .uploading 上传,校验后改为最终文件名 |
第 12 节 |
| S07 | 保存传输结果 | 将最终路径、MD5 值和任务阶段写入数据库 | 第 13 节 |
| S08 | 清理当前任务临时文件 | 回收 XFER-Temp 空间,不影响其他任务 | 第 14 节 |
| S09 | 释放传输许可 | 保证后续排队任务可以继续执行 | 第 6 节 |
| S10 | 通知 SRC 和 TGT | 文件上传完成后独立通知,失败只重试通知 | 第 13、16 节 |
以下代码只表示一次传输尝试,由 4.1 节的重试循环调用。高磁盘和网络 IO 阶段、数据库状态更新和通知阶段相互分离,避免通知失败后重新传输几十 GB 的文件。
java
private String executeTransferTask() throws Exception {
// S01:初始化 XFER-Temp(对应第 5 节)。
// 该目录用于保存当前任务的 .downloading 文件和下载完成后的本地临时文件。
ensureTempDirExists(tempDir);
// S02:校验文件名并构造本地路径(对应第 8 节)。
// 只采用源路径的最后一级名称,避免外部路径跳出 XFER-Temp。
String filename = extractAndValidateFilename(sourceFilePath);
String localFilePath = tempDir + File.separator + filename;
String downloadingFilePath = localFilePath + ".downloading";
boolean permitAcquired = false;
try {
// S03:获取传输许可(对应第 6 节)。
// 许可覆盖下载、MD5 文件校验和上传,防止多个大文件任务同时占满磁盘和网络 IO。
TRANSFER_SEMAPHORE.acquire();
permitAcquired = true;
// S04:从 SRC-SFTP 下载(对应第 7、9、10 节)。
// 方法内部依次执行空间检查、流式写入、大小校验和本地文件重命名。
downloadFromUpstreamSftp(sourceFilePath, downloadingFilePath, localFilePath);
// S05:执行 MD5 文件校验(对应第 11 节)。
// 方法检查文件是否存在,并以流式方式计算 MD5 值。
String md5 = performMd5Check(localFilePath);
// S06:上传到 XFER-SFTP(对应第 12 节)。
// 先上传为 .uploading 文件,校验远端大小后再改为 TGT 使用的最终文件名。
String finalRemotePath = uploadToPlatformSftp(localFilePath, targetDir);
// S07:将传输结果写入数据库(对应第 13 节)。
// 保存最终路径和 MD5 值后,即使通知失败,也不需要重新传输文件。
markTransferSucceeded(finalRemotePath, md5);
// S08:删除当前任务的本地临时文件(对应第 14 节)。
// XFER-SFTP 已保存最终文件,后续失败不再依赖本地文件重新上传。
deleteFileQuietly(localFilePath);
// 把最终文件路径返回给外层,供成功通知使用。
return finalRemotePath;
} catch (InterruptedException e) {
// 等待许可期间被中断时恢复中断标记,并清理当前任务文件。
Thread.currentThread().interrupt();
cleanupLocalFiles(localFilePath, downloadingFilePath);
throw e;
} catch (Exception e) {
// S08:任一传输步骤失败时,只清理当前任务拥有的文件。
cleanupLocalFiles(localFilePath, downloadingFilePath);
throw e;
} finally {
// S09:释放传输许可(对应第 6 节)。
// 只有成功获取许可后才能释放,避免错误增加 Semaphore 许可数量。
if (permitAcquired) {
TRANSFER_SEMAPHORE.release();
}
}
}
文件在 XFER-SFTP 上完成上传、大小检查和重命名后进入 FILE_READY 阶段,再执行通知。通知过程不再占用大文件传输许可。
java
private void executeNotifications(String taskId, String finalRemotePath) {
// S10:通知 SRC 文件处理结果(对应第 13、16 节)。
// 失败状态写入通知表,由后台任务按事件唯一键重试。
notifyWithRetryRecord(
"SRC_RESULT_CALLBACK",
() -> notifyUpstream(taskId, finalRemotePath)
);
// S10:通知 TGT 文件已经上传完成(对应第 13、16 节)。
// 通知失败时保留 XFER-SFTP 上的最终文件,也不重新执行文件传输。
notifyWithRetryRecord(
"TGT_FILE_READY",
() -> notifyDownstream(taskId, finalRemotePath)
);
}
5. 初始化并验证 XFER-Temp
XFER-Temp 是大文件进入 XFER 后首先写入的本地目录。S01 在建立 SFTP 连接和创建输出流之前完成目录初始化,目录不可写时直接终止任务。
java
private void ensureTempDirExists(String tempDir) throws IOException {
// 临时目录来自 XFER 的受控配置,不能直接使用 SRC 请求中的路径。
Path tempPath = Paths.get(tempDir).toAbsolutePath().normalize();
// createDirectories 支持递归创建;目录已存在时不会重复创建。
Files.createDirectories(tempPath);
// 路径必须是目录,防止同名普通文件占用配置位置。
if (!Files.isDirectory(tempPath)) {
throw new IOException("XFER-Temp 不是有效目录:" + tempPath);
}
// 提前验证写权限,避免下载开始后才在 FileOutputStream 阶段失败。
if (!Files.isWritable(tempPath)) {
throw new IOException("XFER-Temp 不可写:" + tempPath);
}
}
目录存在且可写只表示 XFER 可以创建文件。S04 仍需根据源文件大小检查该目录所在分区的剩余空间。
6. 限制并发任务数量,控制 IO 压力
流式读写控制的是单个任务的内存占用。多个 30GB 任务并行时,磁盘 IO、网络带宽、操作系统页缓存和 SFTP 连接仍会同时放大。
单实例部署可以使用 Semaphore 限制同时执行下载、MD5 校验和上传的任务数量。
java
// 公平模式按等待顺序分配许可,减少后提交任务长期抢占的概率。
private static final Semaphore TRANSFER_SEMAPHORE =
new Semaphore(1, true);
生产环境更适合带等待超时的获取方式,避免线程无限阻塞。
java
// 最多等待 30 分钟;超时后把任务标记为排队超时,由调度系统决定是否重试。
boolean acquired = TRANSFER_SEMAPHORE.tryAcquire(30, TimeUnit.MINUTES);
if (!acquired) {
throw new TimeoutException("等待大文件传输许可超时");
}
Semaphore 只在当前 JVM 内生效。多实例部署需要使用数据库任务锁、Redis 分布式锁或独立传输队列。使用分布式锁时,还需要在任务执行时间超过锁有效期前延长有效期,并确保只有获得锁的任务才能释放该锁。
7. 下载前检查磁盘空间
XFER 下载文件前先获取 SRC-SFTP 上的源文件大小,再检查 XFER-Temp 所在分区的可用空间。
java
// lstat 只读取文件大小、权限等基本信息,不会把文件内容下载到 XFER。
long sourceFileSize = upstreamSftp.lstat(sourceFilePath).getSize();
// getUsableSpace 返回 XFER-Temp 所在文件系统对当前进程可用的空间。
long usableSpace = new File(tempDir).getUsableSpace();
// 预留 10% 安全余量,用于文件系统波动和同分区的其他写入。
long requiredSpace = Math.addExact(
sourceFileSize,
Math.max(sourceFileSize / 10, 512L * 1024 * 1024)
);
完整检查如下。
java
private void checkTempDirSpace(String tempDir, long sourceFileSize)
throws IOException {
// 检查目标目录所在分区,而不是检查应用工作目录。
File tempRoot = new File(tempDir);
long usableSpace = tempRoot.getUsableSpace();
// 至少额外预留 512MB;大文件按 10% 预留。
long reserveSpace = Math.max(
sourceFileSize / 10,
512L * 1024 * 1024
);
long requiredSpace = Math.addExact(sourceFileSize, reserveSpace);
// 可用空间不足时,在建立文件输出流之前终止任务。
if (usableSpace < requiredSpace) {
throw new IOException(
"XFER-Temp 空间不足"
+ ", sourceFileSize=" + sourceFileSize
+ ", requiredSpace=" + requiredSpace
+ ", usableSpace=" + usableSpace
);
}
}
XFER-Temp 应挂载到专用大容量磁盘。即使代码执行了空间检查,将临时目录放在系统盘上仍会放大日志、数据库和操作系统同时受影响的风险。
8. 文件名校验与路径规范
SRC 提供的文件路径属于外部输入。XFER 需要限制文件名,避免外部路径影响 XFER-Temp 的目录边界。
java
private String extractAndValidateFilename(String sourceFilePath)
throws IOException {
// Path.getFileName 只保留最后一级名称,去掉 SRC-SFTP 上的父目录。
String filename = Paths.get(sourceFilePath).getFileName().toString();
// 拒绝空名称、当前目录、父目录和任何残留路径分隔符。
if (filename.isBlank()
|| ".".equals(filename)
|| "..".equals(filename)
|| filename.contains("/")
|| filename.contains("\\")) {
throw new IOException("非法源文件名");
}
// 这里只允许字母、数字、点、下划线和连字符,避免控制字符进入日志和文件系统。
if (!filename.matches("[A-Za-z0-9._-]{1,255}")) {
throw new IOException("源文件名包含不允许使用的字符");
}
return filename;
}
目标路径必须由 XFER 根据可信配置和结构化字段生成,不能直接拼接 SRC 提供的任意目录。
java
private String buildTargetDir(String sourceCode, LocalDate date) {
// sourceCode 必须来自内部映射,或只包含系统允许使用的字符。
String safeSourceCode = validateSourceCode(sourceCode);
// 统一生成 /packages/{source}/{yyyyMMdd}/,避免暴露真实业务目录。
return "/example/file-transfer/packages/"
+ safeSourceCode
+ "/"
+ date.format(DateTimeFormatter.BASIC_ISO_DATE)
+ "/";
}
9. 使用 .downloading 标识尚未下载完成的文件
如果下载过程直接使用最终文件名,下载中断后,目录中仍会留下一个名称正常但内容不完整的文件。后续程序可能把它误认为下载成功。XFER 应先写入带 .downloading 后缀的文件,下载完成并确认文件大小一致后,再去掉该后缀。
java
private void downloadFromUpstreamSftp(
String sourceFilePath,
String downloadingFilePath,
String localFilePath
) throws Exception {
ChannelSftp upstreamSftp = null;
File downloadingFile = new File(downloadingFilePath);
File localFile = new File(localFilePath);
try {
// 建立 SRC-SFTP 连接;连接超时、读取超时和 服务端主机身份校验由连接工厂统一配置。
upstreamSftp = upstreamSftpFactory.open();
// 下载前获取源文件大小,用于空间检查和下载后完整性检查。
long sourceFileSize = upstreamSftp
.lstat(sourceFilePath)
.getSize();
checkTempDirSpace(tempDir, sourceFileSize);
// 下载完成的同名本地文件已存在时拒绝覆盖,由任务恢复逻辑判断是否可以复用。
if (localFile.exists()) {
throw new IOException("当前任务的本地文件已存在");
}
// 删除同一任务上次失败后留下的未完成下载文件。
deleteFileQuietly(downloadingFilePath);
// 输入流来自 SRC-SFTP,输出流只写入 .downloading 文件。
try (InputStream input = upstreamSftp.get(sourceFilePath);
OutputStream output = new BufferedOutputStream(
new FileOutputStream(downloadingFile))) {
copyStream(input, output);
}
// 字节数不一致说明下载不完整,不能进入 MD5 校验和上传阶段。
if (downloadingFile.length() != sourceFileSize) {
throw new IOException("下载文件大小与源文件不一致");
}
// 下载完成后去掉 .downloading 后缀,使后续步骤可以读取该文件。
Files.move(
downloadingFile.toPath(),
localFile.toPath(),
StandardCopyOption.ATOMIC_MOVE
);
} finally {
// 无论下载成功还是失败,都关闭 SFTP 文件操作通道及其底层连接。
closeQuietly(upstreamSftp);
}
}
ATOMIC_MOVE 表示重命名操作要么完整成功,要么保持原状,其他线程不会看到重命名过程中的中间状态。该能力依赖文件系统支持,并要求两个文件位于同一文件系统。当前设计把两个文件放在同一目录;如果文件系统不支持该选项,需要捕获异常并改用普通重命名,同时记录告警。
10. Stream 与 Buffer 控制 JVM 内存
整文件读取会让文件内容进入 JVM 堆。
java
// 反例:文件大小直接决定 byte[] 大小,30GB 文件会造成堆内存耗尽。
byte[] data = Files.readAllBytes(path);
大文件需要固定大小的缓冲区循环搬运。
java
private void copyStream(InputStream input, OutputStream output)
throws IOException {
// 每个任务只持有 1MB Java 堆缓冲区,与文件总大小无关。
byte[] buffer = new byte[1024 * 1024];
long copiedBytes = 0L;
int len;
// read 返回 -1 表示到达流末尾;每次只写入实际读取的字节数。
while ((len = input.read(buffer)) != -1) {
output.write(buffer, 0, len);
copiedBytes += len;
}
// try-with-resources 会在方法返回后关闭流;flush 明确提交用户态缓冲数据。
output.flush();
log.info("文件流复制完成,copiedBytes={}", copiedBytes);
}
BufferedOutputStream 只缓存一小段数据,用于减少频繁的小块写操作。它不会把整个文件保存在内存中。
流式处理控制的是 JVM 堆占用。操作系统仍可能把刚读写的文件数据暂存在内存页缓存(Page Cache)中,因此还需要磁盘空间检查和并发控制。
11. MD5 文件校验
下载完成并通过文件大小检查后,XFER 对本地文件执行 MD5 校验。该步骤先确认文件存在,再以流式方式读取文件并计算 MD5 值;文件不存在或读取异常时立即终止上传。
java
private String performMd5Check(String filePath) throws IOException {
File file = new File(filePath);
// 文件必须存在且为普通文件,否则不能进入 XFER-SFTP 上传阶段。
if (!file.isFile()) {
throw new IOException("MD5 校验失败:文件不存在");
}
// DigestUtils 按流读取文件,不会把整个大文件加载到 JVM 堆中。
try (InputStream input = new FileInputStream(file)) {
String md5 = DigestUtils.md5Hex(input);
log.info("MD5 文件校验完成,file={}, md5={}", filePath, md5);
return md5;
} catch (IOException e) {
// 读取失败表示当前文件无法完成校验,后续上传必须终止。
throw new IOException("MD5 文件校验失败:" + filePath, e);
}
}
计算出的 MD5 值需要与任务状态一起写入数据库,便于后续排查问题和识别重复任务。
11.1 MD5 校验会增加一次完整磁盘读取
一个 30GB 文件在当前流程中至少经历:
text
# SRC-SFTP 到 XFER-Temp
网络读取 30GB + 本地写入 30GB
# XFER 执行 MD5 文件校验
本地读取 30GB
# XFER-Temp 到 XFER-SFTP
本地读取 30GB + 网络写入 30GB
这也是 Semaphore 需要覆盖下载、MD5 文件校验和上传整个阶段的原因。若下载时同步更新 MessageDigest,可以在文件写入过程中计算 MD5,减少一次额外的完整磁盘读取。
12. 单文件上传与最终文件名切换
共享临时目录不能通过 listFiles() 扫描后批量上传。每个任务只处理自身计算出的文件路径。
java
// 反例:可能上传其他任务已经下载完成的文件或仍在下载的 .downloading 文件。
File[] files = new File(tempDir).listFiles();
XFER-SFTP 也应使用临时后缀,避免 TGT 读取仍在上传、内容尚不完整的文件。
java
private String uploadToPlatformSftp(String localFilePath, String targetDir)
throws Exception {
ChannelSftp platformSftp = null;
File localFile = new File(localFilePath);
try {
// 建立 XFER-SFTP 连接,连接配置中必须启用服务端主机身份校验。
platformSftp = platformSftpFactory.open();
ensureRemoteDir(platformSftp, targetDir);
// 每个任务只上传自己的文件,不扫描 XFER-Temp。
if (!localFile.isFile()) {
throw new IOException("待上传文件不存在");
}
String finalRemotePath = targetDir + localFile.getName();
String uploadingPath = finalRemotePath + ".uploading";
// 先上传到不可对外使用的临时文件名。
platformSftp.put(localFile.getAbsolutePath(), uploadingPath);
// 上传后读取远端文件大小,大小不一致时不能改为最终文件名。
long uploadedSize = platformSftp.lstat(uploadingPath).getSize();
if (uploadedSize != localFile.length()) {
platformSftp.rm(uploadingPath);
throw new IOException("XFER-SFTP 文件大小校验失败");
}
// 大小一致后一次性改为最终文件名,TGT 此时才能按该名称获取文件。
platformSftp.rename(uploadingPath, finalRemotePath);
return finalRemotePath;
} finally {
// 上传异常时也必须关闭 SFTP 文件操作通道及其底层连接。
closeQuietly(platformSftp);
}
}
上传前需要创建多级目录。
java
private void ensureRemoteDir(ChannelSftp sftp, String remoteDir)
throws SftpException {
String[] segments = remoteDir.split("/");
String currentPath = "";
for (String segment : segments) {
// 绝对路径开头产生空片段,直接跳过。
if (segment.isBlank()) {
continue;
}
// 每次只推进一级目录,便于判断具体失败位置。
currentPath = currentPath + "/" + segment;
try {
sftp.cd(currentPath);
} catch (SftpException notFound) {
// 目录不存在时创建,再进入该目录验证创建结果。
sftp.mkdir(currentPath);
sftp.cd(currentPath);
}
}
}
catch (SftpException) 不能无条件等价于"目录不存在"。生产实现应检查 SFTP 错误码,权限不足、连接中断等异常必须直接抛出。
13. 避免重复传输与故障恢复
大文件任务不能把"整个方法重跑"直接等同于重试。上传成功、数据库更新失败或通知失败时,整体重跑会再次传输同一个大文件。
建议为任务维护以下阶段:
| 阶段 | 允许重新执行的动作 | 判断是否需要重复执行的依据 |
|---|---|---|
PENDING |
获取许可 | taskId |
DOWNLOADING |
重新下载 | 清理本任务 .downloading |
DOWNLOADED |
重新执行或复用 MD5 校验 | 本地文件大小与 MD5 值 |
UPLOADING |
重新上传 | 清理本任务 .uploading |
FILE_READY |
不再传输文件 | XFER-SFTP 路径、大小和 MD5 值 |
NOTIFY_PENDING |
只重试通知 | 通知事件唯一键 |
COMPLETED |
不再执行 | 任务已经完成 |
FILE_READY 表示文件已经上传到 XFER-SFTP、通过大小检查并改为最终文件名。任务恢复时应先读取数据库中的任务阶段和 XFER-SFTP 文件信息,再决定从哪一步继续。
java
private void resumeTask(TransferTask task) throws Exception {
// 文件已经在 XFER-SFTP 上准备完成时,只重新执行缺失的状态更新或通知。
if (task.getStage() == Stage.FILE_READY
|| task.getStage() == Stage.NOTIFY_PENDING) {
verifyFinalRemoteFile(task);
executeNotifications(task.getTaskId(), task.getFinalRemotePath());
return;
}
// 文件尚未在 XFER-SFTP 上准备完成时,才重新进入文件传输流程。
executeTransferTask();
}
当前实现每次重试前固定等待 5 秒,并且最多执行 3 次。任务数量较大时,可以把等待时间改为逐次增加,避免 SFTP 服务刚恢复就同时收到大量重试请求。
14. 失败清理
失败清理只能处理当前任务拥有的文件。
java
private void cleanupLocalFiles(
String localFilePath,
String downloadingFilePath
) {
// 删除当前任务已经下载完成但尚未清理的本地临时文件。
deleteFileQuietly(localFilePath);
// 删除当前任务未完成的下载文件。
deleteFileQuietly(downloadingFilePath);
}
private void deleteFileQuietly(String filePath) {
// 空路径直接返回,避免构造含义不明确的 File 对象。
if (filePath == null || filePath.isBlank()) {
return;
}
File file = new File(filePath);
if (file.isFile()) {
// 删除失败必须记录路径和结果,交由后台清理任务继续处理。
boolean deleted = file.delete();
log.info("删除任务临时文件,file={}, deleted={}",
file.getAbsolutePath(), deleted);
}
}
进程被强制终止时,catch/finally 无法执行。XFER 还需要定时清理超过阈值的 .downloading 和 .uploading 文件。清理任务必须结合任务状态和文件更新时间,不能只按后缀立即删除。
15. 限制读取 ZIP 内的文件名称
读取 ZIP 内的文件名称不应影响主传输结果。每个 ZipEntry 表示 ZIP 中的一个文件或目录;代码需要限制其数量和累计名称长度,避免异常压缩包造成过多内存占用或超出数据库字段长度。
java
private List<String> readZipEntries(
String zipFilePath,
int maxEntries
) throws IOException {
List<String> entries = new ArrayList<>();
// ZipInputStream 逐个读取 ZIP 中的文件或目录信息,不解压文件内容。
try (ZipInputStream zip = new ZipInputStream(
new FileInputStream(zipFilePath))) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
// 超出上限后终止读取,避免异常 ZIP 产生超大列表。
if (entries.size() >= maxEntries) {
throw new IOException("ZIP 内的文件和目录数量超过限制");
}
// 这里只记录名称。这里只记录名称,不执行解压。
entries.add(entry.getName());
}
}
return entries;
}
如果业务只需要文件大小和 MD5 值,可以不扫描 ZIP 目录,减少一次文件读取和数据库存储压力。
16. 通知结果必须显式判断
HTTP 客户端捕获异常后返回 "fail",调用方再以"是否抛异常"判断成功,会把失败误判为成功。通知方法应在 HTTP 状态或业务码异常时直接抛出异常。
java
private void notifyUpstream(String taskId, String finalRemotePath) {
// 请求体只包含协议要求的字段,日志中不得输出 认证信息。
NotifyRequest request = new NotifyRequest(taskId, finalRemotePath);
ResponseEntity<NotifyResponse> response =
restTemplate.postForEntity(callbackUrl, request, NotifyResponse.class);
// HTTP 非 2xx 时进入通知重试,而不是返回普通字符串。
if (!response.getStatusCode().is2xxSuccessful()) {
throw new IllegalStateException(
"SRC 回调 HTTP 状态异常:" + response.getStatusCode()
);
}
// HTTP 成功不代表业务成功,还需要检查响应业务码。
NotifyResponse body = response.getBody();
if (body == null || !"SUCCESS".equals(body.getCode())) {
throw new IllegalStateException("SRC 回调业务响应失败");
}
}
通知请求建议携带 taskId 或事件 ID 作为唯一标识。SRC 和 TGT 收到同一标识的重复通知时,应返回与首次处理相同的成功结果,避免重复创建任务。
17. SFTP 连接配置与敏感信息保护
连接配置至少包含以下要求:
- 启用服务端主机身份校验,只连接已被信任的服务端主机。
- 认证信息通过配置中心或受控配置注入。
- 日志禁止输出认证信息、完整请求体。
- 分别配置连接超时、Socket 读取超时和任务总超时。
- 使用最小权限账号,SRC-SFTP 账号只读,XFER-SFTP 账号只写目标目录。
- 定期轮换认证信息,并保留审计记录。
JSch 连接应加载受信任的 known_hosts 文件,并开启主机校验。
java
// known_hosts 由部署系统维护,文件内容不应由业务请求动态写入。
jsch.setKnownHosts(knownHostsPath);
// yes 表示只连接已被信任的服务端主机身份。
session.setConfig("StrictHostKeyChecking", "yes");
18. 监控与告警
大文件传输需要暴露以下指标:
| 指标 | 用途 |
|---|---|
| 等待传输许可的任务数 | 判断并发数量限制是否造成大量排队 |
| 许可等待时间 | 判断任务排队程度 |
| 下载和上传耗时 | 定位 SRC-SFTP、XFER 磁盘或 XFER-SFTP 性能问题 |
| 每阶段失败次数 | 区分下载、MD5 校验、上传、文件重命名和通知故障 |
| XFER-Temp 可用空间 | 在任务失败前触发磁盘告警 |
| 当前传输速率 | 发现网络或存储性能下降 |
| 未清理临时文件的数量和大小 | 发现清理逻辑失效 |
| 等待重新通知的任务数量 | 发现文件已上传完成但上下游状态未同步 |
日志应包含 taskId、阶段、文件大小、耗时和错误类型。文件路径可以记录脱敏后的相对路径,认证信息不得进入日志。
19. 方案边界
当前方案解决以下问题:
- 固定缓冲区控制 JVM 堆内存占用。
- Semaphore 或分布式调度控制并发 IO。
- 空间检查降低 XFER-Temp 所在磁盘被写满的风险。
.downloading标识尚未下载完成的文件,后续步骤不会读取它。.uploading标识尚未上传完成的文件,TGT 不会按最终文件名读取它。- 文件大小检查和 MD5 文件校验用于确认本地文件可正常读取。
- 单文件路径避免误处理其他任务文件。
- 任务阶段记录和独立通知重试可以避免重复传输同一个大文件。
以下问题需要部署和基础设施配合:
- 存储设备自身故障和容量规划。
- 多实例间的全局并发限制。
- SFTP 服务端是否支持一次性完成同一文件系统内的重命名。
- 跨机房网络带宽和超时参数。
- 认证信息管理、服务端主机身份 分发和安全审计。
- 数据库状态更新与 SFTP 文件重命名无法放进同一个事务中一起提交或回滚。
最后一项通常通过记录任务阶段、重复执行前检查文件状态,以及后台重新执行失败步骤来解决。不应把数据库事务一直保持到大文件传输结束。
20. 核心原则
20GB、30GB 级文件传输需要同时满足以下约束:
text
# 内存约束
不把整文件读入 JVM 堆。
# IO 约束
不允许下载、MD5 校验和上传任务无上限并发。
# 磁盘约束
下载前检查空间,并使用专用临时盘。
# 完整性约束
下载和上传都先使用带临时后缀的文件名,完成校验后再改为后续系统使用的最终文件名。
# 隔离约束
每个任务只处理自身文件,不扫描共享目录批量操作。
# 恢复约束
文件传输、数据库状态更新和通知分别记录执行结果,失败后只重新执行尚未完成的步骤。
# 安全约束
校验路径、主机身份和 MD5 文件,日志不泄露认证信息。
大文件模块的目标不仅是完成文件传输,还要保证任意阶段失败时,服务器资源保持可控,下载或上传尚未完成的文件不会被后续系统误读,任务能够从明确的阶段继续执行。