為什麼需要 Cache

Cache 解的問題是讀多寫少的熱點資料——同樣的 query 每分鐘被打幾千次,但資料幾乎不變。

典型場景:

  • 商品詳情頁(每秒幾千次讀,商品資料一天改幾次)
  • 用戶 permission(每個 request 都要查,但很少變動)
  • 設定值(首頁 banner、feature flag)

不適合 cache 的場景:

  • 即時性要求高的資料(庫存數量、座位餘額)
  • 每個 user 看到的資料不同(個人化內容)
  • 寫多讀少的流水帳資料(transaction log)

三種 Cache 模式

Cache-Aside(最常用)

應用自己管理 cache:讀時先查 cache,miss 了才查 DB,然後寫回 cache。

class ProductService {
  async findById(id: string): Promise<Product> {
    const cacheKey = `product:${id}`;
 
    // 1. 先查 cache
    const cached = await redis.get(cacheKey);
    if (cached) {
      return JSON.parse(cached);
    }
 
    // 2. Cache miss → 查 DB
    const product = await productRepo.findById(id);
    if (!product) throw new NotFoundError(`Product ${id} not found`);
 
    // 3. 寫回 cache,TTL 1 小時
    await redis.set(cacheKey, JSON.stringify(product), 'EX', 3600);
 
    return product;
  }
 
  async update(id: string, dto: UpdateProductDto): Promise<Product> {
    const product = await productRepo.update(id, dto);
 
    // 更新後讓 cache 過期(下次讀取自動重建)
    await redis.del(`product:${id}`);
 
    return product;
  }
}

優點:簡單、只 cache 真正被讀到的資料(lazy loading)、DB 和 cache 短暫不一致但最終一致。
缺點:第一次讀(cold start)要去 DB;高並發下的 Cache stampede 問題(下面說)。


Write-Through

寫 DB 的同時也更新 cache,保持兩者同步。

async update(id: string, dto: UpdateProductDto): Promise<Product> {
  const product = await productRepo.update(id, dto);
 
  // 同步更新 cache(不是刪,是更新)
  await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 3600);
 
  return product;
}

優點:cache 永遠是最新的,讀取不會有 miss(暖 cache)。
缺點:每次寫都要更新 cache,寫頻繁時多了 Redis 操作;如果某個資料只偶爾被讀,也會佔 cache 空間。

適合:讀寫比接近(不是純讀多寫少)、對資料一致性要求高的場景。


Write-Behind(異步)

寫 cache,不立即寫 DB,由後台 worker 異步同步到 DB。

適合:write 很頻繁的計數器(按讚數、瀏覽數),接受最終一致性,不接受每次 +1 都打 DB。

缺點:複雜、cache 掛掉可能丟資料。大部分應用不需要這個——除非你的 write QPS 真的把 DB 打掛了。


Cache Key 設計

pattern:  {entity}:{id}
          {entity}:{field}:{value}
          {entity}:list:{hash_of_query_params}

例:
  product:abc123                      → 單一商品
  user:456                            → 單一用戶
  user:email:alice@example.com        → 用 email 查 user
  products:list:category=books&page=1 → 列表(用 query 參數 hash)
  config:feature-flags                → 全域設定

版本化 key(需要批量 invalidate 時):

// 不是刪所有 product:* 的 key(Redis 的 SCAN 刪 wildcard 很慢)
// 而是讓 version 部分過期
const version = await redis.get('products:version') || '1';
const cacheKey = `product:v${version}:${id}`;
 
// 需要 invalidate 所有商品 cache 時,只需要改 version
await redis.incr('products:version');
// 舊的 cache key 會自然 TTL 過期,不需要手動刪

Cache Stampede(雷群效應)

高流量下,一個 cache key 過期的瞬間,同時有幾百個 request 都 miss cache,一起去打 DB——這叫 Cache Stampede。結果是 DB 瞬間被幾百個重複 query 打垮。

解法一:Probabilistic Early Recompute

在 TTL 到期前就提前 refresh,不等它 expired:

async getWithEarlyRecompute(key: string, fetcher: () => Promise<unknown>, ttl: number) {
  const result = await redis.get(key);
  if (result) {
    const { value, expiresAt } = JSON.parse(result);
    const remainingTtl = expiresAt - Date.now() / 1000;
 
    // 剩餘 TTL < 10% 時,有 30% 機率提前 refresh
    if (remainingTtl < ttl * 0.1 && Math.random() < 0.3) {
      fetcher().then(fresh =>
        redis.set(key, JSON.stringify({ value: fresh, expiresAt: Date.now() / 1000 + ttl }), 'EX', ttl)
      );
    }
 
    return value;
  }
 
  const fresh = await fetcher();
  await redis.set(key, JSON.stringify({ value: fresh, expiresAt: Date.now() / 1000 + ttl }), 'EX', ttl);
  return fresh;
}

解法二:Redis 分散式鎖(最直接)

async getWithLock(key: string, fetcher: () => Promise<unknown>, ttl: number) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
 
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);  // 5 秒鎖
 
  if (!acquired) {
    // 沒拿到鎖,等待後重試(其他 request 正在 rebuild)
    await new Promise(resolve => setTimeout(resolve, 100));
    return this.getWithLock(key, fetcher, ttl);
  }
 
  try {
    const fresh = await fetcher();
    await redis.set(key, JSON.stringify(fresh), 'EX', ttl);
    return fresh;
  } finally {
    await redis.del(lockKey);
  }
}

Cache 邏輯放 Service 還是獨立 Cache Repository?

最常見的問題:cache 的讀寫邏輯要放在 Service 裡,還是包一層 Cache Repository?

放 Service(最常見)

class ProductService {
  async findById(id: string) {
    const cached = await redis.get(`product:${id}`);
    if (cached) return JSON.parse(cached);
 
    const product = await this.productRepo.findById(id);
    await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 3600);
    return product;
  }
}

簡單、直覺,但 Service 開始混入基礎設施邏輯(Redis),測試時要 mock Redis。

Cache Repository(Decorator pattern)

// CachingProductRepository 包住真實的 SequelizeProductRepository
class CachingProductRepository implements ProductRepository {
  constructor(
    private inner: ProductRepository,
    private redis: Redis,
  ) {}
 
  async findById(id: string): Promise<Product | null> {
    const cached = await this.redis.get(`product:${id}`);
    if (cached) return JSON.parse(cached);
 
    const product = await this.inner.findById(id);
    if (product) {
      await this.redis.set(`product:${id}`, JSON.stringify(product), 'EX', 3600);
    }
    return product;
  }
}
 
// DI 組裝時選擇要不要套上 cache
const productRepo = new CachingProductRepository(
  new SequelizeProductRepository(),
  redis,
);
const productService = new ProductService(productRepo);

Service 完全不知道 cache 的存在,可以單獨測試業務邏輯而不需要 mock Redis。

怎麼選:小型專案放 Service 夠了,重要的是要選一個並且統一;中大型專案或有多個 consumer 需要同一份 cache 邏輯時,Cache Repository 的好處才值得投入。


讀寫分離(Read Replica)≠ Cache

容易混淆的概念:讀寫分離是資料庫層的設計——寫操作走 primary DB,讀操作走 replica(同步延遲通常 < 100ms)。目的是分散 DB 負載,不是解決 cache miss。

Cache   → 從記憶體讀,無 DB 查詢,延遲 < 1ms
Read Replica → 還是查 DB,只是查不同的 DB 節點,延遲仍有 10–100ms

兩個可以同時用:寫入走 primary,讀取先查 Redis cache,miss 了才查 read replica。這是高流量系統的標準配置,但對多數應用來說 cache 單獨就夠,不需要讀寫分離。


Redis 資料結構的選型

結構適合 cache 的場景範例
String單一物件 JSONproduct:123 → JSON 字串
Hash物件的部分欄位(節省序列化)user:456 → { name, email, role }
Sorted Set排行榜、Top Nleaderboard → score:userId
Set集合查詢(誰按讚)post:789:likes → { userId }
List最近 N 筆(feed、log)user:123:activity

大部分的 cache 用 String + JSON 就夠了。只有在效能瓶頸確認後才考慮 Hash(可以 HGET 單一欄位,不用反序列化整個 JSON)。


延伸閱讀