結論先講
Cache 的效果取決於你加在哪裡。 讀取密集場景 +6.5 倍(第 21 篇),但 Auth 場景(bcrypt)只有 +5%。加 cache 之前先做壓測找到瓶頸,然後只 cache 瓶頸路徑。Cache 不是撒胡椒粉——不是撒越多越好。
什麼該 cache、什麼不該
該 cache
| 場景 | 原因 | 預期提升 |
|---|---|---|
| 讀取頻繁、很少變動 | 商品列表、文章內容 | 6.5 倍(壓測數據) |
| 計算成本高 | 排行榜、統計報表 | 高(省掉 DB 聚合查詢) |
| 外部 API 回應 | 匯率、天氣、第三方資料 | 中(省掉 HTTP roundtrip) |
不該 cache
| 場景 | 原因 |
|---|---|
| 寫入為主的操作 | Cache 馬上就髒了,invalidation 成本 > cache 效益 |
| bcrypt / CPU-bound | Cache 不了 CPU 計算(第 6 篇) |
| 用戶個人資料(高度個人化) | 每個用戶的 cache 不同,hit rate 低 |
| 即時性要求極高 | 股票報價、即時庫存(cache 延遲不可接受) |
壓測數據的佐證
| Cache 目標 | 提升 | 結論 |
|---|---|---|
| DB 讀取(Post List) | +6.5 倍 | 值得 cache |
| 標準 CRUD(50:50 讀寫) | +25% | 有效果但不驚豔 |
| Auth(bcrypt) | +< 5% | 不值得——瓶頸是 CPU 不是 DB |
Cache-Aside Pattern:最常用也最安全
讀取:
1. 查 Redis → 有就直接回(cache hit)
2. Redis 沒有 → 查 DB → 寫入 Redis → 回(cache miss)
寫入:
1. 寫 DB
2. 刪 Redis 中的對應 key(不是更新,是刪除)
為什麼寫入時「刪除」不是「更新」
如果兩個請求同時更新同一筆資料:
時間軸:
Request A: 更新 DB(name=Alice)
Request B: 更新 DB(name=Bob)
Request A: 更新 Cache(name=Alice)← 比較慢
Request B: 更新 Cache(name=Bob)← 比較快
結果: DB 是 Bob(正確),Cache 是 Alice(錯誤!)
如果用「刪除」:
Request A: 更新 DB(name=Alice)→ 刪 Cache
Request B: 更新 DB(name=Bob)→ 刪 Cache
下次讀取: Cache miss → 從 DB 拿 Bob → 寫入 Cache
結果: DB 和 Cache 都是 Bob(正確)
刪除永遠不會出錯。 最壞情況是多一次 cache miss,不會出現資料不一致。
Cache Invalidation 三種策略
| 策略 | 做法 | 適合 | 風險 |
|---|---|---|---|
| TTL(到期時間) | SET key value EX 300 | 大部分場景 | 過期前可能拿到舊資料 |
| 主動刪除 | 寫入 DB 後刪 cache key | 一致性要求高 | 漏刪就出 bug |
| 事件驅動 | DB 變更 → 發 event → 刪 cache | 微服務 | 最複雜 |
TTL 怎麼設
| 資料類型 | 建議 TTL | 理由 |
|---|---|---|
| 商品列表 | 5-15 分鐘 | 偶爾更新,可以稍微過期 |
| 用戶 session | 30-60 分鐘 | 安全考量 |
| 統計報表 | 1-24 小時 | 不需要即時 |
| 匯率/天氣 | 5-30 分鐘 | 取決於資料來源更新頻率 |
沒有「正確的 TTL」——只有「你的業務能接受多少秒的過期資料」。
Cache Stampede:最危險的 cache 問題
什麼是 stampede
一個熱門的 cache key 過期了。在同一毫秒內,1000 個請求同時發現 cache miss → 1000 個請求同時查 DB → DB 被打爆。
正常: 999 次 cache hit + 1 次 cache miss + 1 次 DB 查詢
Stampede: 0 次 cache hit + 1000 次 cache miss + 1000 次 DB 查詢
防禦方法
1. Mutex Lock(最常用)
async function getWithLock(key) {
let value = await redis.get(key);
if (value) return JSON.parse(value);
// 嘗試拿鎖
const lock = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 5);
if (lock) {
// 拿到鎖:查 DB、寫 cache
value = await db.query(...);
await redis.set(key, JSON.stringify(value), 'EX', 300);
await redis.del(`lock:${key}`);
return value;
} else {
// 沒拿到鎖:等一下再試
await sleep(50);
return getWithLock(key);
}
}2. 提前續期(Background Refresh)
在 TTL 剩 20% 時,背景自動更新 cache,不等到真正過期。
3. 永不過期 + 主動更新
不設 TTL,寫入 DB 時主動更新 cache。適合變更頻率可控的資料。
微服務場景的 Cache 特殊問題
跨服務的 cache invalidation
Service A 更新了商品價格,Service B 的 cache 裡還是舊價格。
解法:
- 共用 Redis:所有 service 連同一個 Redis,A 刪 key,B 下次讀就是新的
- Event-Driven:A 發 event「price.updated」,B 收到後刪自己的 cache
- 短 TTL:不做主動 invalidation,靠 TTL 自動過期(最簡單但最不即時)
每個 service 要不要自己的 Redis
| 做法 | 優點 | 缺點 |
|---|---|---|
| 共用 Redis | 簡單、跨服務 invalidation 容易 | 一掛全掛 |
| 各自 Redis | 隔離性好 | invalidation 要靠 event |
小規模(< 10 個 service)共用 Redis 就好。大規模再考慮分開。
下一篇
Cache 的常見誤用:你可能正在犯的五個錯 — 把整個 DB table 塞進 Redis、cache key 沒有 namespace 導致衝突、忘記 cache warmup 導致部署後 DB 被打爆、cache 比 DB 還大。
本系列文章
完整 68 篇目錄見 系列首頁
← 上一篇:從 MySQL 遷移到 PostgreSQL:實戰踩坑指南 → 下一篇:Cache 的常見誤用:你可能正在犯的五個錯