文件下載服務接入 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,清理線程隨後清空集合並刪除磁盤文件。請求持有的引用不會因此消失,但它準備打開的文件可能已經不存在。
如果請求已經打開文件,刪除後的行為還會受到操作系統和文件系統語義影響,也不能把某個平臺的表現當作通用保證。
所以,線程安全集合解決的是集合訪問問題,不能替代文件生命週期管理。
當前採用的做法是將邏輯清理與物理刪除分開:先從活動緩存集合移除文件,再把文件引用放入單獨的待刪除集合,間隔一段時間後執行物理刪除。
活動緩存
→ 移除緩存條目
→ 加入待刪除集合
→ 等待一段時間
→ 刪除物理文件
這樣,新的請求不再通過活動緩存取得舊文件,已經取得引用的請求則有一段時間完成讀取。
但固定延遲只能降低競爭發生的概率,不能嚴格保證讀取安全。慢客戶端、大文件或網絡停頓都可能讓讀取超過預留時間;延遲期間文件也仍然佔用磁盤空間。
六、後續如何做到安全回收
要進一步保證安全,刪除時機需要從“過了一段時間”轉向“已經沒有請求使用這個文件”。這屬於後續改進方向,不能把它當作當前延遲刪除機制已經具備的能力。
可以為緩存條目引入使用計數或讀取租約:請求取得文件使用權時登記,讀取結束後釋放;清理任務先把條目標記為退役,禁止新讀者進入,等已有讀者全部退出後再刪除。
可用:允許新請求取得使用權
→ 退役:拒絕新讀者,等待已有讀者退出
→ 無讀者:執行物理刪除
其中,“取得使用權”與“標記退役”必須通過同一套同步機制協調。否則,清理線程可能剛判斷使用計數為零,一個新請求就取得了文件,競爭仍然存在。
使用權也必須覆蓋完整讀取過程,從準備打開文件到流關閉,而不是隻覆蓋從集合中取出引用的瞬間。成功、異常、超時和客戶端斷開都需要觸發釋放。
物理刪除失敗時,應保留待回收記錄並重試。若為了限制退役文件的佔用而設置最長等待時間,就需要明確超過時間後的處理方式,不能一邊強制刪除,一邊繼續承諾所有讀者都能安全完成。
七、用指標驗證緩存是否值得保留
緩存減少了部分回源,卻也增加了磁盤佔用、等待和回收邏輯。它是否帶來足夠收益,需要指標支撐。
後續應補充的觀測包括:
- 請求命中率與回源次數,判斷是否減少了重複下載;
- 緩存文件總大小、待刪除文件大小和磁盤剩餘空間,觀察容量壓力;
- 回源耗時、失敗率、超時次數,以及合併請求的等待時長;
- 清理耗時、刪除失敗次數和待回收文件的最長滯留時間。
驗證也應覆蓋競爭邊界:同一文件同時未命中、下載中途失敗、文件更新與讀取交錯、清理與打開文件同時發生,以及客戶端斷開後能否釋放使用權。
沒有壓測和生產指標時,不宜聲稱某種淘汰算法性能更好,也不宜把引入緩存直接等同於系統性能提升。
文件緩存的核心,是讓下載、讀取和回收遵循明確的生命週期。誰負責填充緩存,誰仍在使用文件,以及什麼時候能夠回收磁盤空間,需要分別做出可靠的判斷。