Redis 快取策略設計模式:後端效能優化完整教學
為什麼後端工程師必須掌握 Redis 快取策略?
每個高流量系統的背後,幾乎都藏著一層精心設計的快取架構。作為一名在生產環境摸爬滾打多年的後端工程師,我見過太多因為快取策略不當導致的系統崩潰——從資料庫被打爆,到使用者資料短暫錯亂。
Redis 不只是「把資料塞進記憶體」這麼簡單。正確的快取設計模式能讓你的 API 回應時間從 500ms 降到 5ms,錯誤的策略則可能在流量高峰時製造出比沒有快取更嚴重的問題。
本文將帶你深入三種核心快取設計模式,以及在實戰中不得不面對的 TTL 策略、快取失效、緩存擊穿(Cache Stampede)等問題,並附上可直接使用的 Node.js 與 Python 程式碼。
三大核心快取設計模式
1. Cache-Aside(旁路快取)
Cache-Aside 是最常見、最靈活的快取模式。應用程式自行負責快取的讀寫邏輯,資料庫與快取之間沒有直接連動。
讀取流程:
- 應用程式先查詢 Redis
- 命中(Cache Hit)→ 直接回傳
- 未命中(Cache Miss)→ 查詢資料庫 → 寫入 Redis → 回傳資料
// Node.js Cache-Aside 實作
const redis = require('ioredis');
const client = new redis();
async function getUserById(userId) {
const cacheKey = `user:${userId}`;
// 1. 嘗試從 Redis 取得
const cached = await client.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 2. Cache Miss:從資料庫查詢
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// 3. 寫入 Redis,設定 TTL 為 3600 秒(1 小時)
await client.setex(cacheKey, 3600, JSON.stringify(user));
return user;
}
async function updateUser(userId, data) {
// 更新資料庫
await db.query('UPDATE users SET ... WHERE id = $1', [userId]);
// 主動刪除快取,確保下次讀取時重新載入
await client.del(`user:${userId}`);
}
Cache-Aside 的優勢在於只快取真正被讀取的資料,且資料庫故障時應用程式仍能運作(只是較慢)。缺點是第一次請求必然 Cache Miss,且讀寫之間存在短暫的資料不一致窗口。
2. Write-Through(同步寫入)
Write-Through 模式在寫入資料庫的同時同步更新快取,確保快取中的資料始終是最新狀態。
# Python Write-Through 實作
import redis
import json
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def update_product(product_id: str, data: dict) -> dict:
"""
Write-Through:同時寫入資料庫與快取
"""
# 1. 寫入資料庫
updated = db.execute(
'UPDATE products SET name=%s, price=%s WHERE id=%s RETURNING *',
(data['name'], data['price'], product_id)
).fetchone()
# 2. 同步更新快取
cache_key = f'product:{product_id}'
r.setex(cache_key, 1800, json.dumps(updated)) # TTL 30 分鐘
return updated
Write-Through 適合讀多寫少、對資料一致性要求較高的場景,例如商品詳情頁。缺點是每次寫入都要同時操作快取,若快取伺服器暫時不可用,需要有降級機制。
3. Write-Behind(非同步寫入)
Write-Behind(又稱 Write-Back)先寫入快取,再非同步批次寫入資料庫。這是追求極致寫入效能時的選擇。
// Node.js Write-Behind 概念實作
const DIRTY_KEY = 'dirty:users';
async function updateUserAsync(userId, data) {
const cacheKey = `user:${userId}`;
// 1. 立即更新 Redis
await client.setex(cacheKey, 3600, JSON.stringify(data));
// 2. 將 userId 加入「待同步」集合
await client.sadd(DIRTY_KEY, userId);
}
// 背景 Worker:每 5 秒批次同步到資料庫
async function syncWorker() {
setInterval(async () => {
const dirtyIds = await client.smembers(DIRTY_KEY);
if (dirtyIds.length === 0) return;
for (const userId of dirtyIds) {
const data = JSON.parse(await client.get(`user:${userId}`));
await db.query('UPDATE users SET ... WHERE id = $1', [userId]);
await client.srem(DIRTY_KEY, userId);
}
}, 5000);
}
Write-Behind 適合高頻寫入場景(如計數器、即時排行榜),但實作複雜度高,且 Redis 故障時未同步的資料有遺失風險。
TTL 策略:快取時間設多長?
TTL(Time To Live)是快取設計中最需要權衡的決策。沒有放諸四海皆準的答案,但有幾個實戰原則:
- 靜態資料(商品分類、系統設定):TTL 設 24 小時以上,配合手動刷新機制
- 使用者資料(個人資料、偏好設定):TTL 設 1-4 小時
- 即時資料(庫存、價格):TTL 設 60-300 秒,或不使用 TTL 改用主動失效
- Session 資料:TTL 與 Session 過期時間一致
另一個進階技巧是隨機化 TTL,避免大量快取在同一時間過期(雪崩效應):
import random
base_ttl = 3600 # 1 小時
jitter = random.randint(-300, 300) # ±5 分鐘隨機偏差
final_ttl = base_ttl + jitter
r.setex(cache_key, final_ttl, json.dumps(data))
緩存擊穿(Cache Stampede)防護
緩存擊穿是高流量系統中最危險的快取問題之一:當一個熱門 Key 過期的瞬間,大量請求同時湧入資料庫,造成資料庫瞬間過載。這個問題在設計 REST API 後端架構時必須提前考慮。
解決方案是分散式鎖(Distributed Lock):
// 使用分散式鎖防止緩存擊穿
async function getWithLock(key, fetchFn, ttl = 3600) {
// 1. 先嘗試讀取快取
const cached = await client.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const lockToken = Date.now().toString();
// 2. 嘗試獲取鎖(NX = 不存在才設定,PX = 毫秒為單位的 TTL)
const acquired = await client.set(lockKey, lockToken, 'NX', 'PX', 5000);
if (acquired) {
try {
// 3. 獲得鎖,查詢資料庫並寫入快取
const data = await fetchFn();
await client.setex(key, ttl, JSON.stringify(data));
return data;
} finally {
// 4. 釋放鎖
const currentToken = await client.get(lockKey);
if (currentToken === lockToken) {
await client.del(lockKey);
}
}
} else {
// 5. 未獲得鎖,短暫等待後重試
await new Promise(resolve => setTimeout(resolve, 100));
return getWithLock(key, fetchFn, ttl);
}
}
另一種更優雅的方案是邏輯過期(Logical Expiration):快取資料永不真正過期,而是在資料中記錄邏輯過期時間,由背景任務負責更新。
快取失效(Cache Invalidation)策略
Phil Karlton 的名言:「電腦科學只有兩個難題:快取失效和命名。」快取失效的核心挑戰在於確保快取資料與資料庫的一致性。
在微服務架構中,這個問題更加複雜。當使用 OpenTelemetry 追蹤分散式請求時,你會發現快取失效的時序問題往往是效能瓶頸的根源。
幾個實戰建議:
- 優先使用刪除而非更新:刪除快取比更新快取更安全,下次讀取時自然重建
- 使用版本號或 Tag 批次失效:
# 使用 Tag 批次失效相關快取
def invalidate_by_tag(tag: str):
"""
刪除所有帶有特定 tag 的快取 Key
例如:tag='category:electronics' 可一次失效該分類下所有商品快取
"""
tag_key = f'tag:{tag}'
keys = r.smembers(tag_key)
if keys:
pipe = r.pipeline()
pipe.delete(*keys) # 批次刪除所有關聯 Key
pipe.delete(tag_key) # 刪除 Tag 索引
pipe.execute()
def cache_with_tag(key: str, data, tag: str, ttl: int = 3600):
"""寫入快取並建立 Tag 索引"""
pipe = r.pipeline()
pipe.setex(key, ttl, json.dumps(data))
pipe.sadd(f'tag:{tag}', key) # 將 Key 加入 Tag 集合
pipe.expire(f'tag:{tag}', ttl)
pipe.execute()
Redis Pipeline 與批次操作
在需要同時操作多個 Key 的場景,使用 Pipeline 可大幅減少網路來回次數(Round Trip Time):
// 使用 Pipeline 批次寫入
async function batchCacheUsers(users) {
const pipeline = client.pipeline();
users.forEach(user => {
pipeline.setex(`user:${user.id}`, 3600, JSON.stringify(user));
});
await pipeline.exec();
console.log(`已批次快取 ${users.length} 筆使用者資料`);
}
這個技巧在建構 Serverless 應用時特別重要。如果你使用 Supabase Edge Functions 搭配 Redis,Pipeline 能顯著降低冷啟動期間的延遲。
監控與調優:快取命中率
快取設計完成後,持續監控是關鍵。最重要的指標是快取命中率(Cache Hit Rate):
# 查看 Redis 快取命中率
redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses'
# 計算命中率:hits / (hits + misses) * 100
# 健康的生產環境通常應維持在 80% 以上
當你的微服務架構擴展,將 Redis 快取與容器化部署結合時,Docker Multi-Stage Build 可以幫助你建立輕量的快取服務映像,而 Kubernetes Gateway API 則能在流量層面為快取服務提供精細的路由控制。
總結:選對模式,事半功倍
Redis 快取策略沒有銀彈,每種模式都有其適用場景:
- Cache-Aside:最通用,適合讀多寫少的一般場景
- Write-Through:適合對一致性要求高、能接受寫入延遲的場景
- Write-Behind:適合極高頻寫入、能接受短暫資料不一致的場景
最重要的是:先測量,再優化。用真實的流量數據決定你需要哪種模式,而不是憑感覺。加上適當的 TTL 隨機化、分散式鎖防止緩存擊穿、Tag 批次失效機制,你的後端效能將脫胎換骨。
繼續閱讀
SQLite Limbo:用 Async Rust 打造的下一代分散式嵌入式資料庫
SQLite Limbo 是一個以 Async Rust 從零重寫的 SQLite 相容嵌入式資料庫,專為邊緣運算、Serverless 與分散式系統設計。
相關文章
你可能也喜歡
探索其他領域的精選好文