文件下载服务接入 OSS、S3 等对象存储后,一个容易遇到的问题是:同一份文件在短时间内被反复访问,每次请求却都重新从上游下载。

为减少重复回源,我在后端引入了本地文件缓存:文件内容保存在磁盘,JVM 内存维护文件引用和缓存信息。后续请求可以复用已经下载完成的文件。

但缓存带来的问题不止是“多久清理一次”。多个请求同时遇到缓存未命中时,谁负责下载?上游文件更新后,如何判断本地副本是否有效?缓存被清理时,已经开始读取文件的请求怎么办?

这篇文章围绕这些问题,整理已经采用的做法、它们的边界,以及后续需要补齐的机制。

一、先确定哪些请求需要经过后端

在这个服务中,不同访问场景采用了不同的传输方式:

场景 当前方式 需要关注的约束
图片预览 后端流式传输 控制缓冲区大小,处理客户端断开与上游超时
PDF 预览 后端鉴权后返回预签名 URL,客户端直接访问对象存储 URL 有效期、权限范围,以及浏览器预览所需的响应头
文件下载 支持 HTTP Range,按指定字节区间返回数据 范围校验、断点续传,以及多次请求之间的文件版本一致性

这里的预签名 URL 是在有效期内可用的临时访问凭证,通常可以重复使用,并不天然具有“一次性”语义。后端仍需要在签发前完成访问权限检查。

流式传输、Range 和本地缓存解决的是不同问题。流式传输控制内存占用;Range 允许客户端选择字节区间;本地缓存减少重复回源。它们可以组合使用。

后端代理下载也不必先把整个文件加载到内存或完整落盘后再响应,可以边读取上游边发送给客户端。不过,在这里的缓存填充流程中,并发等待者会在文件下载完成后再复用本地文件。

二、同一文件只由一个请求负责下载

如果多个请求同时发现本地没有文件,直接各自回源,就会重复消耗上游带宽和本地磁盘 I/O。

采用的处理方式是:首个请求负责下载,其余请求等待下载完成,再读取同一份缓存文件。这个思路通常称为同键请求合并,也常被称为 singleflight。

同一文件的多个请求
    → 首个请求负责下载
    → 其余请求等待
    → 下载完成后复用本地文件

并发控制应围绕文件键展开,而不是让所有文件争用一把全局锁。不同文件可以独立下载,同一文件的请求共享一次下载结果。如果缓存需要区分文件版本,版本信息也应纳入键的设计。

“下载成功后大家一起使用”只是正常路径。要让这一机制稳定运行,还需要明确失败路径:

  • 下载失败时,等待者必须收到失败结果,不能一直等待;
  • 等待需要有超时,单个客户端取消请求也不应随意中断其他请求共享的下载;
  • 下载结束后,无论成功或失败,都需要释放对应的加载状态;
  • 不同文件同时未命中时,仍应限制回源并发,避免连接池、带宽和磁盘被耗尽。

这些是下载合并机制需要满足的约束,不能仅凭“加锁等待”就认为已经全部解决。

另外,正在写入的文件不能被当作完整缓存发布。后续可以采用“临时文件下载完成,再发布缓存条目”的方式,避免读取半成品;若使用重命名发布,还需要确认所在文件系统支持所需的原子操作。

三、定时清理与 LRU、LFU 如何取舍

这个实现采用定时任务清理缓存,主要考虑的是短时间重复访问的场景,以及实现和维护成本。

但并发下载合并与缓存淘汰是两个独立问题。前者决定“谁负责把文件下载回来”,后者决定“哪些文件继续保留”。即使采用 LRU 或 LFU,也仍然可以让同一文件只由一个请求回源。

因此,不能直接得出“LRU、LFU 的锁竞争开销太大,所以定时清理一定更好”的结论。淘汰策略的成本取决于实现、访问频率和并发规模,需要测量才能判断。

定时清理适合用较简单的机制回收超过保留时间的文件。但它也有边界:清理任务两次执行之间,大量新文件仍可能迅速占满磁盘。

后续应将时间和容量作为两类约束共同考虑:按过期时间回收文件,同时设置缓存容量上限、单文件大小限制和磁盘剩余空间阈值。容量不足时,可以拒绝新的缓存填充,或按明确规则提前淘汰已有条目。

是否进一步引入 LRU 或 LFU,应由文件大小分布、访问热度和实际命中率决定。

四、用 ETag 判断文件是否更新

本地文件下载完成,不代表它会永久有效。对象存储中的文件可能被覆盖,如果一直复用本地副本,就可能向客户端返回旧内容。

这一问题已经通过 ETag 校验引入了更新判断。需要注意,ETag 是服务端提供的校验标识,不能一概理解为文件的 MD5;例如 S3 的分段上传或部分加密场景下,ETag 并不等于完整文件的 MD5。

ETag 解决了“用什么判断变化”,还需要明确“什么时候检查变化”。

如果每次命中本地缓存都先请求上游检查 ETag,仍然会产生网络往返和对象存储请求费用;如果按一定间隔检查,则需要接受这段时间内可能返回旧副本。检查频率应根据文件更新频率和业务允许的陈旧时间来确定。

校验与下载之间也可能发生更新:先获取 ETag,再发起普通下载,并不能保证两次请求看到的是同一版本。后续可以结合对象存储支持的条件请求或版本 ID,使下载内容与记录的版本一致。

本地文件更新同样需要考虑正在进行的读取。若直接覆盖原路径,已有读者可能受到影响。更稳妥的改进方向是:新版本写入独立文件,完成后切换缓存引用,旧版本进入待回收流程。

五、缓存失效不等于可以立即删除文件

这里最值得关注的是缓存清理与文件读取之间的竞争。

内存中的文件引用使用 CopyOnWriteArrayList 管理。它允许遍历者读取集合快照,但快照保存的只是 File 引用。Java 的 File 对象主要表示文件路径,并不保证该路径对应的物理文件持续存在。

假设一个请求已经从集合中取到了 File,清理线程随后清空集合并删除磁盘文件。请求持有的引用不会因此消失,但它准备打开的文件可能已经不存在。

如果请求已经打开文件,删除后的行为还会受到操作系统和文件系统语义影响,也不能把某个平台的表现当作通用保证。

所以,线程安全集合解决的是集合访问问题,不能替代文件生命周期管理。

当前采用的做法是将逻辑清理与物理删除分开:先从活动缓存集合移除文件,再把文件引用放入单独的待删除集合,间隔一段时间后执行物理删除。

活动缓存
    → 移除缓存条目
    → 加入待删除集合
    → 等待一段时间
    → 删除物理文件

这样,新的请求不再通过活动缓存取得旧文件,已经取得引用的请求则有一段时间完成读取。

但固定延迟只能降低竞争发生的概率,不能严格保证读取安全。慢客户端、大文件或网络停顿都可能让读取超过预留时间;延迟期间文件也仍然占用磁盘空间。

六、后续如何做到安全回收

要进一步保证安全,删除时机需要从“过了一段时间”转向“已经没有请求使用这个文件”。这属于后续改进方向,不能把它当作当前延迟删除机制已经具备的能力。

可以为缓存条目引入使用计数或读取租约:请求取得文件使用权时登记,读取结束后释放;清理任务先把条目标记为退役,禁止新读者进入,等已有读者全部退出后再删除。

可用:允许新请求取得使用权
    → 退役:拒绝新读者,等待已有读者退出
    → 无读者:执行物理删除

其中,“取得使用权”与“标记退役”必须通过同一套同步机制协调。否则,清理线程可能刚判断使用计数为零,一个新请求就取得了文件,竞争仍然存在。

使用权也必须覆盖完整读取过程,从准备打开文件到流关闭,而不是只覆盖从集合中取出引用的瞬间。成功、异常、超时和客户端断开都需要触发释放。

物理删除失败时,应保留待回收记录并重试。若为了限制退役文件的占用而设置最长等待时间,就需要明确超过时间后的处理方式,不能一边强制删除,一边继续承诺所有读者都能安全完成。

七、用指标验证缓存是否值得保留

缓存减少了部分回源,却也增加了磁盘占用、等待和回收逻辑。它是否带来足够收益,需要指标支撑。

后续应补充的观测包括:

  • 请求命中率与回源次数,判断是否减少了重复下载;
  • 缓存文件总大小、待删除文件大小和磁盘剩余空间,观察容量压力;
  • 回源耗时、失败率、超时次数,以及合并请求的等待时长;
  • 清理耗时、删除失败次数和待回收文件的最长滞留时间。

验证也应覆盖竞争边界:同一文件同时未命中、下载中途失败、文件更新与读取交错、清理与打开文件同时发生,以及客户端断开后能否释放使用权。

没有压测和生产指标时,不宜声称某种淘汰算法性能更好,也不宜把引入缓存直接等同于系统性能提升。

文件缓存的核心,是让下载、读取和回收遵循明确的生命周期。谁负责填充缓存,谁仍在使用文件,以及什么时候能够回收磁盘空间,需要分别做出可靠的判断。