大文件(20GB+)SFTP 下载模块设计与实现

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. 总体架构

%% 实线表示文件流,虚线表示控制流或通知流。 graph LR SRC[SRC<br/>来源系统] SRCSFTP[(SRC-SFTP<br/>源文件)] XFER[XFER<br/>文件中转服务] TEMP[(XFER-Temp<br/>临时目录)] XFERSFTP[(XFER-SFTP<br/>目标文件)] TGT[TGT<br/>目标系统] SRC -.->|下载任务| XFER SRC -->|写入源文件| SRCSFTP SRCSFTP -->|SFTP 流式下载| XFER XFER -->|写入 .downloading 临时文件| TEMP TEMP -->|读取下载完成的临时文件| XFER XFER -->|上传 .uploading, 完成后重命名为最终文件名| XFERSFTP XFER -.->|处理结果| SRC XFER -.->|文件准备完成通知| TGT TGT -->|SFTP 读取文件| XFERSFTP

3. 文件传输状态

下载和上传都需要临时状态,防止未完成文件进入后续系统。

%% 每次状态转换都必须在前一步成功后执行。 stateDiagram-v2 [*] --> Waiting: 任务已创建 Waiting --> Downloading: 获取传输许可 Downloading --> Downloaded: 下载完成且大小一致 Downloading --> RetryWaiting: 本次传输失败且仍有次数 RetryWaiting --> Downloading: 等待 5 秒后重新执行 Downloaded --> Uploading: MD5 文件校验完成 Uploading --> FileReady: 上传完成并改为最终文件名 FileReady --> Notified: SRC 与 TGT 通知完成 Downloading --> Failed: 3 次传输全部失败 Downloaded --> RetryWaiting: MD5 校验或本地文件异常 Uploading --> RetryWaiting: 上传或远端校验异常 FileReady --> NotifyPending: 文件已上传,通知异常 NotifyPending --> Notified: 独立重试通知

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 文件,日志不泄露认证信息。

大文件模块的目标不仅是完成文件传输,还要保证任意阶段失败时,服务器资源保持可控,下载或上传尚未完成的文件不会被后续系统误读,任务能够从明确的阶段继续执行。

相关推荐
Dilee1 小时前
Spring AI 2.0.0 接 Skill 最小 Demo:SkillsTool 加载 SKILL.md 一次跑通
后端
zoulee241 小时前
doris-python:让 SQLAlchemy 玩转 Apache Doris 多驱动生态
后端
RainCity1 小时前
Java Swing 自定义组件库分享(十二)
java·笔记·后端
Csvn2 小时前
Linux 系统性能监控与瓶颈排查
后端
铁皮饭盒2 小时前
Rust版Bun1.4之前, 盘点Bun1.3新特性
前端·javascript·后端
kfaino9 小时前
码农的AI翻身(五)你好,我叫 Transformer
后端·aigc
阳光是sunny12 小时前
Vue 项目怎么做用户行为全链路监控?轻量插件方案详解
前端·面试·架构