前言
Redis 是十分常見的 In-Memory 資料庫,工作上時不時會碰到它但很少仔細從頭了解它,所以透過撰寫重現一些案例達成更深刻的理解。
為什麼需要快取
快取就是把常存取的資料放在能夠快速獲取的地方,在這個案例中:Redis。和常見資料庫相比,因為 Redis 利用了記憶體作為存儲媒介,所以天生有「快個數量級的讀取優勢」相較於其他常見的資料庫如:Postgres、Mongo。
缺點是記憶體不像硬碟適合長久保存資料,但不管是提高速度、降低延遲、降低負擔或減輕成本⋯⋯快取都是很棒的選擇。
| 階層 | 典型存取時間 |
|---|---|
| CPU L1 Cache | 1 ns |
| CPU L2 Cache | 4 ns |
| CPU L3 Cache | 40 ns |
| Main Memory | 100 ns |
| SSD | 100,000 ns |
| HDD | 10,000,000 ns |
Cache Aside 模式 - 資料庫優先快取輔助
一種簡單常見的應用端快取模式是「Cache Aside」,意味著:
- 讀取:先查詢 Redis
- 命中:回傳快取
- 未命中:查資料庫,再將結果寫入 Redis
- 寫入:直接更新資料庫且同時刪除對應的 Redis 快取,讓下次讀取時重新填充
| 特點 | 說明 |
|---|---|
| ✅ 實作簡單 | 邏輯清晰,應用層完全掌控快取行為 |
| ✅ 容錯性佳 | Redis 掛掉時,系統仍可退回直接查資料庫 |
| ✅ 按需載入 | 只有實際被查詢的資料才會進入快取,節省記憶體 |
| ⚠️ 首次必定 Miss | 冷啟動或快取清除後,第一次請求延遲較高 |
| ⚠️ 短暫不一致 | 從寫入 DB 到刪除快取之間,存在極短暫的髒讀(dirty read)區段 |
async function main() { // --- 讀取 --- // 第一次讀取:Cache Miss,從 DB 載入 // 結果: {"id":1,"name":"Alice","email":"[email protected]"} const user = await getUser(1);
// 第二次讀取:Cache Hit,直接從 Redis 回傳 // 結果: {"id":1,"name":"Alice","email":"[email protected]"} const userCached = await getUser(1);
// --- 寫入 --- // 更新資料,快取會被刪除
// 再次讀取:快取已失效,重新從 DB 載入 const userUpdated = await getUser(1); // 結果: {"id":1,"name":"Alice Wu","email":"[email protected]"}}讀取
async function getUser(userId: number): Promise<string | null> { const cacheKey = `user:${userId}`;
// 1. 先查 Redis const cached = await redis.get(cacheKey); if (cached !== null) { return cached; }
// 2. Cache Miss:查資料庫 const data = await queryDB(cacheKey); if (data === null) return null;
// 3. 回填快取,設定 TTL 避免永久佔用記憶體 const TTL = 60; // 快取 60 秒 await redis.set(cacheKey, data, "EX", TTL); return data;}寫入
async function updateUser(userId: number, newData: object): Promise<void> { const cacheKey = `user:${userId}`; const value = JSON.stringify(newData);
// 1. 更新資料庫 await updateDB(cacheKey, value);
// 2. 刪除舊快取,讓下次讀取重新填充 await redis.del(cacheKey);}為什麼是刪除不是更新快取?
刪除是幂等操作,不用擔心競爭更新。
為什麼一定要更新資料庫再刪除快取?
如果反過來,先刪除快取再更新資料庫,在併發情況下會發生以下問題:
- A 請求寫入,刪除快取
- B 請求讀取,發現快取 miss
- B 到 DB 讀取舊資料
- B 把舊資料寫回 Cache
- A 更新 DB 為新資料
- 結果:快取仍是舊資料
什麼情況仍可能資料不一致?
- 併發讀寫:A 讀取舊快取後 B 删除舊快取
- 快取刪除失敗
可嘗試:重試機制、異步刪除緩存、Double Delete
Read Through 模式 - 快取優先資料庫輔助
Read Through 與 Cache Aside 的讀取流程相似,最大的差異在於:快取填充的責任由應用層轉移到快取層本身。應用程式永遠只和快取溝通,由快取自行決定何時去查詢資料庫。
- 讀取:只查詢快取
- 命中:回傳快取
- 未命中:由快取層自動查資料庫、填充快取,再回傳結果
- 寫入:更新資料庫,快取由 TTL 自然過期(或搭配 Write Through/Write Behind 同步)
| 特性 | 說明 |
|---|---|
| ✅ 應用邏輯簡潔 | 應用程式只需呼叫快取介面,無需自行撰寫處理 Cache Miss 後的回填邏輯。 |
| ✅ 按需載入 | 與 Cache Aside 相同,只有被實際查詢到的資料才會進入快取,節省空間。 |
| ⚠️ 首次必定 Miss | 在冷啟動或 TTL 到期後,第一次請求仍須等待快取層向資料庫(DB)查詢。 |
| ⚠️ 實作耦合度較高 | 快取層需要整合查詢資料庫的程式碼,增加了基礎設施層級的複雜度。 |
| ⚠️ 容錯性較差 | 若快取服務故障,應用程式通常無法自行跳過快取去查詢 DB,必須額外設計降級機制。 |
async function main() { // --- 讀取 --- // 第一次讀取:Cache Miss // 快取層自動向 DB 查詢並填充,應用層無感知 // 結果: {"id":1,"name":"Alice","email":"[email protected]"} const user = await cache.get("user:1");
// 第二次讀取:Cache Hit,直接從快取回傳 // 結果: {"id":1,"name":"Alice","email":"[email protected]"} const userCached = await cache.get("user:1");
// --- 寫入 --- // 直接更新資料庫;快取等待 TTL 自然過期
// TTL 到期後再次讀取:Cache Miss // 快取層重新向 DB 查詢並填充 // 結果: {"id":1,"name":"Alice Wu","email":"[email protected]"} const userUpdated = await cache.get("user:1");}Write Through
更新資料時,快取會同步將資料寫入資料庫,直到兩者都成功才回傳完成。資料一致性最高,但寫入延遲較長。
Write Behind
當更新資料時,快取更新完就立即回傳成功,隨後再非同步批量更新到資料庫。效能極高,適合高併發寫入,但如果快取當機且資料尚未寫入 DB,會有遺失資料的風險。
Refresh-Ahead
在快取過期之前,系統主動提前刷新資料,讓應用程式幾乎永遠都能命中有效快取,避免 Miss 造成的延遲。
- 讀取:只查詢快取
- 命中且資料尚新:直接回傳
- 命中但接近過期:回傳現有快取,同時在背景非同步刷新
- 未命中:退回查詢資料庫並填充快取(行為同 Read Through)
- 寫入:更新資料庫,快取由背景刷新機制維持同步
const TTL = 60; // 快取總存活時間(秒)const REFRESH_THRESHOLD = 0.75; // 超過 75% 存活時間即觸發刷新
async function getUser(id: number) { const cached = await redis.get(`user:${id}`);
if (cached) { const { data, cachedAt } = JSON.parse(cached); const age = (Date.now() - cachedAt) / 1000;
// 快取仍有效,但已進入刷新窗口 → 背景非同步更新,本次請求不等待 if (age > TTL * REFRESH_THRESHOLD) { refreshInBackground(id); }
// 無論是否觸發刷新,本次一律回傳現有快取 return data; }
// Cache Miss:同步查詢 DB 並填充快取 return await loadFromDB(id);}
async function refreshInBackground(id: number) { // 設置鎖,避免多個請求同時觸發重複刷新(race condition) const lockKey = `lock:user:${id}`; const acquired = await redis.set(lockKey, "1", { NX: true, EX: 10 }); if (!acquired) return;
try { await loadFromDB(id); // 查詢 DB 並寫入快取 } finally { await redis.del(lockKey); }}
async function loadFromDB(id: number) { const user = await db.findUser(id); await redis.set( `user:${id}`, JSON.stringify({ data: user, cachedAt: Date.now() }), { EX: TTL } ); return user;}快取災難
快取穿透 (Cache Penetration) - 大量查詢沒有快取的東西
假設用戶狂刷一個連資料庫裡都沒有的資料,請求將會耗費大量資源「穿透」快取直接訪問資料庫。
解方
- 快取空值 (Cache Null Value): 即便資料庫查不到,也把這個 Key 存進 Redis,值設定為 null 或特定標記,並給一個很短的過期時間(TTL),下次再查同一個 ID,Redis 就會直接擋下來。
- 布隆過濾器 (Bloom Filter): 在請求碰到 Redis 之前,先經過一層布隆過濾器。它可以非常高效率的計算「這個 ID 絕對不存在」還是「可能存在」。如果絕對不存在就直接拒絕請求。
快取擊穿 (Cache Breakdown) - 某熱點資料快取突然過期
單個超級熱門的快取在過期的一瞬間所有訪問都直接打在資料庫上瞬間把資料庫壓垮。
- 互斥鎖 (Mutex Lock / Redis SETNX): 發現快取失效時,不要讓所有請求都去查 DB。只讓第一個搶到鎖的請求去查 DB 並寫回快取,其他沒搶到鎖的請求就等待再重試讀取快取。
- 邏輯過期 (Logical Expiration): 就是 Refresh-Ahead ,不設定 Redis 原生的 TTL,而是把過期時間寫在 Value 裡。發現快取「邏輯上」過期時,先回傳舊資料,並在背景非同步去更新資料。
快取雪崩 (Cache Avalanche) - 大量快取同時過期
多個快取同時過期導致所有訪問都打在資料庫上瞬間把資料庫壓垮。
- TTL 加上隨機亂數 (Random Jitter): 在設定過期時間時,不要設死(例如都是 3600 秒),而是加上一個隨機值(例如 3600 + Math.random() * 300 秒),讓快取失效的時間錯開。
- 高可用架構 (High Availability): 部署 Redis Sentinel 或 Redis Cluster,確保一台機器掛了還有備援。
- 限流與降級 (Rate Limiting & Circuit Breaking): 如發現資料庫壓力太大,直接在應用層拒絕部分請求,或是關閉部分非核心功能,死保資料庫存活。
總結
- 快取策略:
- Cache Aside 以資料庫為中心
- Cache Through 以快取為中心
- Write Through 求穩
- Write Behind 求快
- Refresh-Ahead 空間換時間
- 快取災難
- 穿透:大量查詢沒有快取的東西
- 擊穿:某熱點資料快取突然過期
- 雪崩:大量快取同時過期