OSS や S3 に接続するダウンロードサービスでは、短時間に同じファイルが何度も要求され、そのたびに上流から取得し直すことがあります。
重複取得を減らすため、ローカルファイルキャッシュを導入しました。内容はディスクに保存し、JVM メモリで参照とメタデータを管理して、後続要求は取得済みのファイルを再利用します。
課題は清掃周期だけではありません。同時にキャッシュミスしたら誰が取得するのか、上流の更新をどう判断するか、清掃時に読み取り中の要求をどう扱うか。この文章では、採用した方法と限界、今後必要な仕組みを整理します。
1. バックエンドを通す要求を決める
用途ごとに転送方式を分けました。
| 用途 | 方式 | 注意点 |
|---|---|---|
| 画像プレビュー | バックエンドのストリーミング | バッファ上限、切断、上流タイムアウト |
| PDF プレビュー | 認可後に署名付き URL を発行し直接取得 | 有効期限、権限範囲、ブラウザー表示用ヘッダー |
| ファイルダウンロード | HTTP Range | 範囲検証、再開、複数要求間の版の一致 |
署名付き URL は期限内に使える一時的な資格情報です。通常は再利用でき、必ず一度だけ使えるものではありません。発行前の認可は必要です。
ストリーミングはメモリ使用を抑え、Range はバイト範囲を選び、キャッシュは重複取得を減らします。それぞれ異なる問題を解決するため、組み合わせられます。
プロキシ転送では、全体をメモリやディスクへ取得し終える前に、上流から読みながら返すこともできます。ただし今回のキャッシュ充填では、待機者は取得完了後にファイルを再利用します。
2. 同じファイルの取得を一つにまとめる
複数要求がミス時に個別取得すると、上流帯域とローカル I/O を重複消費します。
採用したのは、最初の要求が取得し、残りは待機して完了後に同じファイルを読む方式です。同一キーの要求統合、または singleflight と呼ばれます。
同じファイルへの複数要求
→ 最初の要求が取得 → 他は待機
→ 完了後に同じファイルを再利用
全ファイルを一つのグローバルロックに集めず、ファイルキー単位で制御します。異なるファイルは独立して取得でき、同じファイルは結果を共有します。版を区別するならキーにも版情報を含めます。
正常系に加え、次の条件を確認する必要があります。
- 失敗を待機者に返し、永久待機させない。
- 待機に期限を設け、一人のキャンセルで共有取得を不用意に止めない。
- 成功・失敗の両方でロード中の状態を解除する。
- 異なるファイルの同時ミスにも上流並行数の上限を設け、接続・帯域・ディスクを守る。
単にロックを追加しただけで、これらが保証されるわけではありません。
書きかけのファイルを完成品として公開してはいけません。今後は一時ファイルへ取得して完了後に公開する方法が考えられます。リネームで公開する場合はファイルシステムの原子性も確認します。
3. 定期清掃と LRU・LFU の判断
今回は短期間の再アクセスを想定し、実装・保守の簡単さから定期清掃を採用しました。
要求統合と追い出し方式は別の問題です。前者は誰が取得するか、後者は何を残すかを決めます。LRU や LFU でも同一キーの取得を一つにできます。
したがって、ロック競合が大きいから定期清掃が必ず優れるとは言えません。コストは実装、頻度、並行数に依存し、測定が必要です。
期限による定期回収は簡単ですが、実行間隔の間に新しいファイルがディスクを埋める可能性があります。
今後は期限と容量を併用し、総容量、単一ファイルの上限、空き容量の閾値を設定すべきです。不足時には新規充填の拒否や明確なルールによる早期削除を行います。LRU・LFU を追加するかは、サイズ分布、アクセスの偏り、命中率で判断します。
4. ETag で更新を判断する
取得済みファイルが永久に正しいとは限りません。上流で上書きされると、古い内容を返してしまいます。
更新判断には ETag 検証を導入しました。ETag はサーバーの検証識別子であり、常にファイルの MD5 とは限りません。S3 のマルチパートアップロードや一部の暗号化では、全体の MD5 と一致しません。
何で変化を判定するかに加え、いつ判定するかも必要です。毎回上流へ確認すると往復とリクエスト料金が残り、周期的に確認するなら一定時間の古さを許容します。更新頻度と許容できる古さで周期を決めます。
検証と取得の間に更新される可能性もあります。ETag 取得後の通常 GET では同じ版とは保証できません。条件付き要求やバージョン ID で内容と版を一致させる方法が考えられます。
ローカル上書きも読み取り中の要求へ影響します。新しい版を別ファイルへ保存し、完成後に参照を切り替え、旧版を回収待ちにするのが今後の改善方向です。
5. 無効化と物理削除は別の判断
最も注意すべきなのは、清掃と読み取りの競合です。
参照は CopyOnWriteArrayList で管理していました。走査側は集合のスナップショットを読めますが、中身は File 参照です。Java の File は主にパスを表し、物理ファイルの存続を保証しません。
要求が参照を得た直後に清掃が集合とディスクのファイルを削除すると、参照は残ってもパスを開けない場合があります。既に開いている場合の挙動も OS とファイルシステムに依存し、一つの環境の挙動を一般化できません。
スレッドセーフな集合は、ファイルの寿命管理の代わりにはなりません。
採用した方法は、論理的な除外と物理削除の分離です。活動中の集合から除外して削除待ち集合へ移し、一定時間後に削除します。
活動中キャッシュ → エントリー除外 → 削除待ち
→ 猶予期間 → 物理削除
新しい要求は旧ファイルを取得せず、既に参照を持つ要求には読み終える時間ができます。ただし固定猶予は競合の確率を減らすだけです。遅いクライアント、大きなファイル、通信停止で超過し、その間の容量も消費します。
6. 安全な回収に向けて
厳密な安全性には、時間ではなく「利用者がいないこと」に基づく削除が必要です。これは今後の改善で、現在の遅延削除が既に持つ能力ではありません。
使用カウントや読み取りリースを設け、要求が利用権を取り、読み終わったら解放します。清掃はまず退役させて新規読者を拒否し、既存読者がいなくなってから削除します。
利用可能:新規読者を受け入れる
→ 退役:新規を拒否し既存読者を待つ
→ 読者ゼロ:物理削除
利用権取得と退役は同じ同期機構で調整しなければなりません。ゼロと判断した直後に新規読者が入れば競合は残ります。
利用権は参照取得の瞬間だけでなく、ファイルを開く準備からストリームを閉じるまで保持し、成功、例外、タイムアウト、切断で解放します。
削除失敗時には記録を残して再試行します。容量保護で退役の最大時間を設けるなら、超過時の意味も明確にします。強制削除と全読者の安全な完了を同時には約束できません。
7. 指標で価値を確かめる
キャッシュは取得を減らす一方、容量、待機、回収処理を増やします。次の観測が必要です。
- 命中率と上流取得回数。
- 活動中・削除待ちの容量とディスク空き容量。
- 取得時間、失敗率、タイムアウト、統合された要求の待機時間。
- 清掃時間、削除失敗、最長回収待ち時間。
同一キーの同時ミス、取得中の失敗、更新と読み取り、清掃とオープンの競合、切断時の利用権解放も検証します。
負荷試験や運用指標なしに、特定の方式が高速、またはキャッシュが必ず性能向上をもたらすとは言えません。取得、読み取り、回収のライフサイクルを明確にし、それぞれを信頼できる方法で調整することが中心になります。