EP.03系統設計

快取策略:
讓系統快 10 倍的關鍵決策

「加快取就好了」是最常見的回答,也是最危險的回答。 Cache-aside?Write-through?TTL 設多長?資料更新時怎麼辦? 這篇帶你搞清楚每一種快取策略的原理與取捨。

Joseph Chen 2026 14 min read Cache · Redis · Cache Invalidation · System Design

「Computer Science 中只有兩件難事:快取失效(Cache Invalidation)和命名。」

— Phil Karlton,廣為流傳的名言

這個笑話之所以成名,是因為它說出了真相:快取很容易加,難的是讓快取裡的資料保持正確。

為什麼要快取?速度差距有多大?

快取的本質是「用空間換時間」:把計算代價高或存取慢的資料,存在更快的地方。 先感受一下各層儲存的速度差距:

L1 CPU Cache
~1 ns
L2 CPU Cache
~4 ns
RAM(主記憶體)
~100 ns
Redis(本地)
~0.1 ms
Redis(網路)
~1 ms
SSD 讀取
~0.1 ms
HDD 讀取
~10 ms
DB 查詢(無索引)
~100 ms
跨區網路請求
~200 ms

數量級差距的實際影響

Redis 讀取(~1ms)vs DB 無索引查詢(~100ms)差 100 倍。 如果你的 API 每次請求都打 DB,加了 Redis 後同樣的硬體可以多撐 100 倍的流量。 這不是優化,是量級的改變。

Redis 常用資料結構

Redis 不只是 key-value store,它有多種資料結構,選對結構能大幅簡化實作。

String vs Hash:最常被問到的選擇題

用 String(JSON 序列化)

適合:整個物件作為一個整體讀取,不需要部分更新

SET user:123 '{"name":"J","age":28}' EX 3600

用 Hash(欄位分開存)

適合:只更新其中一個欄位,不想序列化整個物件

HSET user:123 name "J" age 28

⚡ 實務上,String + JSON 更普遍:程式語言都有 JSON 序列化,且通常整筆讀取。Hash 適合欄位數量多且頻繁部分更新的場景(如用戶設定頁)。

String

SET / GET / INCR / EXPIRE

最基本的快取(JSON 字串化)、計數器(INCR)、Session Token

SET user:123 '{"name":"Joseph"}' EX 3600

Hash

HSET / HGET / HGETALL

用戶資料(欄位可部分更新)、避免每次序列化整個物件

HSET user:123 name Joseph age 28 HGET user:123 name

List

LPUSH / RPUSH / LRANGE / LPOP

訊息佇列(Message Queue)、最近瀏覽記錄(取前 N 筆)

LPUSH recent:user:123 productId LTRIM recent:user:123 0 49 # 只保留最近 50 筆

Set

SADD / SISMEMBER / SUNION

去重(唯一訪客)、標籤系統、好友關係、點讚狀態

SADD post:456:likes userId SISMEMBER post:456:likes userId # 是否已按讚

Sorted Set(ZSet)

ZADD / ZRANGE / ZRANGEBYSCORE

排行榜(分數排序)、延遲隊列(timestamp 排序)

ZADD leaderboard 9800 "Joseph" ZRANGE leaderboard 0 9 WITHSCORES REV # 前10名

Bitmap / HyperLogLog

SETBIT / PFADD / PFCOUNT

Bitmap:簽到記錄(1位=1天);HLL:大規模 UV 統計(允許誤差)

PFADD uv:2026-05-05 userId1 userId2 PFCOUNT uv:2026-05-05 # 約 1% 誤差,省記憶體

快取策略:四種模式

Cache-aside(Lazy Loading)

最常用

應用程式直接管理快取:讀時先查 Cache,沒有才查 DB 並回寫 Cache(稱為 Cache Miss 後填入)。 寫時只寫 DB,快取讓它自然過期(TTL)或主動刪除。

1讀:先查 Redis
Cache Hit→ 直接返回
|
Cache Miss→ 查 DB → 存 Redis → 返回
Cache-aside 實作
async function getUserById(userId: string) {
  const cacheKey = `user:${userId}`;

  // 1. 先查 Redis
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);  // Cache Hit
  }

  // 2. Cache Miss:查 DB
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  if (!user) return null;

  // 3. 回寫 Redis,設 TTL 1 小時
  await redis.setex(cacheKey, 3600, JSON.stringify(user));
  return user;
}

// 更新時:刪除快取(讓下次讀取重新從 DB 載入)
async function updateUser(userId: string, data: Partial<User>) {
  await db.query('UPDATE users SET ... WHERE id = $1', [userId]);
  await redis.del(`user:${userId}`);  // Cache Invalidation
}

✅ 優點

只快取真正被讀取的資料;DB 故障時快取仍可服務部分請求

⚠️ 缺點

Cache Miss 時需要 3 次操作(讀 Cache + 讀 DB + 寫 Cache),首次較慢

Write-through

寫多場景

每次寫入時,同時寫 DB 和 Cache。保持 Cache 與 DB 強一致,但寫入延遲較高。

Write-through 實作
async function updateUserProfile(userId: string, data: Partial<User>) {
  // ⚠️ 不能用 Promise.all 並行!若 DB 失敗但 Redis 成功,
  // Cache 就存著未落地的資料(Dirty Read 的來源)。
  // 正確做法:以 DB 寫入成功為前提,再更新 Cache。

  // Step 1:先寫 DB
  const updatedUser = await db.query(
    'UPDATE users SET name=$2, bio=$3 WHERE id=$1 RETURNING *',
    [userId, data.name, data.bio]
  );

  // Step 2:DB 成功後才更新 Redis
  // 若 Redis 失敗,允許 Cache 暫時過期,TTL 到期後自動重建(可接受短暫不一致)
  try {
    await redis.setex(`user:${userId}`, 3600, JSON.stringify(updatedUser));
  } catch {
    console.error('Cache update failed, will self-heal on next read');
  }

  return updatedUser;
}

✅ 適合

寫後馬上讀的場景;對快取一致性要求高的資料

⚠️ 缺點

寫入延遲翻倍;寫多讀少時快取中大量資料從未被讀取(浪費記憶體)

Write-behind(Write-back)

高吞吐量

寫入只寫 Cache,非同步批次寫入 DB。寫入延遲最低, 適合高頻寫入的場景(遊戲積分、實時計數器)。代價是快取故障時資料可能遺失。

風險提示

Redis Crash 且 AOF/RDB 未開啟的情況下,尚未落地 DB 的資料會永久遺失。 需要搭配 Redis Persistence 機制(AOF appendfsync everysec)與監控告警。 金融、訂單等關鍵資料不應使用此策略。

Read-through

框架整合

與 Cache-aside 流程相同,但由快取庫(而非應用程式)自動處理 Cache Miss 時的 DB 查詢。 應用程式只與快取層互動,不直接調用 DB。 適合使用支援 Read-through 的快取中間件(如 Caffeine + 自定義 CacheLoader)。

快取策略選擇指南

場景推薦策略原因
讀多寫少(用戶資料、商品詳情)Cache-aside只快取實際被讀的資料,省記憶體
寫後立即讀(下單後看訂單)Write-through保持一致性,避免讀到舊資料
超高頻寫(遊戲積分、點擊計數)Write-behind批次落地 DB,降低寫入壓力
需要複雜 Query 結果(JOIN、聚合)Cache-aside(手動 Key 設計)把計算代價高的結果整體快取

快取失效(Cache Invalidation)策略

快取失效是最難的部分。有兩種思路:被動失效(TTL 到期)主動失效(資料更新時刪除)。 兩者各有適用場景。

被動失效:TTL 過期

設定快取存活時間,時間到就自動失效。簡單、不需要程式邏輯, 但在 TTL 內資料可能是舊的。

熱門商品:TTL = 5 分鐘

用戶基本資料:TTL = 1 小時

靜態設定檔:TTL = 24 小時

Session Token:TTL = 15 分鐘

主動失效:事件驅動刪除

資料更新時,主動刪除對應的快取 Key,讓下次讀取重新從 DB 載入最新資料。

// 更新用戶後刪除快取
await db.updateUser(userId, data);
await redis.del(`user:${userId}`);

// 批次刪除(如更新分類下所有商品)
const keys = await redis.keys(`product:cat:${catId}:*`);
if (keys.length > 0) await redis.del(...keys);

三個必須了解的快取問題

快取設計有三個典型的陷阱,都源自「Cache 與 DB 之間的資料斷層」。 理解它們的根因,面試和實作時才能給出正確的解法而不是頭痛醫頭。

快取穿透(Cache Penetration)

大量請求查詢一個「確定不存在於 DB」的 Key,每次都 Cache Miss → 打穿 DB。常見於惡意攻擊。

解法:方案一:快取空值(存 null 並設短 TTL)。方案二:Bloom Filter(先用 bitmap 判斷 Key 是否存在)。
// 快取空值防穿透
const user = await db.getUser(userId);
if (!user) {
  // 快取 null,TTL 設短(避免長時間快取不存在的 Key)
  await redis.setex(`user:${userId}`, 60, 'NULL');
  return null;
}
await redis.setex(`user:${userId}`, 3600, JSON.stringify(user));

快取擊穿(Cache Breakdown)

一個「熱點 Key」快取剛好過期(TTL = 0),此時大量請求同時 Cache Miss 並打到 DB,造成 DB 瞬間壓力。

解法:方案一:互斥鎖(只讓一個請求去查 DB,其他等待)。方案二:熱點 Key 永不過期(邏輯 TTL:存儲期望失效時間,後台非同步更新)。
// 互斥鎖防擊穿(Redis SETNX 實現分散式鎖)
async function getHotDataWithLock(key: string) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const lockKey = `lock:${key}`;
  const lockAcquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);

  if (lockAcquired) {
    try {
      const data = await db.query(...);
      await redis.setex(key, 3600, JSON.stringify(data));
      return data;
    } finally {
      await redis.del(lockKey);
    }
  } else {
    // 未拿到鎖,等一下再讀 Cache
    await new Promise(r => setTimeout(r, 50));
    return getHotDataWithLock(key);
  }
}

快取雪崩(Cache Avalanche)

大量 Key 同時過期(例如系統啟動時統一設置了相同 TTL),造成瞬間大量 DB 請求。

解法:在 TTL 基礎上加入隨機抖動(jitter),讓過期時間分散,避免同時大量失效。
// TTL 加隨機抖動
const baseTTL = 3600;
const jitter = Math.floor(Math.random() * 300);  // 0~5 分鐘隨機
await redis.setex(key, baseTTL + jitter, value);

生產環境 Redis 最佳實踐

Key 命名規範

使用命名空間前綴避免衝突:
user:123
post:456:comments
leaderboard:2026-05
方便 SCAN 按模式批次操作

記憶體淘汰策略

⚠️ 預設值是 noeviction(記憶體滿了直接報 OOM 錯誤)!
生產環境必須顯式設定:

maxmemory-policy allkeys-lru  # 推薦

allkeys-lru:淘汰最久未用的(通用快取場景)
volatile-lru:只淘汰有 TTL 的 Key
noeviction:滿了就報錯(預設,不適合快取)

Pipeline / MULTI-EXEC

批次操作用 Pipeline 減少 RTT:
const pipe = redis.pipeline();
pipe.set(...);
pipe.expire(...);
await pipe.exec();
比逐一操作快 5-10 倍

監控指標

關注以下指標:
· Cache Hit Rate(>90% 才有意義)
· Memory Usage(<80% 可用)
· Evicted Keys(非 0 表示記憶體不足)
· Latency(p99 <5ms)

重點整理

Cache-aside 是預設選擇

讀多寫少的場景幾乎都適用。Cache Miss 時查 DB 並回寫,更新時刪除快取讓 TTL 自然重建。

TTL 加 Jitter 防雪崩

不要統一設置相同的 TTL,加入 ±10% 的隨機值,讓過期時間分散,避免瞬間大量 DB 請求。

空值也要快取

防穿透攻擊:查詢不存在的 Key 也要快取 null(設短 TTL),否則每次都穿透到 DB。

Cache Hit Rate 是核心指標

Hit Rate < 90% 代表快取設計有問題(TTL 太短、Key 設計不合理)。先量後優化。

快取不是銀彈

快取的代價是複雜度:你有兩份資料,要保持一致。一致性需求高的場景(餘額、庫存)要謹慎使用。

面試三問必答

問你快取時,說清楚:① 用哪種策略?② TTL 設多長?③ 如何處理穿透、擊穿、雪崩?

上一篇

EP.02 負載均衡

Cache
Redis
System Design