一个视频下载到一半连接断开,服务器磁盘上却已经有了文件。下一个任务如果只检查 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 业务系统。
让调用方拿到可用路径之前,先结束写入、检查响应并完成发布,下载失败就能留在正确的阶段处理。后续再决定是否需要更复杂的并发、权限与缓存一致性策略。