Java 文件缓存实战:如何避免读到未下载完成的视频?

一个视频下载到一半连接断开,服务器磁盘上却已经有了文件。下一个任务如果只检查 Files.exists,就可能把这段残留交给 FFmpeg,看到的错误也不再像下载失败。

Vimorph 的多个媒体处理入口会先读取本地缓存。本文从这段项目代码展开,用一个独立 Java 21 示例说明文件何时可以被复用。示例对失败清理作了单独处理,没有接入当前业务系统。

先区分正在下载与可以复用

Vimorph 的视频抽音轨、缩略图生成和转写预处理,都有先取得本地媒体路径、再继续处理的调用。这里讨论的是后端下载,文件字节从远程地址进入服务器磁盘。

FileCacheService.getOrDownload() 在缓存启用时,先检查已有文件,未命中再下载。下面是项目代码的接口节选:

java 复制代码
Path tempFile = cacheFile.resolveSibling(cacheFile.getFileName() + ".tmp");
download(sourceUrl, tempFile);
Files.move(tempFile, cacheFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);

cacheFile 是按命名空间、完整 URL 摘要和扩展名构造的正式路径,sourceUrl 是输入地址;download() 负责 HTTP 读取,之后才尝试移动文件。这个顺序让"正在写入"和"可以复用"有了不同的文件名。

现有实现的失败分支仍需改进:复制异常不一定清掉 .tmp,锁表移除存在并发风险;关闭普通缓存的 _passthrough 分支还会复用非空文件,不能认为关闭后一定每次重下。下文用独立教学工程展示统一失败出口,不把这些改进算作已经接入业务。

锁表的问题在于映射与等待者的生命周期不同:A 离开时移除锁 L1,B 可能已经拿到 L1 并在等待,C 此时却能为同一 key 创建 L2。即使随后两者都进入了 synchronized,也可能锁住不同对象。只把移除改成 remove(key, lock),仍不能消除这个窗口。

用一个串行实例讲清文件发布

示例使用 Java 21 标准库。RemoteFileCache.Config 保存目录、单次下载字节上限、TTL、连接和读取超时;CachedFile 返回路径、是否命中与字节数。Clock 用于控制测试时间。

getOrDownload() 和 cleanupExpired() 都是 synchronized 实例方法,使用同一实例锁,下载期间的清理也会等待。这样可以集中说明文件发布,不在这篇实现按 key 并发调度。目录由该实例独占;多个实例、进程或外部程序共享目录不在其保证范围。

下面是教学工程 getOrDownload() 的接口节选:

java 复制代码
public synchronized CachedFile getOrDownload(URI source) throws IOException {
    requireTrustedHttpShape(source);
    Files.createDirectories(config.directory());
    Path target = config.directory().resolve(cacheKey(source) + ".cache");
    if (isFresh(target)) {
        Files.setLastModifiedTime(target, FileTime.from(clock.instant()));
        return new CachedFile(target, true, Files.size(target));
    }
    // 过期文件不继续作为可用结果暴露;后续下载失败也不会返回旧缓存。
    Files.deleteIfExists(target);
    downloadAndPublish(source, target);
    return new CachedFile(target, false, Files.size(target));
}

requireTrustedHttpShape() 只检查 URL 形状:允许 HTTP/HTTPS,要求有主机且不带用户信息、片段。它没有验证 DNS 解析后的地址、来源白名单或用户权限,也不构成完整 SSRF 防护。教学关闭重定向,业务下载入口则会跟随重定向;实际接入需要在授权与出站策略层约束来源、解析地址及跳转目标,本例没有实现这层。SSRF 防护参考

cacheKey() 对完整 URL 字符串求 SHA-256,仅用于构造文件名,不是对下载内容求哈希;URL 摘要也不构成用户权限隔离。

命中时刷新修改时间,返回 hit=true。过期文件先删除,重新下载失败就向调用方报错,此时旧缓存已经不可用。这里选择的是"失效后必须重新取得"的策略。高可用场景可以先下载新文件、校验后再决定是否替换,并单独定义旧数据可用范围;示例仍保留先删后重下,没有实现 stale-while-revalidate。项目未命中路径则先写 .tmp 再尝试替换,下载失败时旧正式路径可能仍在,但不等于会回退使用失效缓存。

把下载与失败清理放进同一生命周期

临时文件创建在正式文件的同一目录,名称由 Files.createTempFile() 唯一生成。下载时既检查响应声明的大小,也累计实际字节,避免未知长度响应绕过单次上限。

下面是教学工程 downloadAndPublish() 及清理辅助方法节选,外围类、构造器和其他辅助方法仍由工程提供:

java 复制代码
private void downloadAndPublish(URI source, Path target) throws IOException {
    HttpURLConnection connection = null;
    Path temporary = null;
    Throwable primary = null;
    try {
        connection = connections.open(source);
        connection.setInstanceFollowRedirects(false);
        connection.setRequestMethod("GET");
        connection.setConnectTimeout(config.connectTimeoutMillis());
        connection.setReadTimeout(config.readTimeoutMillis());
        int status = connection.getResponseCode();
        if (status != HttpURLConnection.HTTP_OK) {
            throw new IOException("下载返回非 200 状态:" + status);
        }
        long declared = declaredLength(connection);
        if (declared > config.maxDownloadBytes()) {
            throw new IOException("声明的文件大小超过单次下载上限");
        }
        temporary = Files.createTempFile(target.getParent(), target.getFileName() + "-", ".part");
        long written = 0;
        try (InputStream input = connection.getInputStream();
             OutputStream output = outputs.open(temporary)) {
            byte[] buffer = new byte[8192];
            for (int count; (count = input.read(buffer)) != -1;) {
                if (count > config.maxDownloadBytes() - written) {
                    throw new IOException("实际接收数据超过单次下载上限");
                }
                output.write(buffer, 0, count);
                written += count;
            }
        }
        if (declared >= 0 && written != declared) {
            throw new IOException("响应长度不符:expected=" + declared + ", actual=" + written);
        }
        if (written == 0) throw new IOException("响应内容为空");
        Files.setLastModifiedTime(temporary, FileTime.from(clock.instant()));
        mover.move(temporary, target); // 不支持 ATOMIC_MOVE 时直接失败,不降级普通移动。
    } catch (IOException | RuntimeException | Error failure) {
        primary = failure;
        throw failure;
    } finally {
        cleanup(primary, connection, temporary);
    }
}

private void cleanup(Throwable primary, HttpURLConnection connection, Path temporary) throws IOException {
    Throwable failure = primary;
    try {
        if (connection != null) connection.disconnect();
    } catch (RuntimeException | Error disconnectFailure) {
        failure = retainFirst(failure, disconnectFailure);
    }
    // 断开连接失败也必须继续尝试删除临时文件。
    try {
        if (temporary != null) temporaryRemover.delete(temporary);
    } catch (IOException | RuntimeException | Error deleteFailure) {
        failure = retainFirst(failure, deleteFailure);
    }
    // 主错误已在 catch 中抛出;清理不能覆盖它。没有主错误时仍需报告清理失败。
    if (primary == null && failure != null) {
        if (failure instanceof IOException io) throw io;
        if (failure instanceof RuntimeException runtime) throw runtime;
        throw (Error) failure;
    }
}

private static Throwable retainFirst(Throwable first, Throwable next) {
    if (first == null) return next;
    if (first != next) first.addSuppressed(next);
    return first;
}

source 是来源 URI,target 是正式路径;config、clock 是实例字段。connections、outputs、temporaryRemover 是包内测试注入字段,默认分别打开 JDK HTTP 连接、文件输出流和调用 Files.deleteIfExists();普通公开构造器的用法没有改变。declaredLength() 把缺失、非法或负的 Content-Length 视为未知,返回 -1。mover 是可注入的 AtomicMover,正常构造时调用 Files.move(),测试中可用它制造移动失败。

示例只接受 HTTP 200,不跟随重定向。它没有发送 Range 请求,因此也拒绝意外的 206 部分响应;项目原来的判断是接受 2xx,两者不要混写。空响应、非 200、读取异常、超限或已知长度不符,都不会进入成功返回。

输入、输出流关闭完成后才发布文件,避免把仍在写入的结果交给后续调用。流使用 try-with-resources:已有读取错误时,关闭错误会作为 suppressed 附加;没有读取错误时,关闭错误本身成为主错误,发布不会继续。

外层也保留第一个错误。cleanup() 先尝试断开连接,再尝试删除临时路径,即使断开失败也继续删除;已有下载或移动错误时,清理错误只附加到原异常。没有主错误时,首个清理错误仍会抛出,后续错误附到它上面。Java suppressed 说明

这也意味着,文件已成功移动后,如果清理才报错,调用仍会失败,但正式目标可能已经存在;不能把异常当成发布从未发生。删除失败也可能留下 .part,本例没有自动补偿扫描,不能承诺任何故障下都无残留。

长度匹配能排除什么问题

字节数比较只在响应给出了有效 Content-Length 时成立。没有该头部时,示例把正常读到 EOF 当作本次响应结束,但不知道服务端原本应提供多少数据。一个提前结束、却没有可核对长度的响应,仍可能通过这层检查。

即使长度相等,也不能证明内容未损坏、文件能完整解码或没有安全问题。媒体探测、可信摘要比对和内容检查是下一层职责,这个缓存没有实现它们。

连接超时与读取超时分别限制建连和等待数据。读取超时不是整个下载任务的总截止时间:持续缓慢发送数据的响应可能一直不触发,排队与等待实例锁也不包含在内。具体语义见 Java URLConnection 文档。

原子移动为什么不能随意降级

正常的 AtomicMover 使用 ATOMIC_MOVE 发起移动。它依赖文件系统支持;同目录创建临时文件能避免常见的跨文件系统移动问题,却不能代替平台验证。原子移动处理的是文件名发布边界,不验证文件内容,也不保证断电持久化或跨系统事务。

本示例遇到不支持原子移动时直接失败,没有自动降级普通移动。否则代码虽然继续运行,"读者只能看到完整发布结果"的前提却变了。还要注意,指定 ATOMIC_MOVE 时其他选项会被忽略,目标已存在时能否替换取决于实现;不能因为同时传了 REPLACE_EXISTING 就承诺所有平台都原子覆盖。依据见 Java Files.move 文档。

本轮直接调用默认 mover 时,当前 macOS 文件系统实际替换了已存在的普通目标文件;目标是非空目录时,移动失败,原源文件及目录内容仍在。这两项只说明本机观察。它们与 getOrDownload() 的入口策略不同:入口已经先删除过期旧目标,不能用直接 mover 的保留结果推导出重新下载失败时还有旧缓存可回退。

缓存命中后,过期时间也在变化

项目当前缓存命中后会 touch 文件,TTL 按最近修改时间计算,正常 touch 成功时相当于滑动的空闲期限。教学示例也采用这个语义:到达期限视为失效,命中则延长复用时间。

示例提供 cleanupExpired(),由调用方显式触发;它没有后台定时器,也没有容量淘汰。项目另有定时扫描与按总量删除较旧文件的逻辑,不能把它们算进示例。

频繁访问可能持续延长缓存寿命,所以这套规则不保证远端内容实时一致。完整发布、检查时路径存在、任务期间一直可用,是三件不同的事:返回 Path 也不代表取得使用租约。方法返回后实例锁已释放,后续清理可以删掉调用方尚未使用完的文件;业务定时清理甚至没有参与其下载锁。

长时间媒体任务可以采用任务私有文件、租约或引用计数,或者延迟回收;这些方案需要独立设计,当前示例没有实现它们。

签名地址变化后,还算同一个缓存吗

COS、OSS 的预签名 GET URL 都可以作为远程媒体来源。授权期限与本地缓存有效期是两回事,相关机制分别见 COS 预签名 URL和 OSS Java SDK V1 预签名下载。

同一对象重新签名后,URL 的查询参数可能变化;按完整 URL 生成的键随之变化,会再次下载。这不等于内容去重失效,因为这里从未实现内容去重。反过来,同一个 URL 对应内容改变,也不一定立即使本地缓存失效。

不要直接删掉签名参数来提高命中率。稳定对象标识、版本、用户权限和本地复用范围需要一起设计。这个示例不实现跨用户私有文件共享;本地已经命中也不代表远端授权仍有效。实际签名地址不应出现在公开代码、日志或截图中。

测试实际覆盖了哪些分支

本次在 Java 21 下实际运行了项目原有 FileCacheServiceTest:4 项通过,0 失败、0 错误、0 跳过。它使用本地 HTTP/Socket 服务,覆盖下载后命中、404、截断响应与预取逐项结果。截断用例只断言异常,不能据此宣称成功恢复或临时文件清理已经测过。

原教学工程 20 项本次基线复跑通过;补充清理错误传播、目标已存在和 Path 生命周期测试后,独立教学工程实际通过 32 项测试,0 失败、0 跳过。覆盖 HTTP 200 与意外 206、命中、截断、同 URL 失败后恢复、空响应、大小上限、读取超时、原子移动失败、滑动 TTL、过期后重下失败以及查询参数变化。ZIP 解压后同一套 32 项再次通过,复跑不累计为 64 项。实际输出节选:

text 复制代码
RESULT tests=32 passed=32 failed=0 skipped=0
本地受控 HTTP:首次 hit=false,再次 hit=true,实际字节=24

新增故障注入确认了:下载主异常保持原对象,输入/输出关闭、断开连接和删除失败按顺序保留在 suppressed 中;没有主错误时,清理首错误仍会抛出。移动错误也不会被后续清理错误覆盖。删除被注入为失败时,测试确实观察到 .part 残留,并未把"尝试删除"当作一定删除。发布后清理报错、下一次调用仍能命中正式文件的情况也有覆盖。

测试发送合成字节,不是真实视频,也没有运行 FFmpeg 解码。无 Content-Length 的用例展示正常 EOF 可被接收,不证明媒体完整。故障注入使用可控连接、流和删除操作;原子移动不支持同样通过注入验证,不是假装当前文件系统本来不支持。另有实际文件系统与本地 HTTP 测试;取得 Path、推进时钟再清理的用例确认路径会消失,不是真实多线程竞争测试。

本次没有执行真实 COS/OSS 下载、签名到期、跨进程共享目录、在用文件清理、进程崩溃或跨平台文件系统实验,也没有性能收益数据。教学改进尚未接入 Vimorph 业务系统。

让调用方拿到可用路径之前,先结束写入、检查响应并完成发布,下载失败就能留在正确的阶段处理。后续再决定是否需要更复杂的并发、权限与缓存一致性策略。

相关推荐
白露与泡影1 小时前
后端 Java 面试核心八股汇总|高频面试题 + 解析,覆盖基础到进阶
java·开发语言·面试
SL_staff2 小时前
JVS-APS 实践:用双向反馈闭环打破制造计划孤岛
java·spring cloud·全栈
\光辉岁月/2 小时前
3.包装类
java·开发语言·包装类
BeiGuTing_code2 小时前
Java动态数组(一)
java·数据结构·windows
我命由我123452 小时前
软件工程概念 - 可插拔性
java·后端·学习·java-ee·系统架构·软件工程·学习方法
木易 士心3 小时前
Java 后端驱动远程手机自动化:ADB + Python + 内网穿透实战全解
java·后端·智能手机·自动化
十年Java程序媛3 小时前
JVM 内存区域与对象内存布局|new 对象底层流程,对象头 MarkWord 详解
java·jvm·spring boot
用户094248568033 小时前
第2章:Windows、WSL2 与 Docker 环境搭建Redis
java·jvm
linyb极客之路3 小时前
Spring Boot 2升3太痛苦?用OpenRewrite批量搞定,少走90%弯路!
spring boot