為什麼需要 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 | 單一物件 JSON | product:123 → JSON 字串 |
| Hash | 物件的部分欄位(節省序列化) | user:456 → { name, email, role } |
| Sorted Set | 排行榜、Top N | leaderboard → score:userId |
| Set | 集合查詢(誰按讚) | post:789:likes → { userId } |
| List | 最近 N 筆(feed、log) | user:123:activity |
大部分的 cache 用 String + JSON 就夠了。只有在效能瓶頸確認後才考慮 Hash(可以 HGET 單一欄位,不用反序列化整個 JSON)。
