- 書籍: 系統設計口袋指南:基礎知識 — 可擴展系統的核心構建塊
- 其他由我著作: Go語思維 (2冊書系列) — Go語程式設計完整指南 + Go語六邊形架構
- 我的專案: Hermes IDE |GitHub — 一款供開發者使用,搭配Claude Code和其他AI程式設計工具的IDE
- 我: xgabriel.com | GitHub
有四層快取位於使用者請求與他們查詢的資料列之間。大多數團隊只使用其中兩層(通常是將 Redis 放在資料庫前面,再加上用於靜態資產的 CDN),而將另外兩層視為別人的問題。
那樣你就會得到一個 Redis 層級在做 CDN 應該做的工 作,一個靜默地使用其計劃緩存作為品質墊的數據庫,以及每當一個熱鍵過期時發生的擁擠。每一層回答一個不同的問題。選擇錯誤的層級,你就得為你沒選擇的那一層付出代價。
為何緩存是分層的,而不是單一選擇
模式很重要,因為問題會疊加:
- 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)。
這裡放什麼:公開的 GET 請求、匿名回覆、對成千上萬用戶看起來一樣的內容。商品清單、行銷頁面、公開個人資料頁面、未個人化的搜尋結果、OG 圖片終點、sitemap.xml。除非你對用戶 ID 使用緩存鍵,否則任何根據用戶個人化都是錯誤的。
這裡不放什麼:任何帶有Set-Cookie 在回應中,任何經過驗證的內容,任何具有寫入副作用的內容。如果你的CDN在/api/*上的命中率超過5%,你已經比大多數團隊做得更好了.
第2層:應用程式快取(Redis)
當人們說"快取"時,他們指的是應用程式快取。它位於您的服務行程中,或與服務行程並列的 Redis 中。它捕捉 CDN 不能處理的請求(因為它們需要驗證或個人化),並且無需透過資料庫迴圈即可提供這些請求。
容易部署的模式:
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法則:選擇佔據80%數據庫負載的20%查詢,並優先緩存這些。
什麼不適用:任何需要強一致性(例如某人即將花費的銀行餘額)。任何寫入次數比讀取次數多的內容。任何一個過期讀取比50毫秒延遲更糟糕的情況.
第三層:資料庫快取(物化視圖)
物化視圖是團隊最常忽略存在的層。它們位於資料庫內部,預先計算出昂貴查詢的結果。資料庫將結果存儲為一個表格。讀取操作是 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 的內容。
這裡不放什麼:需要實時結果的情況。物化視圖在刷新之間本質上就是過期的。如果用戶期望立即看到他們的操作反映,這層解決方案不適用。
這裡的陷阱是安靜的:物化視圖的年邁。一個團隊部署了一個,達到了儀表板延遲目標,然後繼續前進。六個月後底層表格的大小增加了三倍,刷新運行時間長達12分鐘,而且這個視圖比新鮮的更常過期。像審計查詢計劃一樣審計刷新持續時間。
第四層:查詢緩存(預備語句計劃緩存)
最深的層也是最難察覺的。每當您的駕駛員發送一條 SQL 語句,資料庫都必須解析它、規劃它、並執行它。前兩個步驟如果使用預備語句可以緩存。
大多數 ORMs 都做得不好,因為它們為每個查詢發送一個新的語句字符串 (WHERE id = 1 vs 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 vsDB::select 與繫結,GORM 的 Raw 與 Where,等等) 之後,再調整其他任何東西。
這裡應該放什麼:熱情 OLTP 查詢。根據 ID 查找,向訂單表中插入,更新客戶最後一次登錄。每個查詢的獲益很小(可能只有一毫秒,最多兩毫秒)。每秒 50,000 個查詢的獲益之間,是兩個數據庫實例與十個之間的區別。
無法處理:具有變動形狀的查詢。如果您的 WHERE 子句根據使用者輸入變更結構,您就無法重用執行計劃,而這是沒問題的。
決策矩陣
「這些數據屬於哪一層」的快速捷徑:
| 數據形狀 | CDN | 應用程式快取 | 物化視圖 | 計劃快取 |
|---|---|---|---|---|
| 靜態資產,匿名 | 是 | 否 | 否 | 否 |
| 公開 GET,無權限驗證 | 是 | 可能 | 否 | 否 |
| 個人檔案,熱門 | 否 | 是 | 否 | 是 |
| 每小時分析資料匯總 | 否 | 可能 (TTL) | 是 | 否 |
| OLTP ID 查詢 | 否 | 是 | 否 | 是 |
| 強一致性讀取 | 否 | 否 | 否 | 是 |
| 即時寫入回饋 | 否 | 否 | 否 | 是 |
「可能」的列是團隊在設計審查中爭論的地方。誠實的答案是「先測量」。如果你的小時級匯總每分鐘被觸發200次,一個物化視圖是值得的。如果它每天只被觸發3次,一個具有30分鐘TTL的Redis緩存就很好,而物化視圖則是過度工程。
每一層的失效策略
每一層都希望有不同的失效故事
CDN. TTL 是大多數團隊的唯一現實選項。您可透過 API 清除特定的 URL,但在高請求率下,清除操作需要數秒才能傳播,您無法依賴它來確保正確性。使用短 TTL(熱端點使用 60 秒,目錄數據使用 5 分鐘),並接受數據的過期。對於長 TTL 的資產,對 URL 進行版本控制 (/static/app.a3f7b2.js),以便新版本是一個新鍵,而不是無效化。
應用程序快取。 事件驅動失效。每一條改變資料行的寫入路徑都會為該資料行的每一個緩存衍生版本呼叫 r.delete(key)。這一切運作良好,直到你有一個寫入到 users 的地方有12個,而某個人又新增了第十三個卻忘了失效。集中化它:每一個寫入都會經過一個儲存庫,該儲存庫在其提交後鉤子(post-commit hook)中觸發失效。
實現的視圖。 設定定期更新。決定您可接受的過期時間(10分鐘?1小時?),然後設定cron。為了在過期時間窗口內降低延遲,可以疊加事件觸發的REFRESH ... CONCURRENTLY,但要小心:更新本身是一個昂貴的操作,您不希望它每分鐘觸發100次。
設計緩存。 您不需要使它失效。數據庫管理它。您的任務是編寫對規劃器來說看起來相同的查詢。
惡手:跨層的驚慌
摧毀分層設計的bug:當一個熱緩存條目同時在多個層級過期時,每個並發請求會同時錯過每個層級,它們都一起衝向源頭.
一個實際版本:一個CDN緩存為/api/homepage 於 12:00:00 到期。千個並發請求錯過 CDN,觸及您的應用程式,錯過應用程式快取(其亦於 12:00:00 到期,因兩者的 TTL 均為 60 秒,且均設定於 11:59:00),觸及資料庫,所有觸發相同的物化視圖重建,而資料庫崩潰。
兩種模式可防止此問題。第一種是 singleflight:將 N 個相同的並發請求合併成一個,並廣播結果:
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()
現在一千個並發請求觸發一次重建。其他的九百九十九個則獲得稍顯過期的值(可接受)或等待50毫秒然後獲取新寫入的值。資料庫只看到一個額外的查詢而不是一千個.
在每一層都應用這個模式。CDNs原生就這樣做。stale-while-revalidate 指令。應用程式快取需要你來設定它。實體視圖設計上就是暫存中重新驗證的。規劃快取沒有這個問題.
星期一要做什么
讓一個請求通過你的系統,標示每個數據片段應該存放在哪個層級。大多數團隊發現他們有一個層級在做三個層級的工作,或者三個層級都在快取同一件事,但有不同的TTL且不一致。
選擇錯誤率與成本比最高的那層。加入它。測量。重複。目標不是使用每一層。而是將每一個資料放在回答其特定問題的那一層,然後確保它們不會爭相前進.
你目前的系統中,哪一層負擔最多工作,哪一層又默默缺席?
如果這有幫助
四層快取堆疊是系統設計面試中會問到的類型,也是你的運營團隊會感謝你發布的類型。系統設計口袋指南:基礎 涵蓋了緩存,收錄在「效能基礎原語」章節中,與負載平衡、佇列和複製模式並列,這些模式決定了你的設計能否在規模上穩定運行。如果這篇文章對你有幫助,那本書在250多頁的建構基礎中,使用了相同的語氣。













