おそらく、あなたのAPIは毎日何千回も同じJSONを送信しています。クライアントがGET /v1/products/42を要求し、18KBのデータを受け取り、5分後に再度要求しても、同じ18KBを受け取ります。何も変更されていないのに。それでもあなたは帯域幅、シリアライゼーション、データベースの読み取りに対して料金を支払っています。
HTTPはこの問題をすでに解決しています。Cache-Controlヘッダーは、レスポンスがどれくらいの期間新鮮であるかをクライアントに伝えます。ETagヘッダーは、レスポンスが変更されたかどうかを確認するためのフィンガープリントを提供します。これらを組み合わせることで、繰り返しのリクエストはボディが空の304 Not Modifiedレスポンスに変わり、おまけとして書き込み時の更新の損失を防ぐこともできます。同じアイデアはクライアント側のパターンにも適用されます。ReactでのAPIレスポンスのキャッシュに関する私たちのガイドを読んだことがあるなら、これはその話のサーバーサイド版です。
このガイドでは、HTTPキャッシュの3つのレイヤーを順に説明し、304のラウンドトリップをステップバイステップで示し、no-cacheとno-storeの違いを解き明かし、実際に動作するExpressコードで締めくくります。また、条件付きヘッダーを送信し、304を自分でアサートすることで、Apidogですべてを検証する方法も示します。
HTTPキャッシュの3つの層
APIのHTTPキャッシュは、3つの独立した決定に分かれます。これらを混同すると、チームは問題に直面します。
レイヤー1: 鮮度。クライアントが全く問い合わせずにレスポンスを再利用できる期間はどれくらいか?これがCache-Control: max-age=60です。60秒間、クライアントはキャッシュされたコピーをローカルで提供します。ネットワークトラフィックはゼロです。これは可能な限り最も安価なキャッシュヒットですが、タイマーが期限切れになるまでクライアントが変更を検出できないため、最もリスクが高いとも言えます。
レイヤー2: 検証。レスポンスが古くなった後でも、クライアントはそれを再ダウンロードする必要はありません。以前に渡されたフィンガープリントを送信することで、「これは変更されましたか?」と問い合わせます。リソースが変更されていなければ、ボディなしの304 Not Modifiedで応答します。If-None-MatchとETagを組み合わせたものがこの正確なバージョンであり、If-Modified-SinceとLast-Modifiedを組み合わせたものは、1秒の粒度を持つ古いタイムスタンプベースのバージョンです。
レイヤー3: 無効化。データが変更されたとき、古いコピーはどのように消滅するか?プライベートクライアントキャッシュはmax-ageによって独自に期限切れになります。共有キャッシュやCDNは、明示的なパージ、短いTTL、または鮮度を制限するstale-while-revalidateのようなディレクティブを必要とします。
鮮度が最も節約でき、検証は鮮度が取りこぼすすべてを捕捉し、無効化は両方を正直に保ちます。ほとんどのAPIにはこれら3つすべてが必要です。
304 Not Modifiedのラウンドトリップの仕組み
製品エンドポイントの完全なサイクルを、ステップバイステップで説明します。
初回リクエスト。クライアントは何もキャッシュしていません:
GET /v1/products/42 HTTP/1.1
Host: api.example.com
初回レスポンス。ボディとキャッシュメタデータを返します:
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Content-Length: 18432
クライアントはボディとETagを保存します。次の60秒間は、全くあなたに問い合わせません。
60秒後の2回目のリクエスト。コピーが古くなったため、クライアントは再検証します:
GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
2回目のレスポンス、リソースは変更なし。サーバーは受信したETagを現在のものと比較します。一致するため、次のようになります:
HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
ボディはありません。18KBの代わりに、レスポンスは数百バイトのヘッダーのみです。クライアントはキャッシュされたコピーをさらに60秒間新鮮なものとしてマークし、それを提供します。もし製品が変更されていたら、新しいボディと新しいETagを含む通常の200を返します。ステータスコード自体については、304 Not Modifiedの解説で詳しく説明しました。要するに、304はエラーではなく、キャッシュの指示です。
経済性は単純です。条件付きGETは、依然としてラウンドトリップと現在のETagを計算する作業のコストがかかります。しかし、ペイロードの転送とクライアント側の再解析を排除します。モバイルクライアントがポーリングする大規模なリストエンドポイントでは、これによりAPIのエグレスが日常的に60〜90パーセント削減されます。
APIにとって重要なCache-Controlディレクティブ
Cache-Controlには12を超えるディレクティブがあります。JSON APIの場合、そのうちの5つが最も重要です。
no-store vs no-cache。これは本番APIで最もよくあるキャッシングのバグであり、両方向に影響します。no-storeは「これをいかなるキャッシュにも書き込むな」という意味です。トークン、銀行データ、永続化してはならないPIIなど、本当に機密性の高いペイロードに使用してください。no-cacheは、その名前が示す意味とはほぼ逆です。キャッシュはレスポンスを保存してもよいが、再利用する前に必ずオリジンで再検証しなければならない、という意味です。ETagと組み合わせることで、no-cacheは、クライアントが古いデータを決して表示しないことを保証しつつ、すべてのリクエストで304による節約をもたらします。「安全のために」すべてのものにno-storeを適用するチームは、条件付きリクエストを完全に無効にし、すべての呼び出しでペイロードの全コストを支払っています。
private。レスポンスがエンドユーザーのクライアントのみによってキャッシュ可能であり、共有キャッシュやCDNによってはキャッシュされないことを示します。ユーザーごとに異なるレスポンス(認証済みAPIトラフィックのほとんど)にはprivateを含めるべきです。これがないと、誤って設定されたプロキシが、あるユーザーのアカウントデータを別のユーザーに提供してしまう可能性があります。
max-age。鮮度保持期間を秒単位で指定します。APIの場合、短く設定することを考えましょう。30秒から300秒でほとんどの読み取りエンドポイントをカバーできます。1日分のリクエストをなくすことを目指しているのではなく、バーストやポーリングループを吸収しようとしているのです。
stale-while-revalidate。実用的な中間策です。Cache-Control: max-age=60, stale-while-revalidate=300はキャッシュに対し、最大5分間は古いコピーを提供しつつ、バックグラウンドで更新するように指示します。ユーザーは即座にレスポンスを得られ、オリジンはその後すぐに更新されます。CloudflareやFastlyのようなCDN、そしてブラウザもこれをサポートしています。
認証された読み取りエンドポイントの適切なデフォルトは次のようになります:
Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"
完全な動作仕様はRFC 9111にあり、これはHTTPキャッシングの決定版ドキュメントとしてRFC 7234に取って代わりました。CDNの動作に驚いた場合は、そのRFCに答えがあります。
Strong ETag と Weak ETag
ETagには2種類あり、W/プレフィックスで区別されます。
strong ETag (ETag: "33a64df551425fcc") は、バイト単位での完全な一致を約束します。同じstrong ETagを持つ2つのレスポンスは同一であり、このためstrong ETagはバイトレンジリクエストに対して安全であり、If-Matchによる並行処理制御に必要とされます。
weak ETag (ETag: W/"33a64df551425fcc") は、意味的な同等性を約束します。バイト列は異なるかもしれませんが、フィールドの順序が変わったり、タイムスタンプフィールドが進んだりしても、意味は同じなので、キャッシュはそのコピーを保持できます。
ここで問題となるのが、圧縮ミドルウェアです。Nginxや一部のフレームワークは、レスポンスをその場でgzip圧縮する際に、strong ETagをweak ETagに書き換えます。これは、圧縮されたバイトが元のものと一致しなくなるためです。プロキシの背後で並行処理チェックが不可解に失敗する場合、アプリケーションサーバーがレスポンスを送信した際にはなかったW/プレフィックスを探してみてください。
デフォルトでは、非圧縮ボディで計算されたstrong ETagを使用してください。同じデータの異なる表現を意図的に提供する場合にのみ、weak ETagを使用します。
ETagの生成: ボディのハッシュ vs バージョンカラム
2つの戦略が主流であり、どちらが適切かはコストがどこにかかるかによって決まります。
レスポンスボディのハッシュ。レスポンスをシリアライズし、ハッシュ化(MD5またはSHA-1で十分です。これはフィンガープリントであり、セキュリティ境界ではありません)して引用符で囲みます。これは構築上正確であり、スキーマ変更は不要です。難点は、304を含むすべてのリクエストで完全なレスポンスを構築することです。帯域幅は節約できますが、計算負荷やデータベース負荷は節約できません。
バージョンカラムまたはupdated_at。安価に取得できるデータからETagを導出します。例えば、行のバージョンカウンターからETag: "42-v17"を生成したり、updated_atをハッシュ化したりします。これにより、条件付きリクエストは完全なシリアライズではなく、インデックス付きルックアップ1回で済みます。難点は、結合されたテーブルの変更を含む、レスポンスに影響を与えるすべての変更でバージョンを更新しなければならないことです。1つでも見落とすと、古い304が提供されてしまい、これは目に見えないため最悪のキャッシングバグとなります。
まずはボディハッシュから始めましょう。これはデフォルトで正しい方法です。プロファイリングでシリアライズコストが重要であることが示されたら、ホットなエンドポイントをバージョンベースのETagに移行してください。
楽観的並行性制御のためのETag: If-Matchと412
読み込み時の帯域幅を節約する同じフィンガープリントが、書き込み時の更新損失を防ぎます。
更新損失問題: 2人の管理者が同時に製品42を読み込みます。管理者Aが価格を変更して保存します。管理者Bが30秒後に誤字を修正して保存し、Aの価格変更をBが読み込んだ古い価格で上書きしてしまいます。誰もエラーに気づかず、データは silently (静かに) 誤った状態になります。
解決策は、クライアントが最後に見たバージョンに基づいてすべての更新を条件付きにすることです。
PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
サーバーはIf-Matchをリソースの現在のETagと比較します。一致した場合: 更新を適用し、新しいETagとともに200を返します。一致しない場合、他の誰かが先に変更したため: 412 Precondition Failedで拒否し、データには触れません。クライアントはその後再フェッチし、新鮮なバージョンに変更を再適用して再試行します。厳格なAPIはさらに進んで、If-Matchを省略したPUTに対して428 Precondition Requiredを返し、安全チェックを必須にします。
ETagが存在すれば、これを追加するコストはほとんどかからず、目に見えないデータ破損のバグを、明示的で再試行可能なHTTPステータスに変換します。
CDNとプロキシがこれらのヘッダーで何をするか
共有キャッシュはオリジンとクライアントの間に位置し、独自のルールで同じヘッダーを読み取ります。
privateはレスポンスをCDNキャッシュから完全に除外します。s-maxage=600はブラウザのmax-ageよりも長いまたは短いCDN固有のTTLを設定します。- ほとんどのCDNは条件付きリクエストを使用してオリジンで再検証します。オリジンがIf-None-Matchに対して304で応答した場合、CDNはボディを取得せずに保存されたコピーを更新します。ETagはCDNも安価にします。
- フレームワークが
Varyを正しく送信していることを常に確認してください。1つのURLからJSONとCSVの両方を提供するAPIはVary: Acceptを必要とします。そうしないと、共有キャッシュがCSVをJSONクライアントに渡してしまいます。 - 上記で説明したように、プロキシが圧縮によってETagを弱体化させることに注意してください。
Expressの例: ETagの返却とIf-None-Matchの処理
Expressは独自にweak ETagを設定しますが、手動で処理することでstrong ETagと412の書き込みパスを提供できます:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.json());
function etagFor(payload) {
const hash = crypto.createHash("sha1")
.update(JSON.stringify(payload))
.digest("hex");
return `"${hash}"`;
}
app.get("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const etag = etagFor(product);
res.set("Cache-Control", "private, max-age=60, stale-while-revalidate=120");
res.set("ETag", etag);
if (req.get("If-None-Match") === etag) {
return res.status(304).end(); // fingerprint matches: no body
}
res.json(product);
});
app.put("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const currentEtag = etagFor(product);
const ifMatch = req.get("If-Match");
if (!ifMatch) {
return res.status(428).json({ error: "If-Match header required" });
}
if (ifMatch !== currentEtag) {
return res.status(412).json({ error: "Resource changed since you fetched it" });
}
const updated = await db.products.update(req.params.id, req.body);
res.set("ETag", etagFor(updated));
res.json(updated);
});
304の分岐でもCache-ControlヘッダーとETagヘッダーが送信されていることに注意してください。RFC 9111によれば、304は保存されたレスポンスのメタデータを更新するため、クライアントがコピーを新鮮に保つために必要なものはすべて再送信します。
Apidogでのキャッシュ動作の検証
正しく見えるコードでも、ミドルウェアやプロキシが関与すると誤ったキャッシュ動作をする可能性があります。コードレベルではなく、HTTPレベルでテストしてください。
Apidogでは、手動チェックは1分程度で完了します:
GET /v1/products/42を送信し、レスポンスヘッダーパネルを開きます。ETagとCache-Controlが存在し、ETagが引用符で囲まれていることを確認します。ETagの値をコピーします。- 同じリクエストで、コピーした値を
If-None-Matchヘッダーとして追加し、再度送信します。空のボディを持つ304が返されるはずです。まだ200が返される場合、検証レイヤーがフィンガープリントを比較していません。 - レコードを変更し、再送信して、新しいETagとともに200に戻ることを確認します。
デプロイ後もこれが動作し続けるように、同じフローをテストシナリオに組み込みます。2つのリクエストを連結します。最初のリクエストはレスポンスヘッダーからETagを変数に抽出し、2番目のリクエストはそれをIf-None-Matchとして送信し、ステータスが304でボディが空であることをアサートします。書き込みパス用に3番目のステップを追加します。意図的に古いIf-Match値(例: "deadbeefcafe1234")を含むPUTを送信し、412をアサートします。APIアサーションに関するガイドでは、ステータスコードとヘッダーのアサーション構文について説明しています。
そのシナリオをCIで実行すると、ETagをサイレントに削除するミドルウェアのアップグレードが、帯域幅の請求書ではなく失敗したパイプラインになります。Apidogを無料でダウンロードして、独自のエンドポイントに対してシナリオを構築してください。読み込むよりもクリックして構築する方が時間がかかりません。
よくある質問
no-cacheとno-storeの違いは何ですか?
no-storeはキャッシュを完全に禁止します。ディスクやメモリに何も書き込まれないため、すべてのリクエストで完全なレスポンスがダウンロードされます。no-cacheは保存を許可しますが、再利用する前に毎回再検証を強制するため、ETagと組み合わせると304レスポンスとペイロードの節約をもたらします。no-storeは機密データにのみ使用してください。すべてに使用することは、APIチームが犯しうるCache-Controlの最も高価な間違いです。
ETagはPOSTで機能しますか?
ほとんどの場合、いいえ。そしてそれは設計によるものです。ETagはURL上のリソースの状態を記述するものであり、POSTは通常、安定した状態を読み取るのではなく、新しいものを作成します。キャッシュは実際にはPOSTレスポンスをキャッシュしません。書き込みにとって重要な条件付きヘッダーは、PUT、PATCH、DELETEにおけるIf-Matchであり、ETagが更新損失を防ぎます。POSTレスポンスをキャッシュしたいという誘惑に駆られる場合、それは通常、その操作がGETであるべきであるという兆候です。
304レスポンスはAPIを高速化しますか?
転送量を減らしますが、それは高速化とは異なります。サーバーは依然としてリクエストを受信し、認証を実行し、現在のETagを計算するため、オリジンのCPU節約は、そのフィンガープリントをどれだけ安価に導き出せるかに依存します。メリットは、帯域幅、モバイルバッテリー、低速ネットワークでのレンダリング時間で現れます。前後で測定してください。APIパフォーマンステストガイドでは、レイテンシとスループットをベンチマークする方法を示しており、推測ではなく違いを証明できます。
ETagとLast-Modifiedのどちらを使うべきですか?
可能であれば両方送信してください。ETagの方がより正確です。タイムスタンプが見落とす1秒未満の変更やコンテンツレベルの違いを捕捉し、If-None-Matchは両方が到着した場合にIf-Modified-Sinceよりも優先されます。Last-Modifiedは、古いクライアントへのフォールバックとして、また一部のキャッシュが鮮度を推定するために使用するヒューリスティックとして依然として有用です。もし1つだけ送るなら、ETagを送信してください。
