- 書籍: システムデザインポケットガイド:基本——スケーラブルなシステムのための核心構造
- 他にも私の著書: Goで考える (2冊シリーズ) — Goプログラミング完全ガイド + Goにおける六角形アーキテクチャ
- 私のプロジェクト: Hermes IDE |GitHub — Claude Codeと他のAIコーディングツールを利用する開発者のためのIDE
- 私: xgabriel.com | GitHub
ユーザーのリクエストと、それが参照している行の間には、4つのキャッシュレイヤーが存在します。多くのチームは2つを使用します(通常はDBの前にRedisを使用し、静的アセットにはCDNを使用)そして残りの2つは他の誰かの問題として扱います。
その結果、CDNが行うべき仕事をRedisの階層が行い、データベースが静かにそのプランキャッシュを品質のクッションとして使用し、そして毎回のホットキーの有効期限切れごとに大混乱が起きることになります。各レイヤーは異なる質問に答えます。間違ったレイヤーを選んでしまうと、選ばなかったレイヤーの代償を払うことになります。
キャッシングがレイヤー化される理由
パターンが重要なのは、質問が積み重なるからです。
- CDNは「オリジンを完全に回避できますか?」と答えます
- アプリケーションキャッシュは「データベースのラウンドトリップを回避できますか?」と答えます
- データベースキャッシュは「結果を再計算する必要はありませんか?」と答えます
- クエリキャッシュは「ステートメントのパースとプランニングを回避できますか?」と答えます
「はい」が得られるたびに下の層がショートサーキットされます。「いいえ」はリクエストを下に渡します。すべての四つの層にヒットするリクエストは、何もヒットしないものとコストとレイテンシ以外は違和感がないはずです。
ミスはこれを単一のレイヤーに統合することです。チームはすべてのものをRedisに押し込んでいますが、それはすでに持っているレイヤーだからです。Redisは優れたアプリケーションキャッシュです。それは悪いCDN、さらに悪い実装されたビュー、そして準備されたステートメントプランナーを全く助けません.
レイヤー1:CDNエッジキャッシュ
CDNはユーザーに最も近くにあります。リクエストを処理する最も安価な場所です。リクエストはあなたのサーバーに到達しません。2026年のトリックは、CDNが.jpgよりも多くのファイルをキャッシュすることです。あなたが教えてくれたら、APIレスポンスもキャッシュします。
ここにCloudflareのキャッシュルールの一例があります。これは公開されている製品リストエンドポイント用です:
// Cloudflare Worker - cache /api/products/* responses for 60s at edge
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const cache = caches.default;
// only cache GETs for the public catalog
if (request.method !== "GET" ||
!url.pathname.startsWith("/api/products/")) {
return fetch(request);
}
// strip auth-affecting query params from the cache key
url.searchParams.delete("trace_id");
const cacheKey = new Request(url.toString(), request);
let response = await cache.match(cacheKey);
if (response) {
return response; // edge hit, never touches origin
}
response = await fetch(request);
if (response.status === 200) {
const cached = new Response(response.body, response);
cached.headers.set("Cache-Control", "public, max-age=60, s-maxage=60");
cached.headers.set("CDN-Cache-Control", "max-age=60");
ctx.waitUntil(cache.put(cacheKey, cached.clone()));
return cached;
}
return response;
},
};
s-maxage は共有キャッシュのディレクティブです。それにより、CDNはレスポンスをどのくらいの時間保持するかが指示されます。ブラウザの行動とは別に。Cache-Control の二つのディレクティブは、CDNに60秒を与えつつ、ブラウザには別のことを伝える(多くの場合 max-age=0, must-revalidate)ことを可能にします。
ここに入るもの:publicなGETリクエスト、匿名のレスポンス、数千のユーザーにとって同じように見えるもの。商品リスト、マーケティングページ、公開プロフィールページ、パーソナライズなしの検索結果、OGイメージエンドポイント、sitemap.xml。ユーザーごとにパーソナライズするものはここでは間違っており、ユーザーIDにキャッシュキーを設定しない限りはダメです.
ここにはないもの:ユーザーIDに関連するものSet-Cookie に記載されている内容、認証されているもの、書き込み副作用があるもの。/api/* の CDNヒット率が5%を超えていれば、既に大多数のチームよりも良い成果を上げていることになります。
レイヤー2:アプリケーションキャッシュ(Redis)
アプリケーションキャッシュとは、「キャッシュ」と言うときに人々が意味するものです。サービスプロセスの中に、あるいはその隣のRedisにあります。CDNが処理できなかったリクエスト(認証されたり、パーソナライズされたりするため)をキャッチし、DBのラウンドトリップなしでそれを提供します.
うまく動作するパターン:
import json
import redis
from typing import Optional
r = redis.Redis(host="cache.internal", port=6379, decode_responses=True)
def get_user_profile(user_id: str) -> dict:
key = f"user:profile:{user_id}"
# try cache first
cached = r.get(key)
if cached is not None:
return json.loads(cached)
# cache miss - hit the DB
profile = db.fetch_one(
"SELECT id, name, plan, created_at FROM users WHERE id = $1",
user_id,
)
# set with a TTL so stale data times out even if invalidation fails
r.setex(key, 300, json.dumps(profile, default=str))
return profile
def invalidate_user_profile(user_id: str) -> None:
# called from any writer that mutates the user row
r.delete(f"user:profile:{user_id}")
スキップされる二つのこと:TTLの安全ネットと書き込み側の無効化。TTLだけでは、書き込み後最高5分間、古い読み出しが漏洩する。無効化だけでは、ネットワークの小さな問題がDELをドロップした場合、永遠に漏洩する。両方が必要だ。ベルト、サスペンダー、そしてベルトに第三の手を持つように。
ここに入るもの:ユーザーごとにデータ、セッション状態、再構築に高コストがかかる計算された集計、毎回再計算したくないが頻繁に変わらないもの。80/20のルール:DBの負荷の20%を占めるクエリの20%を選び、それを最初にキャッシュする。
何でもない:強い一貫性が必要なもの(すぐに使おうとしている銀行口座の残高など)。読むよりも書き込むことが多いもの。古くなった読み出しの方が50msの遅延より悪いもの.
レイヤー3:データベースキャッシュ(実装ビュー)
物化ビューは最もチームが存在しないと忘れがちな層です。それらはデータベースの内部にあり、高コストなクエリの結果を事前に計算しています。データベースは結果をテーブルとして格納します。読み取りは7つのジョインとウィンドウ関数ではなく、O(1)の検索になります。
Postgresの例です。他ではダッシュボードの毎回のロード時に事実テーブルをスキャンするであろう、日次・アカウントごとの収益のロールアップです:
CREATE MATERIALIZED VIEW account_revenue_daily AS
SELECT
account_id,
date_trunc('day', created_at)::date AS day,
sum(amount_cents) AS revenue_cents,
count(*) AS txn_count
FROM transactions
WHERE status = 'settled'
GROUP BY account_id, day;
CREATE UNIQUE INDEX ON account_revenue_daily (account_id, day);
-- refresh policy: every 10 minutes via pg_cron
-- CONCURRENTLY needs the unique index above to work
SELECT cron.schedule(
'refresh-account-revenue',
'*/10 * * * *',
$$REFRESH MATERIALIZED VIEW CONCURRENTLY account_revenue_daily$$
);
REFRESH ... CONCURRENTLYは誰も読まないものです。それがなければ、リフレッシュはACCESS EXCLUSIVEロックを取得し、読み取りをブロックします。それがあると、リフレッシュはシャドウコピーに書き込み、アトミックにスワップします。スワップ中にディスクコストが少し増加します;ダッシュボードのブロックを停止します。
ここに入るもの:4つ以上のテーブル間の結合、基礎データがクエリよりも変化が遅いもの。日次の集計、リーダーボード、検索フラクタス、分析者がCTEを書くであろうもの。
ここに入らないもの:リアルタイムで結果が必要なもの。マテリアライズされたビューはリフレッシュの間常に古くなっている。ユーザーが自分のアクションがすぐに反映されることを期待している場合、このレイヤーは答えではない。
ここでの落とし穴は静かです:物化されたビューの年齢化です。チームが一つをリリースし、ダッシュボードのレイテンシ目標を達成し、進みます。6ヶ月後、下位のテーブルのサイズは3倍になり、リフレッシュは12分かかり、ビューは新鮮なものより stale であることが多いです。クエリプランの監査のようにリフレッシュ期間を監査してください.
レイヤー4:クエリキャッシュ(準備されたステートメントプランキャッシュ)
最も深い層はまた最も見えにくいものです。あなたのドライバーがSQL文を送るたびに、データベースはそれをパースし、プランニングし、実行しなければなりません。最初の2つのステップは、プリペアドステートメントを使用すればキャッシュできます.
ほとんどのORMはこれをうまく行いません。なぜなら、毎回クエリのために新しいステートメント文字列を送るからです(WHERE id = 1 ではなく WHERE id = 2)、キャッシュを無効にします。対処法はパラメータバインディングです:
# bad - new statement every call, plan cache miss every time
def get_order_bad(order_id: int):
return db.execute(f"SELECT * FROM orders WHERE id = {order_id}")
# good - same statement text, only parameters change, plan reused
def get_order_good(order_id: int):
return db.execute(
"SELECT * FROM orders WHERE id = $1",
order_id,
)
Postgresでは、キャッシュされている内容を確認できます
-- requires pg_stat_statements extension
SELECT
query,
calls,
mean_exec_time,
rows
FROM pg_stat_statements
WHERE query LIKE 'SELECT % FROM orders WHERE id = $1'
ORDER BY calls DESC
LIMIT 10;
同じ論理クエリが異なる文字列表現で何度も表示され、$1ではなく、あなたのORMがプランキャッシュをバイパスしている可能性があります。ORM設定を修正してください(EloquentのDB::statementの対)DB::select にはバインディングが付いており、GORMの Raw と Where などが存在します。他の何かをチューニングする前に確認してください。
ここに何が入りますか:ホットなOLTPクエリです。IDによる検索、注文への挿入、顧客の最終アクセス時刻の更新。1クエリあたりの利点は小さい(ミリ秒1つか2つ程度)です。50,000クエリ/秒あたりの利点は、2つのデータベースインスタンスと10つのインスタンスの違いです。
変数の形状を持つクエリは機能しない。WHERE句がユーザー入力に基づいて構造を変更する場合、プランを再利用できず、それで問題はない。
決定基準
「このデータはどの層に属しているか」のショートカット:
| データ形状 | CDN | アプリケーションキャッシュ | 物化ビュー | プランキャッシュ |
|---|---|---|---|---|
| 静的資産、匿名 | はい | いいえ | いいえ | いいえ |
| 公開GET、認証不要 | はい | 未定 | いいえ | いいえ |
| ユーザーごとプロファイル、人気 | いいえ | はい | いいえ | はい |
| 時間ごとの分析結果のまとめ | いいえ | 未定(TTL) | はい | いいえ |
| OLTP ID の検索 | いいえ | はい | いいえ | はい |
| 強い一貫性のある読み取り | いいえ | いいえ | いいえ | はい |
| リアルタイム書き込みフィードバック | いいえ | いいえ | いいえ | はい |
「かもしれない」行はデザインレビューでチームが議論する場所です。正直な答えは「まず測定する」です。あなたの時間ごとの集計が1分間に200回ヒットする場合、マテリアライズされたビューはその価値を証明します。もし1日3回ヒットする場合、30分のTTLを持つRedisキャッシュで十分で、マテリアライズされたビューは過剰設計です。
各レイヤーごとの無効化戦略
各レイヤーは異なる無効化の物語を望む
CDN TTLは大多数のチームにとって唯一現実的な選択肢です。特定のURLをAPI経由でクリーンアップできるが、高いリクエストレートではクリーンアップが数秒かけて propagate され、正確性に頼ることができません。短い TTL を使用してください(ホットエンドポイントには60秒、カタログデータには5分)。古いデータを受け入れます。長い TTL の資産については、URL をバージョン化する (/static/app.a3f7b2.js) ことで、新しいバージョンが新しいキーになるようにし、無効化されないようにします.
アプリケーションキャッシュ。 イベント駆動型の無効化。行を変更するすべての書き込みパスは、その行のすべてのキャッシュされた派生に対してr.delete(key)を呼び出します。これが機能するのは、usersに書き込みがある12箇所がある場合までです。13番目を追加した人が無効化を忘れると、問題が発生します。中央集権化しましょう:すべての書き込みは、コミット後のフックの一部として無効化を発火するリポジトリを通じて行われます。
マテリアライズされたビュー。 定期的リフレッシュをスケジュールします。あなたの受け入れ可能な古さ(10分?1時間?)を決定し、cronを設定します。古さのウィンドウでの低い遅延のためには、イベント駆動のREFRESH ... CONCURRENTLYトリガーを層化しますが、注意してください:リフレッシュ自体が高コストの操作であり、1分に100回発火したくありません.
キャッシュの計画. あなたはそれを無効にしません。データベースがそれを管理します。あなたの仕事は、プランナーにとって同じように見えるクエリを書くことです。
陷阱:各层数据の暴走
層状設計を破壊するバグ:あるヒートキャッシュエントリーが複数の層で同時に有効期限切れになった場合、すべての並行リクエストが各層を同時に見つからず、それらはすべてオリジンに一斉に暴走する.
実際の例:CDNキャッシュ/api/homepage は 12:00:00 に有効期限切れになります。1000件の同時リクエストがCDNをミスし、あなたのアプリケーションをヒットし、アプリケーションキャッシュをミスします(これも有効期限切れです:両方のTTLが60秒で、両方とも11:59:00に設定されていたため)。データベースをヒットし、すべてが同じ物化ビューの再構築をトリガーし、データベースがクラッシュします。
この問題を防ぐ2つのパターンがあります。最初は singleflight です:N個の同じ同時リクエストを1つにまとめ、結果をブロードキャストします。
import (
"context"
"encoding/json"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
var sf singleflight.Group
func GetHomepage(ctx context.Context, rdb *redis.Client) ([]byte, error) {
key := "homepage:v3"
if cached, err := rdb.Get(ctx, key).Bytes(); err == nil {
return cached, nil
}
// singleflight: only the first concurrent miss recomputes;
// every other caller waits on the same future
result, err, _ := sf.Do(key, func() (interface{}, error) {
fresh, err := buildHomepageFromDB(ctx)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(fresh)
// SETEX with a small jitter so 1000 keys don't expire on the same second
rdb.SetEx(ctx, key, payload, ttlWithJitter(60))
return payload, nil
})
if err != nil {
return nil, err
}
return result.([]byte), nil
}
次にロック+古いものを提供を使用します。有効期限が切れた値をキャッシュに保持し、再構築に一つのリクエストがある間にそれを返します。擬似パターン:
def get_with_stale(key: str, fetch_fn, fresh_ttl=60, stale_ttl=600):
payload = r.get(key)
meta = r.get(f"{key}:meta")
if payload and meta and meta == "fresh":
return json.loads(payload)
# try to grab the rebuild lock; if we get it, rebuild
lock_key = f"{key}:lock"
got_lock = r.set(lock_key, "1", nx=True, ex=10)
if got_lock:
try:
fresh = fetch_fn()
r.setex(key, stale_ttl, json.dumps(fresh))
r.setex(f"{key}:meta", fresh_ttl, "fresh")
return fresh
finally:
r.delete(lock_key)
# we didn't get the lock - serve stale if we have it
if payload:
return json.loads(payload)
# no stale, no lock - wait briefly and retry the read
time.sleep(0.05)
payload = r.get(key)
return json.loads(payload) if payload else fetch_fn()
今や千件の並行リクエストが一つの再構築を引き起こす。他の999件は少し古い値(受け入れ可能)を受け取るか、50ms待って新しく書かれたものを拾う。データベースは千件のクエリの代わりに一つの追加クエリを見る.
このパターンを各レイヤーで適用しよう。CDNはネイティブにそれをやっている。stale-while-revalidate の指示。アプリケーションキャッシュはあなたに接続する必要があります。マテリアライズドビューは設計上、再検証中に古いという性質があります。プランキャッシュはその問題を持ちません.
月曜日の予定
システムを通じてリクエストをたどり、各データがどのレイヤーに存在すべきかラベル付けします。多くのチームは、1つのレイヤーが3つの仕事をしていることに気づき、または同じことをキャッシュしている3つのレイヤーがあり、異なるTTLで合意しないことに気づきます。
最もミスコスト比が高いレイヤーを選択し、追加して測定し、繰り返します。目的はすべてのレイヤーを使用することではありません。各データが特定の質問に答えるレイヤーに配置し、それらが競争しないようにすることです。
現在のシステムで最も多くの作業を行っているレイヤーはどれか、静かに欠落しているレイヤーはどれか?
これは役立った면
四層キャッシュスタックはシステム設計の面接で聞かれるようなものであり、オペレーションチームが配信してくれたことを感謝するようなものです。System Design Pocket Guide: Fundamentals は、「パフォーマンスプリミティブ」という章でキャッシングについてカバーしており、ロードバランシング、キューイング、およびリプレーション パターンと共に、あなたの設計がスケールで機能するかどうかを決定するものです。この投稿が役立ったなら、本は250ページ以上の構築ブロック全体で同じ声を保っています。













